Corrected from the previous commit, which had it backwards. When a batch is running the session is working, not waiting: the main agent will pick the results up and consolidate them itself, so sending you to that terminal wastes the trip. That is the common shape of the work here -- ask for a batch, let it run. Simply letting subagent tool calls set "busy" would mostly work and was tempting, but it leaves a hole. Stop fires before the batch finishes, so the session shows as free from the moment the turn ends until the first subagent tool call lands -- and longer whenever the subagents are thinking rather than calling tools. So subagents are counted instead: PreToolUse, matched to ^(Agent|Task)$ +1 SubagentStop -1 UserPromptSubmit reset to 0 While the count is above zero the session cannot read as waiting; Stop and an idle_prompt nudge both leave it working. The session is freed by the last subagent leaving, and only if the main agent has stopped by then. The matcher is anchored because it is a regex: a bare "Task" also matches TaskCreate and friends, which are not subagents. The hook re-checks the tool name itself in case a future matcher behaves differently, and the reset on UserPromptSubmit bounds a count that leaks because a subagent died without its SubagentStop. Measured, not assumed: a matched PreToolUse fires only on agent launches, SubagentStop arrives once per subagent carrying agent_id, and a real three-subagent run walks the count 0-1-2-3-2-0 before Stop frees it. The menu shows the number, as asked. The panel does not: a batch of eight is still one line saying "working 40 min", which is the right line.
103 lines
3.5 KiB
Python
Executable File
103 lines
3.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Register (or remove) the status hooks in ~/.claude/settings.json.
|
|
|
|
Merges into the existing file rather than rewriting it: settings.json holds
|
|
unrelated user configuration, and entries for other tools must survive both
|
|
install and uninstall. Ownership is tracked by the command path, so a repo
|
|
moved to a new location cleanly replaces its old registration.
|
|
|
|
Usage: install.py [--uninstall] [--settings PATH]
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude-status-hook.py")
|
|
|
|
# Stop and SessionEnd are deliberately synchronous. Both fire as claude is
|
|
# about to go quiet or exit, and an async child racing that exit gets killed
|
|
# before it writes -- observed with `claude -p`, which left a session stuck at
|
|
# "busy" forever. They fire at most once per turn, so ~20 ms is free; the
|
|
# per-tool-call events stay async so they never sit in the agent's loop.
|
|
# (async, matcher). PreToolUse is matched to the agent-spawning tools alone:
|
|
# unmatched it would fire on every tool call in every session, and the only
|
|
# thing it is here for is to count subagents as they start. The pattern is
|
|
# anchored because it is a regex -- a bare "Task" also matches TaskCreate,
|
|
# TaskUpdate and friends, which are not subagents. The hook re-checks the name
|
|
# anyway, in case a future matcher works differently.
|
|
EVENTS = {
|
|
"SessionStart": (True, ""),
|
|
"UserPromptSubmit": (True, ""),
|
|
"Notification": (True, ""),
|
|
"PreToolUse": (True, "^(Agent|Task)$"),
|
|
"PostToolUse": (True, ""),
|
|
"PreCompact": (True, ""),
|
|
"SubagentStop": (True, ""),
|
|
"Stop": (False, ""),
|
|
"SessionEnd": (False, ""),
|
|
}
|
|
|
|
|
|
def entry(spec):
|
|
async_, matcher = spec
|
|
hook = {"type": "command", "command": HOOK, "timeout": 5}
|
|
if async_:
|
|
hook["async"] = True
|
|
return {"matcher": matcher, "hooks": [hook]}
|
|
|
|
|
|
def is_ours(group):
|
|
return any(
|
|
h.get("command", "").endswith("claude-status-hook.py")
|
|
for h in group.get("hooks", [])
|
|
if isinstance(h, dict)
|
|
)
|
|
|
|
|
|
def main():
|
|
uninstall = "--uninstall" in sys.argv
|
|
path = os.path.expanduser("~/.claude/settings.json")
|
|
if "--settings" in sys.argv:
|
|
path = sys.argv[sys.argv.index("--settings") + 1]
|
|
|
|
try:
|
|
with open(path) as fh:
|
|
settings = json.load(fh)
|
|
except FileNotFoundError:
|
|
settings = {}
|
|
except ValueError as exc:
|
|
sys.exit("refusing to touch malformed %s: %s" % (path, exc))
|
|
|
|
if os.path.exists(path):
|
|
shutil.copyfile(path, path + ".bak")
|
|
|
|
hooks = settings.setdefault("hooks", {})
|
|
for event in EVENTS:
|
|
groups = [g for g in hooks.get(event, []) if not is_ours(g)]
|
|
if not uninstall:
|
|
groups.append(entry(EVENTS[event]))
|
|
if groups:
|
|
hooks[event] = groups
|
|
else:
|
|
hooks.pop(event, None)
|
|
if not hooks:
|
|
settings.pop("hooks", None)
|
|
|
|
# Replaced atomically rather than truncated in place: claude re-reads
|
|
# settings.json as it changes, so a running session can be reading this
|
|
# exact file, and a truncate-then-stream write hands it invalid JSON.
|
|
tmp = "%s.%d.tmp" % (path, os.getpid())
|
|
with open(tmp, "w") as fh:
|
|
json.dump(settings, fh, indent=2)
|
|
fh.write("\n")
|
|
os.replace(tmp, path)
|
|
|
|
print("%s %s in %s" % ("removed" if uninstall else "installed", HOOK, path))
|
|
print("restart running claude sessions for the change to take effect")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|