Files
claude-code-gnome-extension/hooks/install.py
T
av 5398f32826 Count subagents, and keep a batch from looking free
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.
2026-08-09 19:26:25 +03:00

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()