Skip to content

feat(hostrunner): blocked panes raise (and retract) attention (P3) - #541

Merged
physercoe merged 2 commits into
mainfrom
feat-panestate-p3
Aug 10, 2026
Merged

feat(hostrunner): blocked panes raise (and retract) attention (P3)#541
physercoe merged 2 commits into
mainfrom
feat-panestate-p3

Conversation

@physercoe

Copy link
Copy Markdown
Owner

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 blocked and carries visible_blocker opens 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 single list-panes round-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-ts above 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. TestIdleDetectorSkipsEveryMappedFamily now 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:809 is an unconditional if lifecycle_authority_active && !process_exited { continue } before the screen is read, and nothing downstream consults visible_blocker for 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.toml also ships bash_permission_prompt and generic_permission_prompt (both visible_blocker), and our claude agents already raise permission_prompt rows from the canUseTool hook 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 (handleListAttention filters 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() from input_parse_buffer() (3.4 input.c:975) on every non-empty chunk of pane output, independent of monitor-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_activity format) 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 idle read wrong for "blocked on approval". It does read wrong: this lane spent P1 making idle and blocked contrasting states. It is still right, because on that surface the kind selects an affordance, and idle is 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 of attentionAwaitsAgentReply, which is what makes /resolve legal 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 the pane_state event all say blocked. Desktop got an approval.attn.idle label 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 /attention honoured a body-supplied actor_handle only when the caller had no handle of its own — a condition that can never hold, because principalFromScope falls 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 recorded actor_kind=operator + the host token's principal instead of the agent that asked. Now keyed on the token kind: only a host token 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.
  • 19 new/changed tests. 8 mutations introduced, 7 caught. The 8th survives the entire suite: the covers() clause of hasAnyStateAuthority changes 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.
  • Full lint set (12 runnable) clean. lint-legacy-markers caught 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 --test desktop suite 795/795, npm run typecheck clean.
  • Docs: plan gains a P3 as-built block plus corrections written next to D-2 and D-6; attention-kinds.md documents idle'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 -J joins wrapped lines where upstream sees wrapped rows, so $-anchored line_regex rules 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

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>
…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>
@physercoe
physercoe merged commit 8422883 into main Aug 10, 2026
8 checks passed
@physercoe
physercoe deleted the feat-panestate-p3 branch August 10, 2026 12:22
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