Skip to content

feat(hostrunner): wire the pane-state evaluator into the poll tick (P2) - #536

Merged
physercoe merged 1 commit into
mainfrom
feat-panestate-p2
Aug 9, 2026
Merged

feat(hostrunner): wire the pane-state evaluator into the poll tick (P2)#536
physercoe merged 1 commit into
mainfrom
feat-panestate-p2

Conversation

@physercoe

Copy link
Copy Markdown
Owner

Lane P wedge P2 of docs/plans/pane-state-manifests.md. P1 (#534) landed internal/panestate as a pure library with no wiring; this feeds it.

Mapped agent families whose pane has no live state-authoring driver are captured each poll tick, classified against the vendored manifests, and every transition posts an agent event with {state, rule_id, manifest_version, previous_state}.

Five things came out different from the plan

Each because a load-bearing claim in it was secondhand. I pulled herdr at the pinned commit (6f311498, the one the manifests are vendored from) and read the source.

1. It is wired into the runner tick, not PaneDriver's. The plan's P2 line says the latter, but three of its own decisions can't be satisfied inside a driver: D-3 needs the agent's family (PaneDriver knows an agent id and a pane id); D-4 wants ONE list-panes round-trip for all panes; D-2's ported exception is about panes PaneDriver doesn't own. The runner already enumerates every running pane for the idle detector, so this adds no new pass — and P3's detector retirement + capture gating both live there too.

2. The D-2 authority gate asks the driver, not the kind. hasStructuredDriver(kind) is a proxy for "its adapter reports state", and it's wrong exactly where it matters: a codex spawn whose M2 launch failed walks the mode ladder down to a raw PaneDriver, keeps kind = codex, and has nothing reporting state at all. The new gate reads the live driver map — a bare *PaneDriver, and an absent driver (a pane that outlived a host-runner restart), are both "no authority". Upstream gates the same way one layer up: lifecycle_authority_active short-circuits before the screen is read (src/pane.rs:807).

3. panestate cannot be an event producer. D-6 names it one. The event-ingest endpoint accepts agent|user|system and 400s the rest (handlers_agent_events.go:95); the agent-input endpoint has its own closed user|a2a vocabulary. Neither has a per-subsystem extension point. The producer axis answers whose bytes are these, and these are host-runner's — so this ships as kind pane_state, producer system, the stamping PaneDriver already uses for synthesized lifecycle events. This binds vision-parity L3/L4's local drivers too, and closes the cross-plan check both plans had recorded as open.

4. No capture trimming. D-4 calls the geometry "the bottom-anchored last 24 rows". Upstream's ghostty_detection_text reads terminal.rows() and falls back to DEFAULT_DETECTION_ROWS = 24 only when the row count is unavailable (src/pane/terminal.rs:2468-2475). 24 is a fallback, not a contract — trimming to it would have blinded the rules on any pane taller than 24 rows, and top_*-region rules first. capture-pane -p -J already returns the visible screen.

5. D-5's constants are verbatim; its shape was one step off. All four check out (AGENT_PENDING_IDLE_RECHECK 100 ms, ..._CONFIRMATIONS 3, ..._CAP 700 ms, AGENT_STARTUP_GRACE_WINDOW 3 s) — but the first plain-idle observation arms the hold with zero confirmations, so the release lands on the fourth observation, and the cap releases the hold rather than bounding it. Which end fires depends entirely on cadence: upstream polls at 100 ms so the confirmations win; we poll at 3 s so the cap always wins and the hold costs exactly one tick. Both are ported so a future faster tick can't silently change the semantics. Two smaller findings: upstream publishes on a change to the state or any visible_* hint, not the state alone; and at identification it stores last_visible_idle = true while publishing visible_idle: false, which I didn't copy — storing a hint we never published makes the first real classification emit a redundant idle.

Client sweep

An unrecognized event kind renders as a raw card in both modes on both clients. Busy inference is allowlist-shaped (mobile v1.0.721's invert), so a new kind is no-signal there and safe — but rendering is not, so pane_state joins the verbose-only tier in feedLens.ts and feed_reducer.dart. Same tier as lifecycle: an agent state transition, not turn telemetry, and on a raw pane often the only structured signal a reader has.

Also

Renamed hostrunner.paneStateidleProbeState (and Runner.panesidleProbes). The older detector's per-pane hash bookkeeping was holding the exact term this lane's package owns. Mechanical; the alignment churn in runner_launch_paneless_test.go is one extra space per sibling key in seven struct literals.

Verification

  • 19 new tests. Seven mutations of the new guards were introduced and all seven caught — D-2 gate, D-3 mapping, 700 ms cap, skip_state_update freeze, startup grace, authority-as-kind-proxy, and title parsing by Fields instead of first-space.
  • go test ./... green, all 41 packages (internal/server re-run with -count=1: ok, 564 s).
  • Desktop node --test: 795 pass. npm run typecheck clean. Token ratchet clean (65, phantom 0).
  • Full runnable lint set green. lint-legacy-markers initially failed on four new comments and each now names P3 as the removal target, which is true rather than lint-appeasing.
  • No tmux server was started at any point; every seam is injected.

What this does NOT prove

No engine has been observed live. Every screen in these fixtures is upstream's own test data. The lane's device-verify debt now also covers "does a real codex pane, captured through capture-pane -p -J, classify the way the fixture does" — -J joins wrapped lines, where upstream sees the wrapped rows, so $-anchored line_regex rules are where a divergence would show first. Recorded in the plan, not smoothed over.

Nothing raises attention yet and IdleDetector still runs for every pane it ran for before — both are P3.

🤖 Generated with Claude Code

P1 landed `internal/panestate` as a pure library. This feeds it: mapped
agent families whose pane has no live state-authoring driver are
captured each poll tick, classified against the vendored manifests, and
every transition posts an agent event carrying `{state, rule_id,
manifest_version, previous_state}`.

Five decisions came out different from the plan, each because a
load-bearing claim in it was secondhand and the upstream source says
otherwise.

**It is wired into the runner tick, not `PaneDriver`'s.** Three of the
plan's own decisions cannot be satisfied inside a driver: D-3 needs the
agent's family (PaneDriver knows an agent id and a pane id), D-4 wants
ONE `list-panes` round-trip for all panes, and D-2's ported exception
concerns panes PaneDriver does not own. The runner already enumerates
every running pane for the idle detector, so this adds no new pass.

**The D-2 authority gate asks the driver, not the kind.**
`hasStructuredDriver(kind)` is a proxy for "its adapter reports state",
and it is wrong exactly where it matters: a codex spawn whose M2 launch
failed walks the mode ladder down to a raw `PaneDriver`, keeps
`kind = codex`, and has nothing reporting state at all. The new gate
reads the live driver map; a bare `*PaneDriver` — and an absent driver,
i.e. a pane that outlived a host-runner restart — is no authority.
Upstream gates the same way one layer up (`lifecycle_authority_active`
short-circuits before the screen is read, `src/pane.rs:807`).

**`panestate` cannot be an event producer.** The event-ingest endpoint
accepts `agent|user|system` and 400s the rest
(`handlers_agent_events.go:95`); the agent-input endpoint has its own
closed `user|a2a` vocabulary. Neither has a per-subsystem extension
point. The producer axis answers "whose bytes are these" and these are
host-runner's, so this ships as kind `pane_state`, producer `system` —
the stamping PaneDriver already uses for synthesized lifecycle events.
This binds vision-parity L3/L4's local drivers too, and answers the
cross-plan check both plans had recorded as open.

**No capture trimming.** D-4 calls the geometry "the bottom-anchored
last 24 rows"; upstream's `ghostty_detection_text` actually reads
`terminal.rows()` and falls back to `DEFAULT_DETECTION_ROWS = 24` only
when the row count is unavailable (`src/pane/terminal.rs:2468-2475`).
24 is a fallback, not a contract, so trimming to it would have blinded
the rules on any pane taller than 24 rows. `capture-pane -p -J` already
returns the visible screen.

**D-5's constants are verbatim; its shape was one step off.** All four
check out (`AGENT_PENDING_IDLE_RECHECK` 100ms, `..._CONFIRMATIONS` 3,
`..._CAP` 700ms, `AGENT_STARTUP_GRACE_WINDOW` 3s), but the first
plain-idle observation arms the hold with ZERO confirmations, so the
release lands on the fourth observation, and the cap RELEASES the hold
rather than bounding it. Which end fires depends on cadence: upstream
polls at 100ms so confirmations win; we poll at 3s so the cap always
wins and the hold costs one tick. Both are ported so a future faster
tick cannot silently change the semantics. Also: upstream publishes on
a change to the state OR any `visible_*` hint, not the state alone.

Client sweep: an unrecognized event kind renders as a raw card in both
modes on both clients (busy inference is allowlist-shaped and therefore
safe, but rendering is not), so `pane_state` joins the verbose-only
tier in `feedLens.ts` and `feed_reducer.dart` — same tier as
`lifecycle`, since it is an agent state transition rather than turn
telemetry.

Renamed `hostrunner.paneState` -> `idleProbeState` (and `Runner.panes`
-> `idleProbes`): the older detector's per-pane hash bookkeeping held
the exact term this lane's package owns.

19 tests. Seven mutations of the new guards were introduced and all
seven were caught. Every screen in the fixtures is upstream's own; no
engine has been observed live, so this lane's device-verify debt now
also covers "does a real codex pane classify the way the fixture does".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant