Files
claude-code-gnome-extension/hooks/install.py
T
av d48a18ae75 Ask the session what is running instead of counting it
A session whose main agent had stopped while a background subagent worked
showed as waiting. The count was kept by hand -- +1 on PreToolUse matched
to ^(Agent|Task)$, -1 on SubagentStop -- and the two halves do not see the
same thing. A launch reaches the hook only at the top level: a subagent
spawning its own subagents does it through events carrying agent_id, which
are dropped. SubagentStop arrives for every subagent at every depth. Each
nested one subtracted from a batch it had never joined.

Measured on the live session that showed it: one background agent, then
fourteen nested stops, the first of which took the count to zero and turned
the chip white with the batch still running. Replaying those recorded
events through the old hook reproduces it exactly, and through the new one
holds busy throughout, rising to two while two background agents ran.

The events carry the answer themselves. Stop and SubagentStop -- the two
that can end a turn, and the only two where it matters -- come with
background_tasks: every running task with its type and status. The count is
now read from there and nothing accumulates, so it cannot drift, and a
subagent that dies without sending SubagentStop no longer leaks a count
that pins the chip at busy. Events without the field leave the stored value
alone, which is what keeps an idle_prompt nudge from freeing a working
session.

A background shell is deliberately not counted. A dev server left running
says nothing about whether the session needs you, and treating it as work
would hold the chip at "working" for as long as it lives.

PreToolUse is no longer registered: counting was the only thing it was for.
It stays listed as a legacy event so that both install and uninstall sweep
it out of settings.json rather than leaving it there to spawn the hook on
every agent launch for nothing.
2026-08-23 09:19:30 +03:00

128 lines
4.7 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 shlex
import shutil
import stat
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).
EVENTS = {
"SessionStart": (True, ""),
"UserPromptSubmit": (True, ""),
"Notification": (True, ""),
"PostToolUse": (True, ""),
"PreCompact": (True, ""),
"SubagentStop": (True, ""),
"Stop": (False, ""),
"SessionEnd": (False, ""),
}
# Registered once, no longer. PreToolUse existed to count subagents as they
# started; the count now comes from the snapshot the events carry themselves
# (see running_agents in the hook). Listed rather than forgotten because both
# install and uninstall sweep these out: an entry left behind would go on
# spawning the hook on every agent launch for nothing, and an uninstall that
# leaves our registrations in settings.json is not an uninstall.
LEGACY_EVENTS = ("PreToolUse",)
def entry(spec):
async_, matcher = spec
# Claude Code runs the command through a shell, so a repository path
# containing a space -- or worse -- has to survive the trip.
hook = {"type": "command", "command": shlex.quote(HOOK), "timeout": 5}
if async_:
hook["async"] = True
return {"matcher": matcher, "hooks": [hook]}
def is_ours(group):
return any(
h.get("command", "").rstrip("'\"").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:
try:
path = sys.argv[sys.argv.index("--settings") + 1]
except IndexError:
sys.exit("--settings needs a path")
# Written through, not over: a settings.json symlinked out of a dotfiles
# repository would otherwise be replaced by a regular file, silently
# detaching it from the repository that is supposed to manage it.
path = os.path.realpath(path)
os.makedirs(os.path.dirname(path), exist_ok=True)
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))
# Kept from the first run only. Overwriting it on every run would, on the
# second run, replace the pristine copy with the already-modified one --
# which is exactly when someone reaches for a backup.
if os.path.exists(path) and not os.path.exists(path + ".bak"):
shutil.copyfile(path, path + ".bak")
shutil.copymode(path, path + ".bak")
hooks = settings.setdefault("hooks", {})
for event in list(EVENTS) + list(LEGACY_EVENTS):
groups = [g for g in hooks.get(event, []) if not is_ours(g)]
if not uninstall and event in EVENTS:
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")
# A replace discards the original's mode. settings.json may hold API keys
# and may have been deliberately narrowed to 0600; silently widening it to
# the umask default would undo that without a word.
try:
os.chmod(tmp, stat.S_IMODE(os.stat(path).st_mode))
except OSError:
pass
os.replace(tmp, path)
print("%s %s in %s" % ("removed" if uninstall else "installed", HOOK, path))
print("running sessions pick this up on their own; no restart needed")
if __name__ == "__main__":
main()