Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,13 @@ const en: Dict = {
'approval.attn.permission_prompt': 'Permission needed',
'approval.attn.select': 'Choose',
'approval.attn.help_request': 'Help needed',
// Host-runner's agent-state rows: the legacy stall detector, and lane P's
// manifest classifier when a pane shows a blocked dialog. Labelled for what
// the reader must do (nothing but look), not for the classification — the
// summary carries "blocked". Without this line the card's kind label falls
// back to the raw wire value and the dock shows a blocked agent under the
// word "idle".
'approval.attn.idle': 'Waiting at a prompt',
// D3: the desktop screenshot card. No "Allow session" button exists for it —
// this line says why, so the missing option reads as a decision.
'att.perCallOnly': 'Screenshots are approved one call at a time — there is no standing grant.',
Expand Down Expand Up @@ -2616,6 +2623,7 @@ const zh: Dict = {
'approval.attn.permission_prompt': '需要授权',
'approval.attn.select': '请选择',
'approval.attn.help_request': '需要协助',
'approval.attn.idle': '等待输入',
'att.perCallOnly': '截屏需逐次批准,不提供长期授权。',

'kanban.todo': '待办',
Expand Down
57 changes: 57 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ binding). Seed entries prior to that are in

### Added

- **A blocked pane now raises attention, and withdraws it again.** When
the pane-state classifier matches a rule that says *blocked* AND
reports a dialog visible on screen, host-runner opens one
attention row for that blocked streak — carrying the matched rule id,
the manifest and its version, and the agent + pane, but never any pane
text (evidence is a rule id; an attention row fans out further than a
transcript, and the pane may be showing a secret). When the
classification leaves blocked — because the human answered the dialog
in the terminal, where nothing would otherwise tell the hub — the row
is resolved, as it is when the agent stops running. It is the first
host-runner row that retracts itself. This closes the concrete gap the
lane opened on: codex sitting at "Allow command?" raised nothing at
all before. The row's kind stays `idle`, deliberately: on that surface
the kind picks the affordance, and it is the only value both clients
already route to acknowledge-only — a new kind would have inherited
the unknown-kind default, which on mobile draws Approve / Reject for a
state report nothing can approve.
- **Idle panes are no longer captured when nothing has happened in
them.** Host-runner reads tmux's `#{window_activity}` stamp in the
same single `list-panes` round-trip it already makes for pane titles,
and skips the `capture-pane` subprocess for a pane whose published
state is idle and whose window has produced no output since the last
read. Blocked and working panes are always re-read, so a stale stamp
can never freeze the state this detector exists to report.
Plan: pane-state-manifests P3.

- **Pane-state detection is live on the host-runner poll tick.** The
evaluator below is now fed from the runner's pane pass: mapped families
whose pane has no live state-authoring driver are captured, classified
Expand Down Expand Up @@ -128,6 +154,37 @@ binding). Seed entries prior to that are in
because the argv the driver uses is threaded per turn, not spliced at
spawn. Pane-state-manifests plan, N1.

### Changed

- **The legacy idle detector's guard now names its own reason.**
`hasStructuredDriver(kind)` becomes `hasAnyStateAuthority(agent)` — a
live structured driver, or manifest coverage, or a registered engine
family. The set of agents it scrapes is unchanged (every mapped family
is a registered one, so mapped panes were already excluded) and a
sweep test now asserts that instead of leaving it a coincidence.
Deliberately conservative: the guard still exempts
registered-but-unmapped families such as `kimi-code-ts`, so
retirement can only shrink the legacy detector's reach, never grow it.
Plan: pane-state-manifests P3.

### Fixed

- **A host-runner-raised attention row was attributed to the host, never
to the agent that asked.** `POST /attention` honoured a body-supplied
`actor_handle` only when the authenticated 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. So the branch was unreachable, and every
row the codex approval bridge has raised since ADR-012 D3 recorded
`actor_kind=host` plus the host token's principal instead of
`agent` plus the agent's handle (the stored kind is the caller's
token kind verbatim, and host-runners run under `host` tokens — an
audit of the affected rows should filter on `actor_kind='host'`). The condition is now the token
*kind*: only a `host` token may name someone else, so an agent's own
token still loses to its context identity. Found by testing the new
pane-state row against the real handler instead of a stub; neither
client renders these two columns yet, which is why it went unnoticed.

### Security

- **A session id from the engine could inject shell into the next
Expand Down
112 changes: 109 additions & 3 deletions docs/plans/pane-state-manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
> push channel). P/N/S/Q are unused elsewhere: **P** reads the pane,
> **Q** writes to it, **N** is native resume, **S** is settle semantics.
> **Audience:** principal · contributors
> **Last verified vs code:** main `e498416d` (2026-08-08;
> `hub/internal/hostrunner/idle.go`, `driver_pane.go`,
> `hub/internal/agentfamilies/`, `docs/reference/attention-kinds.md`)
> **Last verified vs code:** main `9e06d8fa` (2026-08-09; P1/Q1/P2
> shipped and P3 built against it — `hub/internal/panestate/`,
> `hub/internal/hostrunner/panestate_watch.go`, `idle.go`, `runner.go`,
> `hub/internal/server/handlers_attention.go`,
> `docs/reference/attention-kinds.md`; herdr re-read at `6f311498`)

**TL;DR.** Host-runner can only say "this agent needs you" for the
three engines with structured M4 adapters; everything else gets
Expand Down Expand Up @@ -88,6 +90,33 @@ pane-input hardening (`paste-buffer -p`, generic multi-line path).
structured-authority pane MAY raise attention (screen shows a live
permission dialog the hooks never reported). It adjusts attention
only, never the session's driver-authored state.
- **Corrected at P3 (2026-08-09): the exception is not a port, and
it is deferred.** Upstream has no such case — `pane.rs:809` is
`if lifecycle_authority_active && !process_exited { pending_idle
.clear(); continue; }`, an unconditional short-circuit *before*
the screen is read, and nothing downstream consults
`visible_blocker` for a pane it skipped. This is a termipod
invention wearing a port's clothes, so it has to earn its place on
its own evidence. It needs two things first, neither available
today:
1. **Proof it complements rather than duplicates.** The 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 events — with Approve/Deny that
work. Whether the TUI *draws* a dialog the hook has already
parked decides whether this exception adds a signal or a
second, un-actionable row beside the right one. Nobody has
watched a real claude pane (this lane's standing device-verify
debt), and static reading cannot settle it.
2. **A suppression rule if it does duplicate** — "no row while one
is open for this agent" needs a hub query host-runner does not
have (`handleListAttention` filters on status and scope_kind
only, never actor).
One capture of a claude pane mid-permission-prompt settles both.
Until then P3 ships the safe half: panes with no state authority,
where there is no other row to collide with.
- **D-3 — engine-kind mapping lives in the overlay, not in vendored
files.** herdr ids (`claude`, `kimi`, `gemini`) differ from our
family names (`claude-code`, `kimi-code-ts`, `gemini-cli`). A
Expand Down Expand Up @@ -170,6 +199,24 @@ pane-input hardening (`paste-buffer -p`, generic multi-line path).
`lifecycle`, since it is an agent state transition rather
than turn telemetry, and on a raw pane it is often the only
structured signal a reader has.
- **Answered at P3 (2026-08-09): the attention kind stays `idle`,
and for a reason that is not "no new kind was needed".** D-6 left
the door open to minting one if `idle` read wrong for "blocked on
approval". It does read wrong — this lane spent P1 making `idle`
and `blocked` contrasting states — but the kind on this surface
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
(`me_screen.dart` `_filterForAttention`, `inline_actions.dart`
`_isInformational`), and the hub keeps it out of
`attentionAwaitsAgentReply`, which is what makes `/resolve` — the
retract leg — legal at all. A newly minted 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.
- **D-7 — distribution starts embedded, hub later.** P1 embeds
vendor + overlay via `go:embed`; binary upgrades ship rule fixes.
P5 adds hub-distributed updates with herdr's exact hardening:
Expand Down Expand Up @@ -335,6 +382,65 @@ takes a codex approval screen → attention item with rule id; the
idle-shell false-positive class (bare `$` prompt) provably cannot
raise; sweep test: no remaining `IdleDetector` path for mapped kinds.

**As built (2026-08-09).** Attention raise + retract, the capture
gate, and the guard rewrite landed; the D-2 exception did not (see
D-2's own correction above — it is not a port, and settling it needs
one real claude pane). All three acceptance clauses are met, by
`TestBlockedScreenRaisesAttentionWithRuleID`,
`TestBareShellPromptCannotRaiseAttention` and
`TestIdleDetectorSkipsEveryMappedFamily`. Four things worth carrying
forward:

- **The retirement was already done, and the guard this line
proposes would have UNDONE part of it.** Every mapped family is a
registered agent family, and the old guard skipped every registered
family — so `IdleDetector` already never touched a mapped pane.
Replacing it with "has any state authority" *literally* would have
handed the legacy regex the registered-but-unmapped families,
`kimi-code-ts` above all: deliberately unmapped, and an instance
whose M4 launch fell back to a raw pane has no authority of either
sort. That is the W11 TUI-prompt false positive, re-opened by a
wedge whose job was to close things. `hasAnyStateAuthority` keeps
the registered-family clause as its third limb, so the legacy set
only ever contracts. The real change here is precision, not
coverage — plus the sweep test that turns "already disjoint" from a
coincidence into an assertion.
- **The `covers()` clause of that guard is dead today, and says so.**
A mutation deleting it survives the entire suite, because clause 3
subsumes it. It is kept as the clause that names the actual reason,
with the subsumption written down and pinned to the test that would
fail if the overlay ever mapped an unregistered family. Recording a
shadowed guard is better than pretending a test covers it.
- **`#{window_activity}` is sound, with one sharp edge.** tmux calls
`window_update_activity()` from `input_parse_buffer()`
(tmux 3.4 `input.c:975`) on every non-empty chunk of pane output,
independent of `monitor-activity` — that option only gates the
alert. But it is per-WINDOW (tmux 3.4 has no `pane_activity`
format) and one-second resolution, so output landing later in the
same second as the stamp we read is invisible to an equality test —
and for an idle pane that skip would repeat 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 panes whose
published state is idle, exactly as upstream confines it
(`should_skip_idle_screen_scan`, agent_detection.rs:91) — a stale
stamp can never freeze a blocked pane.
- **Attention is decided on every classified tick, not on the
transition.** Deciding it inside the publish branch makes a failed
raise permanent: the streak's transition has already happened, so
the retry tick has nothing to publish and never looks again.

Also landed: `listTmuxPaneTitles` became `listTmuxPaneMeta` (title +
activity in the one round-trip P2's note reserved for it), and
`internal/panestate/region.go`'s `Input.Screen` comment lost the last
copy of the retracted 24-row claim.

19 new/changed tests; 8 mutations introduced, 7 caught, 1 documented
above as shadowed.

**Still owed by this wedge:** the D-2 exception, and the device-verify
line it is blocked on.

### P4 — explain verb + Inspect surface

`host.pane_explain` host verb returns the herdr-style evaluation
Expand Down
24 changes: 22 additions & 2 deletions docs/reference/attention-kinds.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> **Type:** reference
> **Status:** Current (2026-06-05)
> **Audience:** contributors (humans + AI agent maintainers)
> **Last verified vs code:** 2026.730.1231 (D3 introduced `desktop_action`; D5 adds the hub-raised leg)
> **Last verified vs code:** 2026.805.1022 (P3 gives `idle` a second raiser — the pane-state classifier — and its first self-retracting leg)

**TL;DR.** When an agent needs the principal to weigh in, it picks one
of three interaction shapes — `approval_request` (binary), `select`
Expand Down Expand Up @@ -49,7 +49,27 @@ Two more attention kinds exist but are not agent-callable (plus
reviews a structured diff, not a free-text reply. Use that tool when
the right artifact is the template body itself.
- `idle` — emitted by host-runner when an agent is paused awaiting
input. State signal, not a request.
input. State signal, not a request. **Two detectors raise it**, and a
reader tells them apart by `pending_payload`, not by the kind:
- the legacy stall heuristic (`idle.go`) — one prompt regex plus a
90 s content-hash stall, for agents with no state authority at
all. No payload.
- the pane-state manifest classifier (`panestate_watch.go`, plan
P3) — a vendored per-engine rule matched a **drawn blocking
dialog**. `pending_payload.detector` is `"panestate"` and carries
`{state: "blocked", rule_id, manifest_id, manifest_version,
agent_id, pane}`. Never screen text: the evidence is a rule id,
because an attention row fans out further than a transcript does
and the pane may be showing a secret. Raised once per blocked
streak and **withdrawn via `/resolve`** when the classification
leaves blocked — the only host-runner row that retracts itself.

A blocked agent therefore arrives under the kind named `idle`. That
is deliberate: on this surface the kind selects the affordance (both
clients route `idle` to acknowledge-only), and no kind that means
"blocked" would have inherited it — an unrecognized kind carrying a
`pending_payload` draws Approve / Reject on mobile, for a row nothing
can approve. The summary and payload carry the classification.

### The gated sibling — `browser_action`

Expand Down
21 changes: 21 additions & 0 deletions hub/internal/hostrunner/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,27 @@ func (c *Client) PostAttention(ctx context.Context, in AttentionIn) (AttentionOu
return out, err
}

// ResolveAttention closes an open row WITHOUT fanning a reply to any agent —
// the hub's dismiss path (`/resolve`), not the decision path (`/decide`).
//
// host-runner uses it to retract a row whose cause is gone: a pane-state
// `blocked` streak that ended because the human answered the dialog in the
// terminal (plan P3). Nothing is owed to the agent — the row was a state
// report, so there is no parked turn to wake.
//
// The hub REFUSES /resolve for kinds that owe a waiting agent a reply
// (`attentionAwaitsAgentReply`), which is one more reason a detector-raised
// row must not borrow one of those kinds.
//
// A 409 means the row was already resolved — the director dismissed it before
// we noticed the pane moved on. That is the normal race, not a fault: callers
// log at debug and drop the id either way.
func (c *Client) ResolveAttention(ctx context.Context, id string) error {
return c.do(ctx, http.MethodPost,
fmt.Sprintf("/v1/teams/%s/attention/%s/resolve", c.Team, id),
map[string]any{}, nil)
}

type AgentPatch struct {
Status *string `json:"status,omitempty"`
PauseState *string `json:"pause_state,omitempty"`
Expand Down
11 changes: 11 additions & 0 deletions hub/internal/hostrunner/driver_appserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ type AttentionPoster interface {
PostAttention(ctx context.Context, in AttentionIn) (AttentionOut, error)
}

// AttentionResolver is the retract half: it closes a row this host-runner
// raised, once the condition that justified it is gone. Only a raiser that
// keeps watching its own condition can implement this honestly — the codex
// bridge above cannot (its rows are answered, not withdrawn), the pane-state
// watcher can (plan P3: a blocked streak ends when the screen stops saying
// blocked). Split from AttentionPoster so the two capabilities are asked for
// separately rather than one interface implying the other.
type AttentionResolver interface {
ResolveAttention(ctx context.Context, id string) error
}

// pendingApproval tracks one server-initiated approval request that
// has been bridged to an attention_items row but not yet resolved.
// jsonRPCID is the parked codex request id we'll respond on; method
Expand Down
Loading
Loading