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:
av
2026-08-09 19:26:25 +03:00
parent 09b9aae2eb
commit 5398f32826
6 changed files with 153 additions and 34 deletions
+31 -12
View File
@@ -142,20 +142,39 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
всё ещё позволяет хуку, прочитавшему старое состояние до `Stop`, записать своё всё ещё позволяет хуку, прочитавшему старое состояние до `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 ## zellij
+54 -12
View File
@@ -47,6 +47,11 @@ NOTIFICATION_STATES = {
"agent_completed": "waiting", "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 = { EVENT_STATES = {
# A session that has just opened is waiting for your first prompt, which is # 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 # 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 # ever reachable before the first prompt, so it bought a fourth glyph in
# the panel that nobody saw. # the panel that nobody saw.
"SessionStart": "waiting", "SessionStart": "waiting",
"PreToolUse": "busy",
"UserPromptSubmit": "busy", "UserPromptSubmit": "busy",
"PreCompact": "busy", "PreCompact": "busy",
"PostToolUse": "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. # and that is just as true when the agent that got stuck is a subagent.
if name == "Notification": if name == "Notification":
return NOTIFICATION_STATES.get(event.get("notification_type")) return NOTIFICATION_STATES.get(event.get("notification_type"))
# Everything else describes work, and work done by a subagent is not the # SubagentStop is the counter's decrement and carries agent_id itself, so
# main agent's state. Measured: a subagent's tool call does reach the # it has to pass the filter below. Its state is decided in apply_event,
# parent session's hooks, as PostToolUse carrying agent_id and agent_type. # which is where the count is known.
# if name == "SubagentStop":
# This matters most for background subagents. There the main agent ends its return "busy"
# turn first -- Stop, so "waiting" -- and the subagents keep going, so their # A subagent's own tool calls also reach the parent session's hooks
# PostToolUse arrives afterwards and would flip the session back to "busy". # (measured: PostToolUse carrying agent_id and agent_type). They are
# The panel would then read "working" for a session whose input line is free # ignored, because the count already says a subagent is running and these
# and which is waiting for you, which is the exact confusion it exists to # would only add write traffic.
# 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"): if event.get("agent_id"):
return None return None
if name == "PreToolUse" and event.get("tool_name") not in AGENT_TOOLS:
return None
return EVENT_STATES.get(name) 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. # file on every idle_prompt for no change at all.
message = event.get("message", "") if state == "blocked" else "" 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: if previous:
# Auto-compaction raises SessionStart again, in the middle of a turn the # 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 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: if previous.get("event_ts", 0) > now:
return return
# Nothing new to publish: stay quiet so the directory monitor stays quiet. # 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 return
claude_pid = find_claude_pid() claude_pid = find_claude_pid()
@@ -263,6 +303,8 @@ def apply_event(event, state, path, now):
"event": event.get("hook_event_name", ""), "event": event.get("hook_event_name", ""),
"notification_type": event.get("notification_type", ""), "notification_type": event.get("notification_type", ""),
"message": message, "message": message,
"agents": agents,
"stopped": stopped,
"zellij_session": env.get("ZELLIJ_SESSION_NAME", ""), "zellij_session": env.get("ZELLIJ_SESSION_NAME", ""),
"zellij_pane": env.get("ZELLIJ_PANE_ID", ""), "zellij_pane": env.get("ZELLIJ_PANE_ID", ""),
"transcript": event.get("transcript_path", ""), "transcript": event.get("transcript_path", ""),
+18 -9
View File
@@ -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 # 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 # "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. # 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 = { EVENTS = {
"SessionStart": True, "SessionStart": (True, ""),
"UserPromptSubmit": True, "UserPromptSubmit": (True, ""),
"Notification": True, "Notification": (True, ""),
"PostToolUse": True, "PreToolUse": (True, "^(Agent|Task)$"),
"PreCompact": True, "PostToolUse": (True, ""),
"Stop": False, "PreCompact": (True, ""),
"SessionEnd": False, "SubagentStop": (True, ""),
"Stop": (False, ""),
"SessionEnd": (False, ""),
} }
def entry(async_): def entry(spec):
async_, matcher = spec
hook = {"type": "command", "command": HOOK, "timeout": 5} hook = {"type": "command", "command": HOOK, "timeout": 5}
if async_: if async_:
hook["async"] = True hook["async"] = True
return {"matcher": "", "hooks": [hook]} return {"matcher": matcher, "hooks": [hook]}
def is_ours(group): def is_ours(group):
+10 -1
View File
@@ -305,6 +305,15 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
/** Second line: what the session needs, then where to find it. */ /** Second line: what the session needs, then where to find it. */
_subtitleFor(session) { _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 where = [];
const tab = this._tabFor(session); const tab = this._tabFor(session);
if (tab) 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 // 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 // label already does. Getting the real command means reading the tail
// of the transcript when the prompt fires; it is not in the event. // 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) { _ageOf(session) {
+1
View File
@@ -211,6 +211,7 @@ export const SessionStore = GObject.registerClass({
since: Number(raw.since) || 0, since: Number(raw.since) || 0,
pid, pid,
message: String(raw.message ?? ''), message: String(raw.message ?? ''),
agents: Math.max(0, Number(raw.agents) || 0),
notificationType: String(raw.notification_type ?? ''), notificationType: String(raw.notification_type ?? ''),
zellijSession: String(raw.zellij_session ?? ''), zellijSession: String(raw.zellij_session ?? ''),
zellijPane: String(raw.zellij_pane ?? ''), zellijPane: String(raw.zellij_pane ?? ''),
+39
View File
@@ -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 a live session" "busy" "$(field state)"
check "compaction does not reset the clock" "$since_before" "$(field since)" 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 ----------------------------------------------------------- # --- concurrency -----------------------------------------------------------
# The hazard: the last PostToolUse of a turn is async and can still be running # 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 # when the turn's Stop fires. Two mechanisms defend against it and they are