A subagent's tool calls do reach the parent session's hooks: measured, a PostToolUse arrives carrying agent_id and agent_type. Only Stop was guarded against that, and Stop was the case that mattered least. With background subagents the ordering is the harmful one. The main agent ends its turn first, so Stop lands and the session reads "waiting"; the subagents keep working, and their PostToolUse arrives afterwards and puts the session back to "busy". The panel then says a session is working when its input line is free and it is waiting for you -- the precise confusion this indicator exists to prevent, and reported from a live session doing exactly that. Every event carrying agent_id is now ignored. Synchronous subagents lose nothing: the main agent is mid-turn, so its own earlier events already say "busy". Notification is deliberately exempt. It means a human is needed, and that is as true when the agent that got stuck is a subagent -- ignoring it would leave a session silently blocked. Covered both ways in the hook tests, and checked once against a real subagent event captured from a live run rather than a hand-written one.
312 lines
11 KiB
Python
Executable File
312 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Claude Code hook -> per-session state file for the GNOME panel indicator.
|
|
|
|
Reads one hook event as JSON on stdin and maps it to a session state, written
|
|
to $XDG_STATE_HOME/claude-code-status/<session_id>.json. The GNOME extension
|
|
watches that directory; no polling, no daemon, no socket.
|
|
|
|
Design notes that are easy to get wrong:
|
|
|
|
* Writes are skipped when the state does not change. PostToolUse fires on every
|
|
tool call, and its only job here is to clear "blocked" once a permission has
|
|
been granted -- letting it rewrite the file each time would make the directory
|
|
monitor fire hundreds of times per turn for no new information.
|
|
|
|
* Hooks are registered async, so two events can race (the last PostToolUse of a
|
|
turn against that turn's Stop). The whole read-decide-write runs under a file
|
|
lock and an event older than the stored one is refused; without both, a late
|
|
"busy" buries "waiting" and the panel claims a session is working while it
|
|
actually waits for input.
|
|
|
|
* No stdlib import beyond what is needed: this runs once per tool call.
|
|
"""
|
|
|
|
import fcntl
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
STATE_DIR = os.path.join(
|
|
os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"),
|
|
"claude-code-status",
|
|
)
|
|
|
|
# Notification covers several unrelated things; only some mean "the session
|
|
# stopped and is asking me something". auth_success / elicitation_complete /
|
|
# elicitation_response are progress chatter and must not touch the state.
|
|
# Observed: a question put to the user (AskUserQuestion) arrives as
|
|
# "permission_prompt" too, with the same generic message as a tool asking to
|
|
# run. The two are therefore not separable here, which is why the panel has one
|
|
# "blocked" state rather than telling a permission from a question.
|
|
NOTIFICATION_STATES = {
|
|
"permission_prompt": "blocked",
|
|
"agent_needs_input": "blocked",
|
|
"elicitation_dialog": "blocked",
|
|
"idle_prompt": "waiting",
|
|
"agent_completed": "waiting",
|
|
}
|
|
|
|
EVENT_STATES = {
|
|
# A session that has just opened is waiting for your first prompt, which is
|
|
# the same thing as one that has finished a turn: the input line is free
|
|
# and the next move is yours. It had a state of its own once; it was only
|
|
# ever reachable before the first prompt, so it bought a fourth glyph in
|
|
# the panel that nobody saw.
|
|
"SessionStart": "waiting",
|
|
"UserPromptSubmit": "busy",
|
|
"PreCompact": "busy",
|
|
"PostToolUse": "busy",
|
|
"Stop": "waiting",
|
|
}
|
|
|
|
|
|
def debug_log(event):
|
|
"""Append raw events when a 'debug' marker file exists in the state dir.
|
|
|
|
Gated on a file rather than an env var because the hook inherits claude's
|
|
environment, which cannot be changed without restarting the session.
|
|
"""
|
|
marker = os.path.join(STATE_DIR, "debug")
|
|
if not os.path.exists(marker):
|
|
return
|
|
try:
|
|
with open(marker, "a") as fh:
|
|
stamped = dict(event, _at=time.strftime("%H:%M:%S"))
|
|
fh.write(json.dumps(stamped, sort_keys=True)[:2000] + "\n")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def derive_state(event):
|
|
"""Return the new state, 'end' to drop the session, or None to ignore."""
|
|
name = event.get("hook_event_name")
|
|
if name == "SessionEnd":
|
|
return "end"
|
|
# A notification is honoured whoever raised it: it means a human is needed,
|
|
# and that is just as true when the agent that got stuck is a subagent.
|
|
if name == "Notification":
|
|
return NOTIFICATION_STATES.get(event.get("notification_type"))
|
|
# Everything else describes work, and work done by a subagent is not the
|
|
# main agent's state. Measured: a subagent's tool call does reach the
|
|
# parent session's hooks, as PostToolUse carrying agent_id and agent_type.
|
|
#
|
|
# This matters most for background subagents. There the main agent ends its
|
|
# turn first -- Stop, so "waiting" -- and the subagents keep going, so their
|
|
# PostToolUse arrives afterwards and would flip the session back to "busy".
|
|
# The panel would then read "working" for a session whose input line is free
|
|
# and which is waiting for you, which is the exact confusion it exists to
|
|
# prevent. Synchronous subagents need no special handling either way: the
|
|
# main agent is mid-turn, so its own earlier events already say "busy".
|
|
if event.get("agent_id"):
|
|
return None
|
|
return EVENT_STATES.get(name)
|
|
|
|
|
|
def read_environ(pid):
|
|
"""Environment of a process as a dict, empty if it is gone or not ours."""
|
|
try:
|
|
with open("/proc/%d/environ" % pid, "rb") as fh:
|
|
raw = fh.read()
|
|
except OSError:
|
|
return {}
|
|
env = {}
|
|
for entry in raw.split(b"\0"):
|
|
if not entry:
|
|
continue
|
|
key, sep, value = entry.partition(b"=")
|
|
if sep:
|
|
env[key.decode("utf-8", "replace")] = value.decode("utf-8", "replace")
|
|
return env
|
|
|
|
|
|
def read_cmdline(pid):
|
|
try:
|
|
with open("/proc/%d/cmdline" % pid, "rb") as fh:
|
|
return fh.read().replace(b"\0", b" ").decode("utf-8", "replace")
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def parent_of(pid):
|
|
try:
|
|
with open("/proc/%d/status" % pid, "r") as fh:
|
|
for line in fh:
|
|
if line.startswith("PPid:"):
|
|
return int(line.split()[1])
|
|
except (OSError, ValueError):
|
|
pass
|
|
return 0
|
|
|
|
|
|
def find_claude_pid():
|
|
"""Nearest ancestor that is the claude process itself, or 0 if unknown.
|
|
|
|
The hook is spawned through a shell, so the immediate parent is usually not
|
|
claude. Walking beyond a handful of levels risks latching onto an outer
|
|
claude when one session drives another, so the search stops early.
|
|
|
|
Returning 0 rather than guessing matters: the reader deletes state files
|
|
whose pid is gone, and the obvious fallback -- the shell that spawned this
|
|
hook -- exits milliseconds later, which would make the session flicker in
|
|
and out of the panel forever.
|
|
"""
|
|
pid = os.getppid()
|
|
for _ in range(6):
|
|
if pid <= 1:
|
|
break
|
|
# Matched anywhere in the command line, not just argv[0]: installs that
|
|
# run it as `node .../claude/cli.js` are just as valid as a direct one.
|
|
if "claude" in read_cmdline(pid):
|
|
return pid
|
|
pid = parent_of(pid)
|
|
return 0
|
|
|
|
|
|
def alive(pid):
|
|
return pid > 0 and os.path.exists("/proc/%d" % pid)
|
|
|
|
|
|
def sweep_dead(keep):
|
|
"""Drop state files whose claude process is gone.
|
|
|
|
A killed terminal never sends SessionEnd, so files leak. Cleaning up on
|
|
SessionStart keeps the sweep off the hot path -- the extension only has to
|
|
hide stale entries, not own their lifetime.
|
|
"""
|
|
try:
|
|
names = os.listdir(STATE_DIR)
|
|
except OSError:
|
|
return
|
|
for name in names:
|
|
if not name.endswith(".json") or name == keep:
|
|
continue
|
|
path = os.path.join(STATE_DIR, name)
|
|
try:
|
|
with open(path, "r") as fh:
|
|
pid = json.load(fh).get("pid", 0)
|
|
except (OSError, ValueError, AttributeError):
|
|
continue
|
|
# pid 0 means the hook could not identify the process; there is nothing
|
|
# to test for liveness, so leave it to the reader's age cutoff.
|
|
if pid and not alive(pid):
|
|
for victim in (path, path + ".lock"):
|
|
try:
|
|
os.unlink(victim)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def write_atomic(path, payload):
|
|
tmp = "%s.%d.tmp" % (path, os.getpid())
|
|
with open(tmp, "w") as fh:
|
|
json.dump(payload, fh)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def apply_event(event, state, path, now):
|
|
"""Read the current state, decide, and write. Must run under the lock."""
|
|
if event.get("hook_event_name") == "SessionStart":
|
|
# Swept before any early return: a resumed session keeps its id, so its
|
|
# SessionStart finds an unchanged state and would otherwise bail out
|
|
# before ever reaching the sweep.
|
|
sweep_dead(keep=os.path.basename(path))
|
|
|
|
if state == "end":
|
|
for victim in (path, path + ".lock"):
|
|
try:
|
|
os.unlink(victim)
|
|
except OSError:
|
|
pass
|
|
return
|
|
|
|
previous = None
|
|
try:
|
|
with open(path, "r") as fh:
|
|
previous = json.load(fh)
|
|
except (OSError, ValueError):
|
|
previous = None
|
|
if not isinstance(previous, dict):
|
|
previous = None
|
|
|
|
# Normalised here so the comparison below is against what would actually be
|
|
# stored: comparing a stored "" to a raw notification message rewrites the
|
|
# file on every idle_prompt for no change at all.
|
|
message = event.get("message", "") if state == "blocked" else ""
|
|
|
|
if previous:
|
|
# Auto-compaction raises SessionStart again, in the middle of a turn the
|
|
# session is still working on. Taking it at face value would flip a busy
|
|
# session to idle until the next tool call corrected it.
|
|
if event.get("hook_event_name") == "SessionStart" and event.get("source") == "compact":
|
|
return
|
|
# Refuse events that lost a race with a newer one.
|
|
if previous.get("event_ts", 0) > now:
|
|
return
|
|
# Nothing new to publish: stay quiet so the directory monitor stays quiet.
|
|
if previous.get("state") == state and previous.get("message", "") == message:
|
|
return
|
|
|
|
claude_pid = find_claude_pid()
|
|
env = read_environ(claude_pid) if claude_pid else {}
|
|
|
|
write_atomic(path, {
|
|
"session_id": event.get("session_id"),
|
|
"state": state,
|
|
"cwd": event.get("cwd") or "",
|
|
# Age is measured from the moment the state was entered, not from the
|
|
# last event, so "waiting 40 min" survives unrelated later writes.
|
|
"since": previous["since"] if previous and previous.get("state") == state else now,
|
|
"event_ts": now,
|
|
# 0 means "could not tell"; the reader must not take that for "dead".
|
|
"pid": claude_pid,
|
|
"event": event.get("hook_event_name", ""),
|
|
"notification_type": event.get("notification_type", ""),
|
|
"message": message,
|
|
"zellij_session": env.get("ZELLIJ_SESSION_NAME", ""),
|
|
"zellij_pane": env.get("ZELLIJ_PANE_ID", ""),
|
|
"transcript": event.get("transcript_path", ""),
|
|
})
|
|
|
|
|
|
def main():
|
|
now = time.time()
|
|
try:
|
|
event = json.load(sys.stdin)
|
|
except (ValueError, OSError):
|
|
return 0
|
|
if not isinstance(event, dict):
|
|
return 0
|
|
|
|
session_id = event.get("session_id")
|
|
if not session_id or "/" in session_id:
|
|
return 0
|
|
|
|
debug_log(event)
|
|
|
|
state = derive_state(event)
|
|
if state is None:
|
|
return 0
|
|
|
|
os.makedirs(STATE_DIR, exist_ok=True)
|
|
path = os.path.join(STATE_DIR, "%s.json" % session_id)
|
|
|
|
# Hooks for one session run concurrently -- the last PostToolUse of a turn
|
|
# races that turn's Stop. Comparing timestamps is not enough on its own:
|
|
# without a lock both processes read the same "previous" and the loser's
|
|
# write still lands last, pinning a finished session at "busy". The lock is
|
|
# a separate file because write_atomic replaces the inode of the real one.
|
|
with open(path + ".lock", "w") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
apply_event(event, state, path, now)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except Exception:
|
|
# A hook that fails loudly would spam every session with error output;
|
|
# a missing panel update is the cheaper failure.
|
|
sys.exit(0)
|