feat(hostrunner): blocked panes raise (and retract) attention (P3) - #541
Merged
Conversation
Wedge P3 of docs/plans/pane-state-manifests.md. The classifier wired in
by P2 could see a codex approval dialog but could not tell anyone; now a
`blocked` classification carrying `visible_blocker` opens one attention
row per blocked streak, and withdraws it when the classification leaves
blocked — the human answered the dialog in the terminal, where nothing
would otherwise tell the hub. It is the first host-runner row that
retracts itself.
Evidence is a rule id, a manifest and its version, and the agent + pane;
never screen text. An attention row fans out further than a transcript
does, and the pane may be showing a secret. P4's explain verb is where a
human asks for the region preview.
Also in this wedge:
- Capture-cost gating (B5). tmux's `#{window_activity}` rides the same
single `list-panes` round-trip P2 already made for pane titles, and an
idle pane whose window produced no output since the last read is not
captured at all. Confined to idle panes exactly as upstream confines
it, so a stale stamp can never freeze a blocked one.
- The stall detector's guard becomes `hasAnyStateAuthority(agent)`.
Four findings, each from checking rather than assuming:
1. The retirement this wedge was meant to perform HAD ALREADY HAPPENED,
and doing it literally would have partly undone it. Every mapped
family is a registered family, and the old guard skipped every
registered family — so the stall detector already never touched a
mapped pane. Swapping in "has any state authority" alone would have
handed it the registered-but-unmapped families, `kimi-code-ts` above
all, re-opening the W11 TUI-prompt false positive. The registered
family clause stays, so the set can only contract, and a sweep test
now asserts the disjointness instead of leaving it a coincidence.
2. D-2's structured-authority exception is NOT a port and is deferred.
Upstream short-circuits on `lifecycle_authority_active` before the
screen is read (pane.rs:809) and never consults `visible_blocker` for
a pane it skipped. Whether the exception would complement claude's
hook-raised `permission_prompt` rows or duplicate them turns on
whether the TUI draws a dialog the hook already parked — one capture
of a real claude pane settles it, and static reading cannot.
3. `#{window_activity}` is sound but one-second granular, so a stamp read
during the second it names cannot be compared for equality later —
output landing later in that same second is invisible, and for an idle
pane that skip would repeat forever. The gate arms only on a stamp
whose second had already elapsed. (tmux calls window_update_activity()
from input_parse_buffer(), independent of monitor-activity; verified
against the 3.4 source. There is no per-pane equivalent.)
4. The attention kind stays `idle`, which reads wrong next to this lane's
own `blocked`, and is still right: on that surface the kind picks the
affordance, and it is the only value both clients already route to
acknowledge-only. A new kind inherits the unknown-kind default, which
on mobile draws Approve / Reject for a state report nothing can
approve.
Fixes, found by testing against the real handler instead of a stub:
`POST /attention` honoured a body-supplied `actor_handle` only when the
caller had no handle of its own, which can never hold — principalFromScope
falls back to "@principal" for absent handle, absent role and unparseable
JSON alike. Every codex-bridge row since ADR-012 D3 has been attributed
to the host token's principal rather than the agent. Now keyed on the
token kind, so only a `host` token may name someone else.
19 new/changed tests; 8 mutations introduced, 7 caught, and the 8th is
documented in place as a clause that changes no answer today rather than
pretending a test covers it. Full `go test ./...` green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8 tasks
…d=host, not operator The Fixed entry for the unreachable actor_handle branch said the affected rows recorded `actor_kind=operator`. The stored value is the caller's token kind verbatim (`actorFromContext` returns `tok.Kind`, inserted unmapped), and host-runners run under `host`-kind tokens (handlers_admin_tokens.go mints them; the install guide issues one) — so the rows say `host`. An auditor following the changelog would have queried `actor_kind='operator'` and found nothing. Review pass 2026-08-10. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wedge P3 of
docs/plans/pane-state-manifests.md. P2 gave the classifier a voice in the event feed; P3 gives it the one output the lane exists for — a codex agent sitting on "Allow command?" now raises attention. It raised nothing at all before.What ships
Blocked → attention, once per streak, and retracted. A classification that is
blockedand carriesvisible_blockeropens one row. It closes when the classification leaves blocked — the human answered the dialog in the terminal, where nothing else would ever tell the hub — and when the agent stops running. First host-runner row that withdraws itself.Evidence is a rule id, the manifest + version, and the agent + pane. Never screen text, in the summary or the payload — an attention row fans out further than a transcript does, and the pane may be showing a secret. P4's explain verb is where a human asks for the region preview.
Capture-cost gating (B5).
#{window_activity}rides the singlelist-panesround-trip P2 already made for titles. An idle pane whose window has produced no output since the last read is not captured at all — no subprocess, no evaluation.The stall detector's guard becomes
hasAnyStateAuthority(agent).Four things I found by checking
1. The retirement had already happened — and doing it literally would have partly undone it. ★
The plan says the guard becomes "has any state authority". But every mapped family is a registered agent family, and the old guard skipped every registered family, so the stall detector already never touched a mapped pane. Replacing it with the plan's wording alone would have handed it the registered-but-unmapped families —
kimi-code-tsabove all, deliberately unmapped, whose M4 fallback to a raw pane satisfies neither authority test. That is the W11 TUI-prompt false positive, re-opened by the wedge meant to close things. The registered-family clause stays as the third limb, so the set can only contract.TestIdleDetectorSkipsEveryMappedFamilynow asserts the disjointness instead of leaving it a coincidence.2. D-2's structured-authority exception is not a port, and is deferred. ★★
The plan calls it "one ported exception". Upstream has no such case:
pane.rs:809is an unconditionalif lifecycle_authority_active && !process_exited { continue }before the screen is read, and nothing downstream consultsvisible_blockerfor a pane it skipped. So it is a termipod invention wearing a port's clothes, and it has to earn its place on its own evidence.Its target case is claude's trust dialog, which is hook-blind. But
claude.tomlalso shipsbash_permission_promptandgeneric_permission_prompt(bothvisible_blocker), and our claude agents already raisepermission_promptrows from thecanUseToolhook for those — with Approve/Deny that work. Whether the TUI draws a dialog the hook has already parked decides whether this adds a signal or a second un-actionable row beside the right one. Nobody has watched a real claude pane, and static reading cannot settle it. If it does duplicate, the fix needs "no row while one is open for this agent" — a hub query host-runner doesn't have (handleListAttentionfilters status and scope_kind, never actor).P3 ships the safe half: panes with no state authority, where there is no other row to collide with. One capture settles the rest; it is booked on the lane's device-verify line and recorded in the plan next to the claim it corrects.
3.
#{window_activity}is sound, with a sharp edge. ★tmux calls
window_update_activity()frominput_parse_buffer()(3.4input.c:975) on every non-empty chunk of pane output, independent ofmonitor-activity— that option only gates the alert. Verified against the 3.4 source, not assumed.But it is per-window (3.4 has no
pane_activityformat) and one-second resolution. Output landing later in the same second as the stamp we read produces an identical stamp, so an equality test reads "nothing happened" when something did — and for an idle pane that skip repeats forever. The gate therefore arms only on a stamp whose second had already elapsed when we captured (now.Unix() > activity), which makes equality sound rather than probabilistic. Skipping is also confined to idle panes exactly as upstream confines it, so a stale stamp can never freeze a blocked one.4. The attention kind stays
idle— and that is a decision, not a default. ★★D-6 left the door open to minting a kind if
idleread wrong for "blocked on approval". It does read wrong: this lane spent P1 makingidleandblockedcontrasting states. It is still right, because on that surface the kind selects an affordance, andidleis the only value both clients already route correctly for a row a human can acknowledge but not answer — mobile buckets it under Agents with a single Dismiss, and the hub keeps it out ofattentionAwaitsAgentReply, which is what makes/resolvelegal at all.A new kind inherits the unknown-kind default instead, and on mobile that default is Approve / Reject for any row carrying a
pending_payload: two buttons on a state report nothing can approve. Same hazard P2 found in the event feed, second registry, opposite direction. The collision is contained to the wire name — summary, payload and thepane_stateevent all say blocked. Desktop got anapproval.attn.idlelabel in both dicts so the dock stops printing the raw wire value.One fix, outside the wedge
Testing the row against the real handler instead of a stub turned up a live bug.
POST /attentionhonoured a body-suppliedactor_handleonly when the caller had no handle of its own — a condition that can never hold, becauseprincipalFromScopefalls back to"@principal"for an absent handle, an absent role, and unparseable scope JSON alike. The branch was unreachable, so every row the codex approval bridge has raised since ADR-012 D3 recordedactor_kind=operator+ the host token's principal instead of the agent that asked. Now keyed on the token kind: only ahosttoken may name someone else, so an agent's own token still loses to its context identity. Neither client renders those two columns yet, which is why it went unnoticed.Verification
go test ./...— green, exit 0, 33 packages, zero failures.covers()clause ofhasAnyStateAuthoritychanges no answer today, because clause 3 subsumes it. It is documented in place as exactly that, pinned to the test that would fail if the overlay ever mapped an unregistered family — recording a shadowed guard beats implying a test covers it.lint-legacy-markerscaught six new "legacy detector" comments; rather than name a fake removal target I renamed the thing — it is the stall detector, and it is not scheduled for removal.node --testdesktop suite 795/795,npm run typecheckclean.attention-kinds.mddocumentsidle's second raiser and its retract leg; changelog Added/Changed/Fixed.Not proven
No engine has been observed live. Every screen in these tests is upstream's own corpus.
capture-pane -Jjoins wrapped lines where upstream sees wrapped rows, so$-anchoredline_regexrules are where a real-pane divergence would show first. The device-verify debt is unchanged and now also carries the D-2 question above.Plan: pane-state-manifests P3. Follows #536 (P2), #534 (P1+Q1), #533 (N1), #532 (plan review).
🤖 Generated with Claude Code