2026-08-03 Edge-Triggered Agent State Has No Way to Retire a Wait

What I set out to do

Two symptoms in the tmux agent session manager after last week’s generalisation from Claude-only @claude_state to four-agent @agent_state (see 2026-07-27 Cross-Agent Detection and Waiting State in Tmux): the activity marker sometimes never cleared from the statusbar, and the statusbar count sometimes disagreed with what prefix+a listed.

What I actually did

Both were real, and neither was a flake.

The count mismatch is an off-by-one by construction. claude-pane-picker.sh skips the pane it was invoked from; claude-status.sh has no such exclusion. Since nearly every pane hosts an agent, opening the picker from an agent pane always showed one fewer than the bar. Live at the time: bar said 6, picker would have listed 5. Fixed by listing the current pane rather than dropping it, sorted last and marked (current), so the two always count the same set. There is a second, rarer direction: the picker also keeps a pane on process detection alone, so an agent whose hooks were disabled or that predates the hook install appears in the picker and is never counted.

The marker that never cleared is the more interesting one. Commit 04f38e0 dropped the window_bell_flag gate, correctly, because tmux swallows the BEL that terminates an OSC sequence. What went unnoticed is that the bell flag was also the only thing that ever retired a wait: tmux clears it when you visit the window. Nothing replaced it, and the published state is purely edge-triggered, so a blocked tag can outlive the block it describes. Answering a permission prompt fires no event of its own, so the alert colour survived from the prompt all the way to Stop, i.e. the entire rest of the turn. Worse, Claude skips Stop entirely when a turn ends in a user interrupt, so Esc at a prompt left blocked set permanently.

Fixed on both sides. PostToolUse now publishes running: the tool ran, therefore the decision was made. And the statusline never counts a pane as waiting while it is on screen (pane_active and window_active and session_attached), which is the agent-agnostic replacement for the bell gate and covers the interrupt case, where no hook fires by construction.

Three smaller things fell out of the same read. SessionStart published running across claude and codex, but a launched-but-unprompted agent has no work in flight and nothing else fires until its first prompt, so running stuck for the life of the session. Four panes had been pinned at claude:running since Jul 30, four days. Changed to idle, matching what pi already did. The picker rendered running with the untagged fallback glyph, making a working agent indistinguishable from a pane holding no agent at all; it now has its own. And claude-status.sh captured ps before the pane list, which can only manufacture false staleness, and its response to staleness is to unset the pane option; the two reads are now in the safe order.

What was striking

The bell flag was doing two jobs and only one of them was written down. The ADR justified dropping it on the grounds that OSC-notifying agents never set it, which is true and was the whole reason for the change. Nobody costed the second job. An edge-triggered state machine needs either an edge for every transition or a level-triggered signal to reconcile against, and “the user is looking at the pane” turns out to be a better reconciler than the bell ever was, because it is a property of tmux rather than of the agent.

The other thing worth remembering: the bug reproduced in the session that was debugging it. Calling AskUserQuestion set %17 to claude:blocked, the question was answered, and the tag was still blocked several tool calls later. The live pane table was the proof.

Then keyed it per session

Went back for the last-writer-wins problem in the same session. @agent_state now holds a set of <pid>:<agent>:<state> entries, one per session, instead of a single state.

The part I expected to be awkward turned out to be free. A session id has to come from somewhere, and no two agents carry one the same way: Claude’s session_id is in the stdin payload the script deliberately never parses, codex and agy differ again, and hook commands are static strings with nowhere to interpolate it. But the hook is always a descendant of the agent, so it can just walk up ps -o ppid=,comm= to the nearest process whose comm is a known agent and use that pid. Two hops from a real session, measured. No per-agent plumbing at all, and the pi extension only needed AGENT_STATE_KEY to skip a walk it already knew the answer to.

Recording the pid paid off twice. Liveness was “is any agent alive in this pane’s subtree”; it became “is this entry’s process a live descendant of this pane”. A dead session is now reaped on its own instead of surviving on a sibling’s pulse, and the walk is up rather than down, so it is O(depth) and a recycled pid rooted elsewhere cannot resurrect a dead entry.

Backward compatibility was not optional: every running session had the old binary in memory writing the old two-field value. Unkeyed entries are read through the old subtree check and dropped the first time a keyed write touches the pane. Watched exactly that play out live, %17 on the new format next to four panes still on the old one, with the count and the blocked marker both correct across the mix.

Then asked why none of it was caught

The sharpest question of the session, and the answer split cleanly in two.

Half of it was a declaration gap and it was as bad as it sounds. AGENT_COMMS copied verbatim into two files (I added the second copy myself while fixing the other bugs). The state set spelled out independently in four files in four languages. Four agents mapping their own event names onto those states as bare strings with nothing to compare them against, which is exactly how they disagreed about session start for months. And the entry format parsed three different ways, so growing it from agent:state to pid:agent:state silently changed what the picker’s ${x#*:} returned: it would have rendered every pane as untagged and said nothing. I only caught that because I happened to be rewriting the block.

The tell was that pi’s extension is the one consumer with an actual type, a TS union that correctly omits blocked because pi cannot produce it, and pi is the one agent that had session_start right.

The other half was not a typing problem at all, and that is the part worth remembering. The count/list mismatch was between two type-correct, internally consistent programs. No type says “the set the bar counts must equal the set the picker lists.” And blocked never retiring was worse than a type error: every value was legal and every transition was legal, the graph just had no edge for one real exit. Both needed a shared definition of the set, or a test that could see both sides. The picker had no tests at all.

So: one shell library owning the vocabulary, the parser, the collapse, the liveness check and the inclusion rule, with nix parsing the same declarations to make a bad state name a build failure. Shell as truth rather than nix, because the unit tests source it straight from the tree.

Writing the consistency test paid for itself immediately: it failed on first run, 3 panes to 2. Not the bug I was encoding, a different one still live. The picker applied no liveness check at all, so it was still listing panes whose agent had exited. Sharing the liveness check was the fix.

Still open

The write path is read-modify-write across two tmux calls, so two sessions in one pane firing in the same instant can lose an update. Self-corrects on the next event, and tmux has no CAS to do better with.

Types Catch Vocabulary Drift, Not Disagreement About a Set · Agent Waiting State Splits Into Blocked and Idle · Tmux Consumes the BEL That Terminates an OSC Sequence · 2026-07-27 Cross-Agent Detection and Waiting State in Tmux · 2026-07-28 Codex Hook Trust and the Unkeyed Model Cache · 2026-07-22 Tmux Picker Single-Click and the scratch-popup 127