Repository navigation
fix(agent-state): status path handling refinement and edge cases (#151) - #159
Conversation
…mirlehmam#151) 0.48.0 gave the sidebar the signal it needs — wmux now hears a turn start. The status chain consults it in the multi-session summary; the single-session path doesn't reach it yet, and that's the common shape. `workingSessions` — the count of panes whose DECLARED 8000 state is `working` — is read inside the `sessionCount >= 2` branch. One agent pane in a workspace falls through to the tool label, and PostToolUse fires when a tool FINISHES, so during a stretch of thinking or one slow tool the label expires while the turn runs on. The chain then reaches shell state, and the Claude Code TUI is not a "running command" as far as shell integration is concerned — so the row renders "Idle" mid-turn. The per-session sub-line directly beneath it reads "Running…" at that same moment, from the same store, because sessionDetailText already consults it. Priority 1.5 closes it: below the tool label, which says more ("Reading file…"), and above everything after it, so the agent's own claim outranks the decaying inferences wmux makes about it. The two chains are extracted to `workspace-status.ts` rather than edited in place. They're written out twice — one yields words, the other a CSS modifier — and the single-session case sits in both, which is why this shows up as a colour mismatch about as often as a wrong word. Side by side in one file, with the invariant stated, a priority added to one is visibly missing from the other. The precedence chain is also the part worth testing, and it couldn't be tested inside the component; 15 tests now pin it, including that the two chains agree. Pure extraction otherwise — no behaviour change beyond priority 1.5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0.48.0 closed the main path — wmux now hears a turn start. Three narrower
ones on the same code turned up while running it day to day.
**The per-tool PostToolUse hooks don't reach agent state.**
`handleHookEvent` gates on `params.event`, and the PostToolUse entries
claude-context.ts installs invoke the hook helper by bare tool name, which
sends `{ tool }` with no `event` field. Those get dropped before reaching
the bridge, so `case 'PostToolUse'` isn't currently reached.
Since 0.48.0 that no longer costs the run itself — matcher-less PreToolUse
declares it at the start of every tool. It still costs the other end:
PostToolUse is the only report that arrives between approving a permission
prompt and the turn closing, and it carries the freshness stamp that keeps
a long turn inside the trust window. Without it a turn spent in tracked
tools ages out of `working` on the clock alone.
Handled in the main process rather than by teaching the helper to send
`event`: hooks are written into settings.json once at install, so the
bare-tool form is already out in every existing config and will keep
arriving from helper vintages this build doesn't control.
**The 60s idle nudge reads as "Needs you".**
Notification covers two unrelated things: a permission/question prompt, and
the ~60s "Claude is waiting for your input" nudge armed when a turn ends.
Parking the pane for both means a pane nobody has touched starts asking for
attention a minute after it goes quiet. Seen that way here: a session was
/clear'd and left alone, and 60s after its Stop the sidebar read "Needs
you" with an empty conversation and nothing to answer.
Told apart by STATE, not by message text — text-sniffing would break the
day Claude Code rewords a prompt, and would fail in the dangerous
direction. A prompt can only occur inside a live turn, and
UserPromptSubmit/PreToolUse both declare depth 1 before Claude Code can ask
anything, so depth 0 means the turn is over.
Gated twice against reading too much into a default: on having seen an
opening hook fire at all (a pre-0.48.0 settings.json sits at depth 0
through a whole turn), and on the pane actually having a record (a main
process restarted mid-turn has none). Either way it parks the pane, as
before.
This is also what keeps noteHumanInput's bargain sound. That code clears a
block when the human types and leans on "if the keystroke didn't satisfy
the agent, the 60-second nudge puts the pane back to asking" — which holds
as long as the nudge doesn't also assert a block on a pane that has
finished. The nudge still re-asserts inside a live turn, which is the case
self-heal needs, and no longer outside one.
**Escape can leave a run asserted.**
noteHumanInput treats a bare Escape as answering, so it clears `blocked` —
but an interrupt mid-run leaves `working` asserted for a turn the user
cancelled. Claude Code fires no hook on interrupt: `updatedAt` is
byte-identical before and after, so there's no report to act on rather than
a stale one. The pane keeps claiming `working` until the next prompt or the
15-minute trust window.
Exact-match on the single ESC byte rather than isAnsweringInput's "contains
a bare ESC": Alt+key also arrives as ESC followed by a character, and
reading that as an interrupt would retract a run nobody stopped. Only ever
clears, and only for a pane that has declared something, so a plain shell
is untouched and a wrong guess under-reports for exactly one event.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mirlehmam#151) SubagentStop maps to an unconditional runDepth of 1, which is right when Claude Code fires the parent's Stop only after every subagent has stopped. Traced at the hook helper on two independent panes here, the wire order came out the other way round: Stop first, SubagentStop 1.8-2.2s later. In that order the trailing report re-asserts the run Stop had just ended, and the pane sits on "Running" — with the agent idle at its prompt — until the 15-minute trust window expires. The ordering may well vary by Claude Code version or by how the subagent exits, so this is written as a guard on top of the existing mapping rather than as a replacement for it: when Stop hasn't fired, behaviour is exactly as before. The wall-clock gate in agent-state.ts doesn't cover this, and shouldn't try to. It drops reports OLDER than what has been accepted, which is the right defence against two hook processes racing to the pipe. This isn't a race — the trailing report is genuinely newer. A subagent finishing may SUSTAIN a turn; it may never START one. The current depth separates the two, using the one fact that distinguishes them: the parent's Stop has already zeroed it. Also covers a second symptom that follows from the re-assertion — with depth back at 1, the 60s idle Notification no longer qualified as a nudge, so a pane with nothing to answer went on to claim "Needs you" a minute later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…irlehmam#151) An agent that starts a background shell and then asks the user a question shows "Running…" with the question still on screen, and nothing corrects it until the turn ends. PreToolUse, PostToolUse and SubagentStop each report `awaitingHuman: false` alongside their run depth. For PostToolUse the reasoning in the comment is sound as written — a pending permission dialog would have stopped the tool from running at all — and it holds for an agent doing one thing at a time. It comes apart for the two things agents do most: a background shell and a parallel subagent both inherit the pane's WMUX_SURFACE_ID, so their tool lifecycle reports to the surface that's asking, and each event clears the block on the way past. Nothing re-asserts it, because Claude Code arms the 60s idle Notification only after a turn ENDS, and the turn is still open. Those three events now report `runDepth` only. runDepth and awaitingHuman are independent facts and the tool lifecycle speaks to the first. A block is ended by UserPromptSubmit, Stop, a keystroke in the pane (noteHumanInput), or an explicit report — the things that know a human dealt with it. resolveState already ranks `blocked` above `runDepth > 0`, so a pane reads "needs you" WHILE its background work keeps running, which is the state the report describes. Trade-off worth a look: a prompt answered through `wmux answer-agent` (a relay, not a local keystroke) keeps saying "needs you" until the turn ends rather than until the next tool. That's answerAgent's rule 3 working as designed, and `answeredAt` is what the UI shows in the meantime — but it is a change on the relay path. Verified: reproduced against the live pipe with the real hook helper (Notification -> blocked, SubagentStop -> working). 915/915 unit tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Merged, all four commits, in 1.0.0. This is the best PR this project has had. Not because the diff is large — because every claim in it is one you measured, and two of the four commits turn on a fact I had asserted in a comment and never checked. On each commit1 — the single-session path. You are right that the ~280 lines are justified, and the reason is stronger than "it's testable". The two chains had already diverged on exactly the line you were fixing: 2 — the bare-tool The Making 3 — the trailing Shipping it as a guard on top of the existing mapping rather than a replacement is the right shape given the ordering may vary by version and by how the subagent exits. And your note that the wall-clock gate cannot cover it is the part worth keeping in mind: that gate drops reports older than what was accepted, which is the right defence against two hook processes racing to the pipe — this is not a race, the trailing report is genuinely newer, and no amount of timestamp discipline fixes a report that is late by design. 4 — keeping a live question while background work runs. Taken, not dropped. You flagged the The premise I wrote — "a pending permission dialog would have stopped the tool from running at all" — holds for an agent doing one thing at a time and is false for the two things agents actually do. Background shells and parallel subagents inherit the pane's On the relay path specifically: a block that lingers until the turn ends is visible and self-correcting, and What I did not changeNothing. Merged as submitted. One thing your PR made me findNot yours, but it came out of reading the 60-second-nudge reasoning: Thank you for running this as your daily driver for two weeks and writing down what you saw. The edge cases you listed as still-hard — nested subagents, a turn ending while a subagent runs, a session answered from another pane — are exactly the ones I would most like eyes on next, and you now have more evidence about them than I do. |
Follow-up to #151, on top of the 0.48.0 rewrite.
That change is what made the rest of this findable — once wmux could hear a turn start, the status became reliable enough that the remaining wrong readings had a shape instead of feeling random. I've been running it as my daily driver since, first on 0.48.0 and then on 0.50.0, mostly with several Claude panes going at once. Four narrower paths turned up in that use, and each one still surfaces as a symptom from my original report.
None of this changes the model 0.48.0 established. It's the same hook-driven design, extended to a few shapes that don't come up until you lean on it: one agent pane instead of several, background shells, parallel subagents, and interrupts.
Four commits, in dependency order. They're independent enough to be reviewed one at a time, and I'm happy to split any of them out.
workingSessionsworking; Needs you on a pane nobody touched; Escape leavesworkingsetSubagentStopcan arrive afterStopawaitingHuman1. Read declared session state in the single-session case
workingSessions— the count of panes whose declared state isworking— is consulted inside thesessionCount >= 2branch ofclaudeStatusText. The single-pane case, which is the common shape, doesn't reach it yet.It falls through to priority 1, the tool label.
PostToolUsefires when a tool finishes, so during a stretch of thinking or one slow tool the label expires while the turn runs on. The chain then reaches shell state at priority 3, and the Claude Code TUI isn't a "running command" as far as shell integration is concerned — so the row renders Idle mid-turn.The per-session sub-line directly beneath it reads "Running…" at that same moment, from the same store, because
sessionDetailTextalready consults it. So the data is there and correct; it's the workspace-level chain that doesn't reach it.Priority 1.5 closes it: below the tool label, which says more ("Reading file…"), and above everything after it, so the agent's own claim outranks the decaying inferences wmux makes about it.
Why this commit is ~280 lines and not 3. The status text chain and the status colour chain are written out twice in
WorkspaceRow.tsx, and the single-session case sits in both — which is why this shows up as a colour mismatch about as often as a wrong word. I pulled both intosrc/renderer/components/Sidebar/workspace-status.tsand fed them from one shared input object, with the invariant written down. Side by side in one file, a priority added to one is visibly missing from the other. It's also the part worth testing and it couldn't be tested inside the component; 15 tests now pin it, including that the two chains agree.Pure extraction otherwise — no behaviour change beyond priority 1.5.
WorkspaceRow.tsxgets ~110 lines shorter as a result.If you'd rather keep the chains inline, the behavioural part is a few lines and I can resubmit it that way.
2. Three edge cases in the hook bridge
The per-tool
PostToolUsehooks don't reach agent state.handleHookEventgates onparams.event, and thePostToolUseentriesclaude-context.tsinstalls invoke the hook helper by bare tool name, which sends{ tool }with noeventfield. Those get dropped before reaching the bridge, socase 'PostToolUse'inagent-hook-bridge.tsisn't currently reached.Since 0.48.0 that no longer costs the run itself — matcher-less
PreToolUsedeclares it at the start of every tool, which is the important half. It still costs the other end:PostToolUseis the only report that arrives between approving a permission prompt and the turn closing, and it carries the freshness stamp that keeps a long turn inside the trust window. Without it, a turn spent in tracked tools ages out ofworkingon the clock alone.I handled it in the main process rather than by teaching the helper to send
event, since hooks are written intosettings.jsononce at install — the bare-tool form is already out in every existing config, and will keep arriving from helper vintages this build doesn't control. Open to the other direction if you'd prefer the helper be the source of truth.The 60s idle nudge reads as "Needs you".
Notificationcovers two unrelated things: a permission/question prompt, and the ~60s "Claude is waiting for your input" nudge armed when a turn ends. Parking the pane for both means a pane nobody has touched starts asking for attention a minute after it goes quiet. I hit it that way: a session was/clear'd and left alone, and 60s after itsStopthe sidebar read Needs you with an empty conversation and nothing to answer.Told apart by state, not by message text. Text-sniffing would break the day Claude Code rewords a prompt, and it would fail in the dangerous direction. A prompt can only occur inside a live turn, and
UserPromptSubmit/PreToolUseboth declare depth 1 before Claude Code can ask anything — so depth 0 means the turn is over.Gated twice against reading too much into a default: on having seen an opening hook fire at all (a pre-0.48.0
settings.jsonsits at depth 0 through a whole turn), and on the pane actually having a record (a main process restarted mid-turn has none). In either of those cases it parks the pane, exactly as today.That gating is also what keeps
noteHumanInput's bargain sound. It clears a block when the human types, and leans on "if the keystroke didn't satisfy the agent, the 60-second nudge puts the pane back to asking" — which works as long as the nudge doesn't also assert a block on a pane that has finished. The nudge still re-asserts inside a live turn, which is the case self-heal needs, and no longer outside one.Escape can leave a run asserted.
noteHumanInputtreats a bare Escape as answering, so it clearsblocked— but an interrupt mid-run leavesworkingset for a turn the user cancelled. Claude Code fires no hook on interrupt:updatedAtis byte-identical before and after, so there's no report to act on rather than a stale one. The pane keeps claimingworkinguntil the next prompt or the 15-minute trust window.I matched on the single ESC byte exactly, rather than reusing
isAnsweringInput's "contains a bare ESC": Alt+key also arrives as ESC followed by a character, and reading that as an interrupt would retract a run nobody stopped. It only ever clears, and only for a pane that has declared something — so a plain shell is untouched, and a wrong guess under-reports for exactly one event.3. Guard against a trailing
SubagentStopSubagentStopmaps to an unconditionalrunDepth: 1, which is right when Claude Code fires the parent'sStoponly after every subagent has stopped.Traced at the hook helper on two independent panes here, the wire order came out the other way round:
Stopfirst,SubagentStop1.8–2.2s later. In that order the trailing report re-asserts the runStophad just ended, and the pane stays on Running — with the agent idle at its prompt — until the 15-minute trust window expires.I'd expect this ordering to vary by Claude Code version and by how the subagent exits, so I wrote it as a guard on top of the existing mapping rather than a replacement for it: when
Stophasn't fired, behaviour is exactly as before. A subagent finishing may sustain a turn; it may never start one, and the current depth is the fact that separates the two.I looked at whether the wall-clock gate in
agent-state.tscould cover it, and I don't think it should. That gate drops reports older than what's been accepted, which is the right defence against two hook processes racing to the pipe — and this isn't a race, the trailing report is genuinely newer.This also covers a second symptom that follows from the re-assertion: with depth back at 1, the 60s idle
Notificationno longer qualified as a nudge under (2), so a pane with nothing to answer went on to claim Needs you a minute later.4. Keep a live question while background work runs
This is the one that finally explained my original report to me. An agent that starts a background shell and then asks the user a question shows Running… with the question still on screen, and nothing corrects it until the turn ends.
PreToolUse,PostToolUseandSubagentStopeach reportawaitingHuman: falsealongside their run depth. ForPostToolUsethe reasoning in the comment is sound as written — a pending permission dialog would have stopped the tool from running at all — and it holds for an agent doing one thing at a time. It comes apart for the two things agents do most: a background shell and a parallel subagent both inherit the pane'sWMUX_SURFACE_ID, so their tool lifecycle reports to the surface that's asking, and each event clears the block on the way past. Nothing re-asserts it, because the 60s idleNotificationis only armed after a turn ends, and the turn is still open.Those three events now report
runDepthonly.runDepthandawaitingHumanare independent facts, and the tool lifecycle speaks to the first. A block is ended byUserPromptSubmit,Stop, a keystroke in the pane (noteHumanInput), or an explicit report — the things that know a human dealt with it.resolveStatealready ranksblockedaboverunDepth > 0, so a pane reads "needs you" while its background work keeps running, which is the state my report was describing.One trade-off I want to put in front of you rather than bury: a prompt answered through
wmux answer-agent(a relay, not a local keystroke) keeps saying "needs you" until the turn ends, rather than until the next tool. That'sanswerAgent's rule 3 working as designed — answering never clearsblocked— andansweredAtis what the UI shows in the meantime. I think it errs on the right side, but it is a change on the relay path, and it's your call. Happy to drop this commit and ship the other three if you'd rather keep the current behaviour there.Verification
npx vitest run→ 915 passed, 78 files (881 in 77 onmaster). 34 tests added.npm run build:mainclean.npm run lint→ 17 errors / 15 warnings, identical to unmodifiedmaster. All pre-existing — I left them alone rather than mix a lint sweep into this.masterat 0.50.0 (8122b69), no conflicts.Live end-to-end, on a build of this branch: started two background shells, then asked a question. Polled
wmux agent-state300×: 72 samplesworking, then 228 samplesblocked "Claude needs your permission", withrunDepthstaying1throughout the blocked stretch, returning toworkingonly after I answered. Sidebar read Needs you the whole time. That's (4).(3) was traced at the hook helper across two panes; (2)'s Escape case by comparing
updatedAtbyte-for-byte before and after an interrupt.Not in this PR
package.jsonandpackage-lock.jsonare untouched.environment: 'node'with no jsdom here and adding one isn't a bug fix. Glad to do it as its own PR if you want that pinned down.Thanks for 0.48.0 and for the detailed write-up on the issue — that's what let me go looking in the right places. Happy to rework any of this to fit how you'd rather it be shaped.