8000 fix(agent-state): status path handling refinement and edge cases (#151) by derekneely · Pull Request #159 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

fix(agent-state): status path handling refinement and edge cases (#151) - #159

Merged
amirlehmam merged 4 commits into
amirlehmam:masterfrom
derekneely:fix/agent-state-151
Aug 14, 2026
Merged

amirlehmam merged 4 commits into
amirlehmam:masterfrom
derekneely:fix/agent-state-151

Conversation

@derekneely
Copy link
Copy Markdown
Contributor

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.

# Edge case What you see
1 Single-session path doesn't reach workingSessions one agent pane reads Idle mid-turn
2 Three cases in the hook bridge turns age out of working; Needs you on a pane nobody touched; Escape leaves working set
3 SubagentStop can arrive after Stop pane stays on Running after the agent is idle
4 Tool-lifecycle events clear awaitingHuman a question on screen while the row says Running…

1. Read declared session state in the single-session case

workingSessions — the count of panes whose declared state is working — is consulted inside the sessionCount >= 2 branch of claudeStatusText. The single-pane case, which is the common shape, doesn't reach it yet.

It falls through to priority 1, the tool label. 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 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 sessionDetailText already 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 into src/renderer/components/Sidebar/workspace-status.ts and 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.tsx gets ~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 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' in agent-hook-bridge.ts 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, which is the important half. 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.

I handled it in the main process rather than by teaching the helper to send event, since hooks are written into settings.json once 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". 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. I hit it that way: 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 it 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). 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. noteHumanInput treats a bare Escape as answering, so it clears blocked — but an interrupt mid-run leaves working set 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.

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 SubagentStop

SubagentStop maps to an unconditional runDepth: 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 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 Stop hasn'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.ts could 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 Notification no 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, 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 the 60s idle Notification is only armed 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 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's answerAgent's rule 3 working as designed — answering never clears blocked — and answeredAt is 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 on master). 34 tests added.
  • npm run build:main clean.
  • npm run lint → 17 errors / 15 warnings, identical to unmodified master. All pre-existing — I left them alone rather than mix a lint sweep into this.
  • Rebased onto master at 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-state 300×: 72 samples working, then 228 samples blocked "Claude needs your permission", with runDepth staying 1 throughout the blocked stretch, returning to working only 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 updatedAt byte-for-byte before and after an interrupt.

Not in this PR

  • No version bump. package.json and package-lock.json are untouched.
  • No renderer test harness. The 15 status tests cover the extracted chains as pure functions; the component wiring isn't tested, because vitest runs 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.
  • A notification-ring issue I found while testing this — the attention ring not clearing when you type into the pane it's pointing at. Unrelated to agent state and shares no files, so it's a separate PR rather than a passenger here.

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.

derekneely and others added 4 commits August 13, 2026 17:02
…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>
@amirlehmam
amirlehmam merged commit 7a24a8a into amirlehmam:master Aug 14, 2026
@amirlehmam
Copy link
Copy Markdown
Owner

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 commit

1 — 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: workingSessions was consulted only under sessionCount >= 2 in both, so one working pane rendered the word "Idle" in the --done colour. Two implementations of one precedence order is how that happens, and it will happen again. I would not have taken "extract it and add a test" on its own; I take it because the file now makes the next divergence visible on one screen. Keep the extraction.

2 — the bare-tool PostToolUse form. This one is mine and it is embarrassing: I wrote case 'PostToolUse' in a switch that handleHookEvent could never reach it through, and the 0.48.0 write-up describes its behaviour as if it were live. You resolved it in the main process rather than in the helper, and that is the right call for the reason you gave — the bare-tool form is already out in every existing settings.json and will keep arriving from helper vintages this build does not control. A fix that requires the other side to be current is not a fix.

The Notification discriminator is the part I would have got wrong. Using runDepth === 0 rather than the message text is correct, and the two gates are what make it safe rather than clever: turnStartTracked because a pre-0.48.0 config sits at depth 0 through a whole turn, and known because a depth of 0 by default is not a depth of 0 by observation. Both fail toward parking the pane. That is the direction that costs a false "Needs you" instead of swallowing a real permission prompt, and getting that asymmetry right is the whole job here.

Making turnStartTracked global rather than per-surface deserves its comment — I read it as a bug for about ten seconds before the comment explained that the question is "does settings.json carry the 0.48.0 hook set", which is one file for every pane.

3 — the trailing SubagentStop. I asserted in the 0.48.0 comment that Claude Code fires the parent's Stop only after every subagent has stopped. You measured the opposite, on two independent panes, with a 1.8–2.2 s gap. I had no evidence for that claim and it should not have been written as one.

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 answer-agent trade-off and offered to drop this one. I want it, and I think your instinct about which side to err on is right.

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 WMUX_SURFACE_ID, so their tool lifecycle reports to the surface that is asking, and each event retracted the flag on the way past with nothing to re-assert it. "Blocked AND busy" is a legitimate state and resolveState already renders it correctly; the events were lying about the first half.

On the relay path specifically: a block that lingers until the turn ends is visible and self-correcting, and answeredAt says "sent — waiting for the agent" in the meantime. A block that vanishes while the agent is still stuck is the failure this whole subsystem exists to prevent. That is rule 3 working, not a regression against it.

What I did not change

Nothing. Merged as submitted. npx vitest run is at 1004 passing on master with the rest of 1.0.0 on top, and lint is below the 32-problem baseline you measured, because deduplication elsewhere in the release removed some of it.

One thing your PR made me find

Not yours, but it came out of reading the 60-second-nudge reasoning: stripWmuxBlock and the block splice existed in two copies, and when I added a third agent (#165) the new copy silently dropped a trimEnd() the others depended on — appending one blank line per launch to a file the agent loads every session. Same failure mode as your commit 1, in a different file. They share one implementation now.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0