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.
"Idle" was set by SessionStart and by nothing else, and there was no path
back into it. So it never meant "sitting unused" -- it meant "opened and
never asked anything yet", a state a few seconds long that you would
almost never catch. Meanwhile a session that finished an hour ago and was
forgotten showed as waiting, which is correct but leaves the fourth state
with nothing to describe.
A session that has just opened is waiting for your first prompt exactly
as one that finished a turn is waiting for your next. They are the same
thing, and now they are the same state. Three glyphs instead of four,
which also gives the remaining three more room to be told apart in a
monochrome panel.
Files written by the previous hook still say "idle", and a session open
across the upgrade must not disappear, so unrecognised states now read as
waiting rather than being treated as unknown. Covered by a test that
feeds an "idle" file to the store and asserts it comes back as waiting,
sorted by age among the others.
The "hide when nothing is running" setting goes with it. Its condition
was "no sessions, or all of them idle"; with idle gone the second half is
unreachable and the first was already unconditional, so the switch could
no longer change anything. A control that does nothing is worse than no
control.
The compaction test also got stronger in passing: it now checks that a
mid-turn SessionStart leaves a *busy* session alone, which is the case
that matters. It used to assert from waiting, where the state it was
guarding against happened to be the state already stored.
Answers one question at a glance: is any session waiting for me, and
which one. With several sessions open the cost is not knowing what each
is doing, it is noticing that one stopped an hour ago.
Claude Code hooks write one JSON file per session under
~/.local/state/claude-code-status; the extension watches the directory
with Gio.FileMonitor, so nothing polls and there is no daemon.
Two distinctions carry the design:
* blocked (permission prompt) is kept apart from waiting (turn done).
Merged, a finished task looks as urgent as a stuck one, which is
exactly the judgement the indicator exists to make.
* the panel names the oldest session in the top state, not the latest.
The session you forget is the one that has been waiting longest.
PostToolUse is registered although it looks redundant: it is the only
event that fires after a permission is granted, so without it a session
stays blocked in the panel for the rest of the turn. It writes only on
an actual state change, so the usual case costs no I/O.
Stop and SessionEnd are synchronous, unlike the rest. Both fire as the
process is about to go quiet, and an async hook racing that exit gets
killed before it writes -- claude -p left a session pinned at busy.
Concurrent hooks for one session serialise on an flock plus a timestamp
guard; tests/test-hook.sh covers each separately, because the burst test
passes on the timestamp guard alone.
Sessions running in zellij are located by tab name rather than by path,
matched through dump-layout on the working directory. The dump carries
no pane ids, so ZELLIJ_PANE_ID cannot be used; rows that do not resolve
stay inert instead of pretending a click does something.
lib/sessions.js deliberately imports nothing from the shell resource
namespace, which lets the riskiest logic -- liveness, ordering, partial
reads, monitoring -- run under plain gjs in tests/test-sessions.js.