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.
This commit is contained in:
av
2026-08-23 09:19:30 +03:00
parent f8e85d5703
commit d48a18ae75
4 changed files with 130 additions and 73 deletions
+33 -18
View File
@@ -162,7 +162,8 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
| `PostToolUse` | `busy` | | `PostToolUse` | `busy` |
| `PreCompact` | `busy` | | `PreCompact` | `busy` |
| `Notification` | `blocked` или `waiting`, в зависимости от `notification_type` | | `Notification` | `blocked` или `waiting`, в зависимости от `notification_type` |
| `Stop` | `waiting` | | `Stop` | `waiting` — если не работают сабагенты (см. ниже) |
| `SubagentStop` | обновляет число работающих сабагентов; последний освобождает сессию |
| `SessionEnd` | файл удаляется | | `SessionEnd` | файл удаляется |
`PostToolUse` не избыточен: это единственное событие, срабатывающее после выдачи `PostToolUse` не избыточен: это единственное событие, срабатывающее после выдачи
@@ -208,31 +209,45 @@ Agent SDK и интеграции с редакторами.
Но `Stop` при этом приходит раньше, чем сабагенты закончат. Если верить ему Но `Stop` при этом приходит раньше, чем сабагенты закончат. Если верить ему
буквально, сессия покажется свободной ровно тогда, когда в неё лезть бессмысленно. буквально, сессия покажется свободной ровно тогда, когда в неё лезть бессмысленно.
Поэтому сабагенты считаются явно:
| Событие | Что делает | Сколько сабагентов ещё работает, хук не считает, а **берёт из самого события**:
|---|---| `Stop` и `SubagentStop` несут поле `background_tasks` — список фоновых задач с их
| `PreToolUse` с матчером `^(Agent\|Task)$` | +1 к счётчику | `status`. Оттуда и берётся число: задачи с `type: "subagent"` и `status:
| `SubagentStop` | −1; последний освобождает сессию, если ход уже закончен | "running"`. Остальные события этого поля не несут, и тогда стоит последнее
| `UserPromptSubmit` | сбрасывает счётчик в 0 | известное значение.
Пока счётчик больше нуля, состояние `waiting` невозможно: и `Stop`, и напоминание Пока число больше нуля, состояние `waiting` невозможно: и `Stop`, и напоминание
`idle_prompt` дают `busy`. Освобождает сессию только уход последнего сабагента — `idle_prompt` дают `busy`. Освобождает сессию только `SubagentStop`, пришедший в
и лишь если основной агент к тому времени остановился. момент, когда список пуст, — и лишь если основной агент к тому времени
остановился.
Матчер якорный не для красоты: это регулярка, и голое `Task` поймало бы Фоновая команда (`type: "bash"`) сабагентом не считается намеренно: поднятый
`TaskCreate`, `TaskUpdate` и прочее. Хук проверяет имя инструмента ещё раз, сам. dev-сервер живёт часами и ничего не говорит о том, нужны вы сессии или нет, — а
Сброс на `UserPromptSubmit` ограничивает ущерб, если сабагент умрёт, не прислав приравняв его к работе, чип пришлось бы держать «работает» всё это время.
`SubagentStop`: счётчик не переживёт следующего вашего сообщения.
### Почему не счётчик
Сначала счётчик и был: `+1` на `PreToolUse` с матчером `^(Agent|Task)$`, `1` на
`SubagentStop`. Он ошибался в худшую сторону — гасил `busy` у занятой сессии, —
и вот почему. Запуск сабагента виден хуку только на верхнем уровне: когда свой
сабагент разворачивает сабагент, событие приходит с `agent_id` и отбрасывается.
А `SubagentStop` приходит **за каждого сабагента на любой глубине**. Каждый
вложенный вычитал единицу из батча, в который никогда не входил.
Замерено на живой сессии: один фоновый агент, четырнадцать вложенных `SubagentStop`
подряд — счётчик обнулился на первом же, и сессия с работающим батчем показалась
ждущей. Снимок вычитать нечего: он просто говорит, что запущено сейчас, и по той
же причине не течёт, если сабагент умрёт, не прислав `SubagentStop`.
Собственные вызовы инструментов сабагентов игнорируются — они долетают до хуков Собственные вызовы инструментов сабагентов игнорируются — они долетают до хуков
родителя как `PostToolUse` с `agent_id`, но счётчик уже всё сказал, а они добавили родителя как `PostToolUse` с `agent_id`, но сессия занята и без них, а записей на
бы только записи на диск. `Notification` — исключение: она означает, что нужен диск от одного сабагента набежали бы сотни. `Notification` — исключение: она
человек, и это одинаково верно, в каком бы агенте ни заклинило. означает, что нужен человек, и это одинаково верно, в каком бы агенте ни заклинило.
В меню число сабагентов показывается строкой «работает · 3 subagents». В панели — В меню число сабагентов показывается строкой «работает · 3 subagents». В панели —
нет: батч из восьми задач остаётся одной строкой «работает 40 мин», и это нет: батч из восьми задач остаётся одной строкой «работает 40 мин», и это
правильная строка. правильная строка. Считаются фоновые: батч, которого основной агент дожидается
сам, и так виден по состоянию `busy`.
## zellij ## zellij
+50 -28
View File
@@ -48,10 +48,11 @@ NOTIFICATION_STATES = {
"agent_completed": "waiting", "agent_completed": "waiting",
} }
# Tools that spawn a subagent. Matched again here, not just in the hook # Background tasks that mean the session is still working. A background shell
# registration: the matcher is a regex and a mistake there would silently # is not one: a dev server left running says nothing about whether the session
# inflate the count with TaskCreate, TaskUpdate and the like. # needs you, and counting it would pin the chip at "working" for as long as it
AGENT_TOOLS = {"Agent", "Task"} # lives.
BUSY_TASK_TYPES = {"subagent"}
# Flags that mean "run one prompt and exit" rather than "open a session". See # Flags that mean "run one prompt and exit" rather than "open a session". See
# is_headless: such a run has no terminal to be called over to. # is_headless: such a run has no terminal to be called over to.
@@ -140,22 +141,51 @@ 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"))
# SubagentStop is the counter's decrement and carries agent_id itself, so # SubagentStop carries agent_id itself, so it has to pass the filter below.
# it has to pass the filter below. Its state is decided in apply_event, # It is the one event that can free a session whose main agent stopped a
# which is where the count is known. # long time ago; which way it goes needs the snapshot and is decided in
# apply_event.
if name == "SubagentStop": if name == "SubagentStop":
return "busy" return "busy"
# A subagent's own tool calls also reach the parent session's hooks # A subagent's own tool calls also reach the parent session's hooks
# (measured: PostToolUse carrying agent_id and agent_type). They are # (measured: PostToolUse carrying agent_id and agent_type). They are
# ignored, because the count already says a subagent is running and these # ignored: the session is working either way, and a single subagent's shell
# would only add write traffic. # commands alone would be hundreds of writes.
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)
def running_agents(event):
"""How many subagents the session still has running, or None if this event
does not say.
Read from the event's own ``background_tasks`` rather than counted from
starts and stops. Counting was what this did first, and it was wrong in the
direction that matters: ``PreToolUse`` only ever sees a top-level launch --
a subagent spawning its own subagents does it through hooks carrying
``agent_id``, which are dropped above -- while ``SubagentStop`` arrives for
every subagent at every depth. Each nested one therefore subtracted from a
batch it had never joined, and the session was declared free with its work
still running. Measured live: one background agent, fourteen nested stops
behind it, and a session shown as waiting from the first of them on.
The field is present exactly where it decides something: on ``Stop`` and
``SubagentStop``, the two events that can end a turn. Everywhere else it is
absent, and the stored value stands.
"""
tasks = event.get("background_tasks")
if not isinstance(tasks, list):
return None
running = 0
for task in tasks:
if not isinstance(task, dict):
continue
if task.get("type") in BUSY_TASK_TYPES and task.get("status") == "running":
running += 1
return min(running, 999)
def read_environ(pid): def read_environ(pid):
"""Environment of a process as a dict, empty if it is gone or not ours.""" """Environment of a process as a dict, empty if it is gone or not ours."""
try: try:
@@ -402,25 +432,17 @@ def apply_event(event, state, path, now, claude_pid):
previous_ts = 0 # corrupt, and trusting it would freeze this session previous_ts = 0 # corrupt, and trusting it would freeze this session
stale = previous_ts > now stale = previous_ts > now
# The subagent count is different in kind: the mutations are deltas, and # Applied whether or not this event lost the race above: a snapshot says
# deltas commute, so every one has to land whatever order it arrives in. # what was running when the event fired, and even a slightly stale one is a
# Dropping a "+1" because its timestamp lost a race leaves the count one # better answer than a number left over from an older event still. Nothing
# short, and the batch then frees the session while a subagent is still # accumulates, so nothing leaks when a subagent dies without ever sending
# running -- the exact failure counting was added to prevent. The timestamps # its SubagentStop -- the next event carrying a list corrects the count.
# cannot be trusted for this at all: each is taken when its hook process snapshot = running_agents(event)
# starts, tens of milliseconds before it reaches the lock. if snapshot is not None:
if name in ("SessionStart", "UserPromptSubmit"): agents = snapshot
# 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 = 0
elif name == "PreToolUse":
agents += 1
elif name == "SubagentStop":
agents = max(0, agents - 1)
if stale: if stale:
# Keep the stored state and flag; the delta above still gets persisted. # Keep the stored state and flag; the snapshot above still stands.
state = previous.get("state", state) state = previous.get("state", state)
else: else:
if name in ("SessionStart", "UserPromptSubmit"): if name in ("SessionStart", "UserPromptSubmit"):
+11 -9
View File
@@ -23,17 +23,11 @@ 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: # (async, matcher).
# 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, ""),
"PreToolUse": (True, "^(Agent|Task)$"),
"PostToolUse": (True, ""), "PostToolUse": (True, ""),
"PreCompact": (True, ""), "PreCompact": (True, ""),
"SubagentStop": (True, ""), "SubagentStop": (True, ""),
@@ -41,6 +35,14 @@ EVENTS = {
"SessionEnd": (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): def entry(spec):
async_, matcher = spec async_, matcher = spec
@@ -90,9 +92,9 @@ def main():
shutil.copymode(path, path + ".bak") shutil.copymode(path, path + ".bak")
hooks = settings.setdefault("hooks", {}) hooks = settings.setdefault("hooks", {})
for event in EVENTS: for event in list(EVENTS) + list(LEGACY_EVENTS):
groups = [g for g in hooks.get(event, []) if not is_ours(g)] groups = [g for g in hooks.get(event, []) if not is_ours(g)]
if not uninstall: if not uninstall and event in EVENTS:
groups.append(entry(EVENTS[event])) groups.append(entry(EVENTS[event]))
if groups: if groups:
hooks[event] = groups hooks[event] = groups
+36 -18
View File
@@ -149,39 +149,57 @@ check "compaction does not reset the clock" "$since_before" "$(field since)"
# The scenario this exists for: you ask for a batch, the main agent launches it # 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. # 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. # It is working, not waiting for you, and must not call you over.
#
# What is running is not counted from starts and stops but taken from the list
# the events carry, exactly as measured on a live session: Stop and
# SubagentStop bring "background_tasks", the rest of the events bring nothing.
bg() { # running-subagent-count -> the snapshot as those events carry it
tasks=""
for i in $(seq 0 $(($1 - 1))); do
tasks="$tasks${tasks:+,}{\"id\":\"sub-$i\",\"type\":\"subagent\",\"status\":\"running\"}"
done
echo "\"background_tasks\":[$tasks]"
}
emit "$(ev UserPromptSubmit '"prompt":"launch a batch"')" emit "$(ev UserPromptSubmit '"prompt":"launch a batch"')"
check "a new turn resets the count" "0" "$(field agents)" emit "$(ev Stop "$(bg 2)")"
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)" check "main agent stopping does not free a running batch" "busy" "$(field state)"
check "the snapshot is what gets stored" "2" "$(field agents)"
emit "$(ev SubagentStop '"agent_id":"sub-1","agent_type":"general-purpose"')" emit "$(ev SubagentStop "\"agent_id\":\"sub-0\",\"agent_type\":\"general-purpose\",$(bg 1)")"
check "one down, still working" "busy" "$(field state)" check "one down, still working" "busy" "$(field state)"
check "count decremented" "1" "$(field agents)" check "the count follows the snapshot" "1" "$(field agents)"
emit "$(ev SubagentStop '"agent_id":"sub-2","agent_type":"general-purpose"')" # The regression this replaced counting for: a subagent's own subagent stops,
# and its SubagentStop reaches this session just like a top-level one -- while
# its *launch* never did, because that event carried agent_id and was dropped.
# Subtracting it freed a session whose batch was still running.
emit "$(ev SubagentStop "\"agent_id\":\"nested-1\",\"agent_type\":\"\",$(bg 1)")"
check "a nested subagent stopping does not free the batch" "busy" "$(field state)"
check "and does not touch the count" "1" "$(field agents)"
emit "$(ev SubagentStop "\"agent_id\":\"sub-1\",$(bg 0)")"
check "last subagent finishing frees the session" "waiting" "$(field state)" check "last subagent finishing frees the session" "waiting" "$(field state)"
check "count back to zero" "0" "$(field agents)" check "count back to zero" "0" "$(field agents)"
# A background shell is a background task too, and must not be mistaken for
# work: a dev server left running would pin the chip at "working" for good.
emit "$(ev UserPromptSubmit '"prompt":"serve"')"
emit "$(ev Stop '"background_tasks":[{"id":"b1","type":"bash","status":"running"}]')"
check "a background shell is not a subagent" "waiting" "$(field state)"
# A batch that finishes while the main agent is still mid-turn must not free it. # A batch that finishes while the main agent is still mid-turn must not free it.
emit "$(ev UserPromptSubmit '"prompt":"again"')" emit "$(ev UserPromptSubmit '"prompt":"again"')"
emit "$(ev PreToolUse '"tool_name":"Agent"')" emit "$(ev SubagentStop "\"agent_id\":\"sub-3\",$(bg 0)")"
emit "$(ev SubagentStop '"agent_id":"sub-3"')"
check "batch done mid-turn leaves the session working" "busy" "$(field state)" 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. # An idle nudge while a batch runs must not claim the session is free either.
emit "$(ev PreToolUse '"tool_name":"Agent"')" # It carries no snapshot of its own, so this also checks the stored one stands.
emit "$(ev Stop)" emit "$(ev Stop "$(bg 1)")"
emit "$(ev Notification '"notification_type":"idle_prompt","message":"still there?"')" emit "$(ev Notification '"notification_type":"idle_prompt","message":"still there?"')"
check "idle nudge cannot free a running batch" "busy" "$(field state)" check "idle nudge cannot free a running batch" "busy" "$(field state)"
emit "$(ev SubagentStop '"agent_id":"sub-4"')" check "an event without a snapshot leaves the count alone" "1" "$(field agents)"
emit "$(ev SubagentStop "\"agent_id\":\"sub-4\",$(bg 0)")"
check "and it frees properly once the batch ends" "waiting" "$(field state)" check "and it frees properly once the batch ends" "waiting" "$(field state)"
# --- concurrency ----------------------------------------------------------- # --- concurrency -----------------------------------------------------------