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.
This commit is contained in:
+54
-12
@@ -47,6 +47,11 @@ NOTIFICATION_STATES = {
|
||||
"agent_completed": "waiting",
|
||||
}
|
||||
|
||||
# Tools that spawn a subagent. Matched again here, not just in the hook
|
||||
# registration: the matcher is a regex and a mistake there would silently
|
||||
# inflate the count with TaskCreate, TaskUpdate and the like.
|
||||
AGENT_TOOLS = {"Agent", "Task"}
|
||||
|
||||
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
|
||||
@@ -54,6 +59,7 @@ EVENT_STATES = {
|
||||
# ever reachable before the first prompt, so it bought a fourth glyph in
|
||||
# the panel that nobody saw.
|
||||
"SessionStart": "waiting",
|
||||
"PreToolUse": "busy",
|
||||
"UserPromptSubmit": "busy",
|
||||
"PreCompact": "busy",
|
||||
"PostToolUse": "busy",
|
||||
@@ -87,19 +93,19 @@ def derive_state(event):
|
||||
# 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".
|
||||
# SubagentStop is the counter's decrement and carries agent_id itself, so
|
||||
# it has to pass the filter below. Its state is decided in apply_event,
|
||||
# which is where the count is known.
|
||||
if name == "SubagentStop":
|
||||
return "busy"
|
||||
# A subagent's own tool calls also reach the parent session's hooks
|
||||
# (measured: PostToolUse carrying agent_id and agent_type). They are
|
||||
# ignored, because the count already says a subagent is running and these
|
||||
# would only add write traffic.
|
||||
if event.get("agent_id"):
|
||||
return None
|
||||
if name == "PreToolUse" and event.get("tool_name") not in AGENT_TOOLS:
|
||||
return None
|
||||
return EVENT_STATES.get(name)
|
||||
|
||||
|
||||
@@ -234,6 +240,37 @@ def apply_event(event, state, path, now):
|
||||
# file on every idle_prompt for no change at all.
|
||||
message = event.get("message", "") if state == "blocked" else ""
|
||||
|
||||
name = event.get("hook_event_name")
|
||||
agents = int(previous.get("agents", 0)) if previous else 0
|
||||
# Whether the main agent has finished its turn. Tracked separately from the
|
||||
# state because with background subagents both are true at once: the turn is
|
||||
# over and work is still running.
|
||||
stopped = bool(previous.get("stopped")) if previous else False
|
||||
|
||||
if name in ("SessionStart", "UserPromptSubmit"):
|
||||
# A new turn from you starts a new batch. This also bounds the damage
|
||||
# when a subagent dies without its SubagentStop ever arriving: the count
|
||||
# cannot leak past the next thing you type.
|
||||
agents, stopped = 0, False
|
||||
elif name == "PreToolUse":
|
||||
agents += 1
|
||||
elif name == "SubagentStop":
|
||||
agents = max(0, agents - 1)
|
||||
elif name == "Stop":
|
||||
stopped = True
|
||||
elif name == "PostToolUse":
|
||||
stopped = False
|
||||
|
||||
if name == "SubagentStop":
|
||||
# The last subagent finishing is what finally frees a session whose main
|
||||
# agent stopped long ago.
|
||||
state = "waiting" if (stopped and agents == 0) else "busy"
|
||||
elif state == "waiting" and agents > 0:
|
||||
# The turn ended but the batch is still running, and the session will
|
||||
# pick the results up itself. Calling it "waiting" would send you to a
|
||||
# terminal that does not need you.
|
||||
state = "busy"
|
||||
|
||||
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
|
||||
@@ -244,7 +281,10 @@ def apply_event(event, state, path, now):
|
||||
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:
|
||||
if (previous.get("state") == state
|
||||
and previous.get("message", "") == message
|
||||
and previous.get("agents", 0) == agents
|
||||
and bool(previous.get("stopped")) == stopped):
|
||||
return
|
||||
|
||||
claude_pid = find_claude_pid()
|
||||
@@ -263,6 +303,8 @@ def apply_event(event, state, path, now):
|
||||
"event": event.get("hook_event_name", ""),
|
||||
"notification_type": event.get("notification_type", ""),
|
||||
"message": message,
|
||||
"agents": agents,
|
||||
"stopped": stopped,
|
||||
"zellij_session": env.get("ZELLIJ_SESSION_NAME", ""),
|
||||
"zellij_pane": env.get("ZELLIJ_PANE_ID", ""),
|
||||
"transcript": event.get("transcript_path", ""),
|
||||
|
||||
+18
-9
@@ -21,22 +21,31 @@ HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude-status-h
|
||||
# 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,
|
||||
"PostToolUse": True,
|
||||
"PreCompact": True,
|
||||
"Stop": False,
|
||||
"SessionEnd": False,
|
||||
"SessionStart": (True, ""),
|
||||
"UserPromptSubmit": (True, ""),
|
||||
"Notification": (True, ""),
|
||||
"PreToolUse": (True, "^(Agent|Task)$"),
|
||||
"PostToolUse": (True, ""),
|
||||
"PreCompact": (True, ""),
|
||||
"SubagentStop": (True, ""),
|
||||
"Stop": (False, ""),
|
||||
"SessionEnd": (False, ""),
|
||||
}
|
||||
|
||||
|
||||
def entry(async_):
|
||||
def entry(spec):
|
||||
async_, matcher = spec
|
||||
hook = {"type": "command", "command": HOOK, "timeout": 5}
|
||||
if async_:
|
||||
hook["async"] = True
|
||||
return {"matcher": "", "hooks": [hook]}
|
||||
return {"matcher": matcher, "hooks": [hook]}
|
||||
|
||||
|
||||
def is_ours(group):
|
||||
|
||||
Reference in New Issue
Block a user