diff --git a/README.md b/README.md index c899fbc..8b88e1d 100644 --- a/README.md +++ b/README.md @@ -142,20 +142,39 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me всё ещё позволяет хуку, прочитавшему старое состояние до `Stop`, записать своё устаревшее решение после него. -Сабагенты не показываются, и это не про экономию строк. Их вызовы инструментов -**долетают** до хуков родительской сессии — как `PostToolUse` с полями -`agent_id` и `agent_type` (проверено запуском). Учитывать их нельзя: при фоновых -сабагентах основной агент заканчивает ход первым (`Stop`, то есть `waiting`), а -сабагенты продолжают работать, и их события перебили бы состояние обратно в -`busy`. Панель показывала бы «работает» у сессии, которая на самом деле ждёт -вас, — ровно та подмена, ради предотвращения которой всё и затевалось. +## Сабагенты -Поэтому любое событие с `agent_id` игнорируется. Кроме `Notification`: она -означает, что нужен человек, и это одинаково верно, в каком бы агенте ни -заклинило. +Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и +**заканчивает ход**, а сессия ждёт результатов, чтобы свести их воедино. Звать +вас туда не надо — она занята. -Батч из восьми задач остаётся одной строкой «работает 40 мин», и это правильная -строка: пятый воркер из восьми ни о чём не просит. +Но `Stop` при этом приходит раньше, чем сабагенты закончат. Если верить ему +буквально, сессия покажется свободной ровно тогда, когда в неё лезть бессмысленно. +Поэтому сабагенты считаются явно: + +| Событие | Что делает | +|---|---| +| `PreToolUse` с матчером `^(Agent\|Task)$` | +1 к счётчику | +| `SubagentStop` | −1; последний освобождает сессию, если ход уже закончен | +| `UserPromptSubmit` | сбрасывает счётчик в 0 | + +Пока счётчик больше нуля, состояние `waiting` невозможно: и `Stop`, и напоминание +`idle_prompt` дают `busy`. Освобождает сессию только уход последнего сабагента — +и лишь если основной агент к тому времени остановился. + +Матчер якорный не для красоты: это регулярка, и голое `Task` поймало бы +`TaskCreate`, `TaskUpdate` и прочее. Хук проверяет имя инструмента ещё раз, сам. +Сброс на `UserPromptSubmit` ограничивает ущерб, если сабагент умрёт, не прислав +`SubagentStop`: счётчик не переживёт следующего вашего сообщения. + +Собственные вызовы инструментов сабагентов игнорируются — они долетают до хуков +родителя как `PostToolUse` с `agent_id`, но счётчик уже всё сказал, а они добавили +бы только записи на диск. `Notification` — исключение: она означает, что нужен +человек, и это одинаково верно, в каком бы агенте ни заклинило. + +В меню число сабагентов показывается строкой «работает · 3 subagents». В панели — +нет: батч из восьми задач остаётся одной строкой «работает 40 мин», и это +правильная строка. ## zellij diff --git a/hooks/claude-status-hook.py b/hooks/claude-status-hook.py index 0e45512..1f05261 100755 --- a/hooks/claude-status-hook.py +++ b/hooks/claude-status-hook.py @@ -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", ""), diff --git a/hooks/install.py b/hooks/install.py index 7ff6782..2aca5f6 100755 --- a/hooks/install.py +++ b/hooks/install.py @@ -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): diff --git a/lib/indicator.js b/lib/indicator.js index 99176f8..fa9649b 100644 --- a/lib/indicator.js +++ b/lib/indicator.js @@ -305,6 +305,15 @@ class ClaudeStatusIndicator extends PanelMenu.Button { /** Second line: what the session needs, then where to find it. */ _subtitleFor(session) { + const what = [stateLabel(session.state)]; + // A batch is why a session can be working with nothing on its own + // plate, and how far along it is decides whether to wait for it. + if (session.agents > 0) { + what.push(session.agents === 1 + ? _('1 subagent') + : `${session.agents} ${_('subagents')}`); + } + const where = []; const tab = this._tabFor(session); if (tab) @@ -320,7 +329,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button { // the tab and the path out of the line to say less than the state // label already does. Getting the real command means reading the tail // of the transcript when the prompt fires; it is not in the event. - return `${stateLabel(session.state)} · ${where.join(' · ')}`; + return `${what.join(' · ')} · ${where.join(' · ')}`; } _ageOf(session) { diff --git a/lib/sessions.js b/lib/sessions.js index 6b47dae..2157b86 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -211,6 +211,7 @@ export const SessionStore = GObject.registerClass({ since: Number(raw.since) || 0, pid, message: String(raw.message ?? ''), + agents: Math.max(0, Number(raw.agents) || 0), notificationType: String(raw.notification_type ?? ''), zellijSession: String(raw.zellij_session ?? ''), zellijPane: String(raw.zellij_pane ?? ''), diff --git a/tests/test-hook.sh b/tests/test-hook.sh index 47ec511..9573e13 100755 --- a/tests/test-hook.sh +++ b/tests/test-hook.sh @@ -86,6 +86,45 @@ emit "$(ev SessionStart '"source":"compact"')" check "compaction does not reset a live session" "busy" "$(field state)" check "compaction does not reset the clock" "$since_before" "$(field since)" +# --- subagents ------------------------------------------------------------- +# The scenario this exists for: you ask for a batch, the main agent launches it +# and ends its turn, and the session waits for the results to consolidate them. +# It is working, not waiting for you, and must not call you over. +emit "$(ev UserPromptSubmit '"prompt":"launch a batch"')" +check "a new turn resets the count" "0" "$(field agents)" + +emit "$(ev PreToolUse '"tool_name":"Agent"')" +emit "$(ev PreToolUse '"tool_name":"Agent"')" +check "two launches counted" "2" "$(field agents)" + +emit "$(ev PreToolUse '"tool_name":"TaskCreate"')" +check "a tool that merely starts with Task is not a subagent" "2" "$(field agents)" + +emit "$(ev Stop)" +check "main agent stopping does not free a running batch" "busy" "$(field state)" + +emit "$(ev SubagentStop '"agent_id":"sub-1","agent_type":"general-purpose"')" +check "one down, still working" "busy" "$(field state)" +check "count decremented" "1" "$(field agents)" + +emit "$(ev SubagentStop '"agent_id":"sub-2","agent_type":"general-purpose"')" +check "last subagent finishing frees the session" "waiting" "$(field state)" +check "count back to zero" "0" "$(field agents)" + +# A batch that finishes while the main agent is still mid-turn must not free it. +emit "$(ev UserPromptSubmit '"prompt":"again"')" +emit "$(ev PreToolUse '"tool_name":"Agent"')" +emit "$(ev SubagentStop '"agent_id":"sub-3"')" +check "batch done mid-turn leaves the session working" "busy" "$(field state)" + +# An idle nudge while a batch runs must not claim the session is free either. +emit "$(ev PreToolUse '"tool_name":"Agent"')" +emit "$(ev Stop)" +emit "$(ev Notification '"notification_type":"idle_prompt","message":"still there?"')" +check "idle nudge cannot free a running batch" "busy" "$(field state)" +emit "$(ev SubagentStop '"agent_id":"sub-4"')" +check "and it frees properly once the batch ends" "waiting" "$(field state)" + # --- concurrency ----------------------------------------------------------- # The hazard: the last PostToolUse of a turn is async and can still be running # when the turn's Stop fires. Two mechanisms defend against it and they are