Files
stablyai--orca/docs/reference/terminal-query-authority.md
2026-07-13 13:05:33 +08:00

22 KiB

Terminal Query Authority

Status: Shipped — Phase 5 of the terminal model/view architecture, kill switch terminalModelQueryAuthority (default on). Builds on terminal-model-view-contract.md (this phase amends invariant 6), terminal-side-effect-authority.md (Phase 3), and the Phase-4 hidden-delivery gate (src/main/ipc/pty-hidden-delivery-gate.ts).

Problem

Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion (src/main/ipc/pty.ts:1426,1515, src/main/ssh/ssh-relay-session.ts:931). Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks waiting for it — terminal-conpty-device-attributes.ts:22), CPR probes hang TUIs, OSC 10/11 leaves claude /theme blind while hidden. The pre-Phase-4 hidden skip latch had the same hole (only mode 2031 and the 10s codex startup window answered), so this is not a regression — it is the long-standing gap this phase closes.

Contract invariant 6 ("the model must never answer queries") was written against a real bug: the daemon emulator replying ahead of the renderer with default-xterm values (the OSC-11 default-black-background race, headless-emulator.ts:86-97, pinned by session.test.ts:163-190). The danger was never "the model answers" — it was two answerers for the same bytes, one of them with wrong values. Phase 5 keeps the singularity and fixes the values.

Decision: the delivery decision is the reply decision

Main answers a query iff main dropped the chunk that carried it. The same per-chunk hidden-gate predicate (shouldDropHiddenRendererPtyData) that decides renderer delivery decides reply ownership, evaluated once, synchronously, at ingestion:

  • Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via Terminal.onDatatransport.sendInput, unchanged.
  • Hidden-marked, no delivery interest → chunk dropped → main answers from the runtime headless emulator, via the provider input path (provider.write, same path as pty:write; daemon shell-ready write gating and the SSH relay write apply unchanged).
  • Replayed/seeded/snapshot bytes → answered by no one (replay guards on both sides).

This is structurally exactly-one-responder: a chunk is delivered or dropped, never both, and each side only answers bytes it actually parsed live. The mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist from Phase 4 and are reused, not duplicated.

Rejected alternatives:

  • Fact-based renderer replies per query class (the mode-2031 pattern generalized): needs a main-side detection grammar per query, a fact round trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the emulator is the only state for a hidden pane. The 2031 fact stays because it is subscription registration, not a state query.
  • Emulator always answers: re-creates the OSC-11 double-reply race for visible panes. Never.

Mechanism: forwarded emulator onData, not a new grammar

HeadlessEmulator has onData wiring behind a per-write capture flag. For static and model-state queries, xterm core is the query grammar: the runtime emulator runs the same xterm version with equivalent options as the renderer pane, so main's reply set equals the visible renderer's by construction — verified empirically against the bundled headless build: DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode 0), DECRQSS (including DECSCUSR from cursor options), XTVERSION, kitty CSI ? u all reply; XTWINOPS (windowOptions stays default-off) and XTGETTCAP stay silent, matching visible behavior today. The headless build has no theme service: OSC 4/10/11/12 queries and DSR ?996n return nothing even with the theme option set, so the view-attribute class is answered by responder-registered parser handlers instead (below) — never by core defaults.

Forwarding predicate, captured per chunk in OrcaRuntimeService.onPtyData and attached to the emulator writeChain link (the mark can flip between ingestion and an async write; the decision must not be re-read at reply time):

  1. gate enabled (terminalMainSideEffectAuthority and terminalHiddenDeliveryGate both on) AND new kill switch terminalModelQueryAuthority !== false;
  2. the chunk was hidden-dropped for this PTY (shouldDropHiddenRendererPtyData — same module state, same tick as the drop sites);
  3. the write is live PTY data — never seedHeadlessTerminal, maybeHydrateHeadlessFromRenderer, option pushes, or any snapshot replay (main-side replay guard, mirror of the renderer's replay-guard.ts);
  4. no remote view subscriber is attached to the PTY (runtime terminal-RPC subscriber records / mobileSubscribers): a mobile/web/remote-desktop xterm receiving the multiplexed stream answers with view authority, exactly like a visible local pane. Legacy JSON terminal.subscribe streams do register as view subscribers and suppress, even when the consumer is a read-only watcher — deliberately conservative, because the stream may feed an older live xterm view and a withheld reply (the pre-Phase-5 status quo) is strictly safer than a double reply. Consumers that never register a stream (CLI terminal.read, automation observers) do not suppress — they also do not answer; that bounded no-reply case matches today's behavior.

Mobile streams preserve that singularity across snapshot startup and multiple views. The mobile WebView suppresses onData during snapshot replay, then enables replies at an explicit live-output boundary. Clearing the WebView drops that replay queue and resumes live reply authority immediately. If a live query arrived while the initial snapshot was being built and its output sequence is covered by that snapshot, runtime RPC re-emits only the bounded query sequence after the snapshot; ordinary covered output stays deduplicated. terminal.send accepts the resulting inputKind: query-reply only from the earliest active phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest is determined by preserved subscribedAt, so a soft-leave resubscribe does not change reply ownership; passive desktop-mode watchers are excluded. Peer phones are rejected, the next subscriber is promoted on unsubscribe, and desktop parser and capability handlers stay silent until desktop retakes the driver. Mixed versions: hosts advertise terminal.query-reply-input.v1; a mobile client never forwards replies to a host that lacks it, because such hosts strip inputKind and would treat the bytes as floor-taking shell input. Residual: a desktop→mobile driver handoff has a bounded double-reply window until the async driver-change event reaches the desktop renderer's cached driver map (isPtyLocked).

Everything the emulator emits outside a forwarding window is discarded, which also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes triggered by option mutations).

Reply classes

Class Queries Answer source
Static DA1 CSI c (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → 0, kitty CSI ? u xterm core constants + kitty flag state
Model-state CPR 6n/?6n, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative
View-attribute OSC 4/10/11/12 ;? queries, DSR ?996n responder parser handlers + renderer attribute push (below); silent until first push
View-attribute (via options) DECRQSS DECSCUSR, DECRQM 12 xterm core, from pushed cursorStyle/cursorBlink emulator options
Silent XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n nobody, visible or hidden
Mode 2031 DECSET 2031 subscribe unchanged in Phase 5: main emits the 2031-subscribe fact, the renderer replies (handleHiddenMode2031SubscribeFact, pty-connection.ts; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard

View-attribute bridge

Renderer→main push, pty:terminalViewAttributes — one global snapshot, not per-PTY: the composed terminal ITheme (from applyTerminalAppearance, terminal-appearance.ts), terminalCursorStyle, terminalCursorBlink, and the resolved color-scheme mode (resolveTerminalColorSchemeMode — the same source as the existing hidden 2031 reply). Pushed on renderer startup and on every theme/settings apply.

Main consumes it two ways:

  • cursorStyle/cursorBlink are applied to every runtime emulator's options inside the replay guard; xterm core then answers DECRQSS DECSCUSR and DECRQM 12 with renderer-true values (verified working headless).
  • Palette and color-scheme replies come from responder-registered parser handlers on the emulator (registerOscHandler 4/10/11/12, registerCsiHandler for DSR ?996n), because the headless core cannot answer them. The OSC handlers see SET payloads too, so runtime OSC 4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are tracked per PTY and layered over the pushed base palette — matching what the renderer's theme service reports for a visible pane.

Staleness rules: replies use the last push; a theme flip is stale for at most one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push). Before the first push main answers no view-attribute query — a fabricated default would resurrect the default-black OSC-11 bug; silence is the documented hidden status quo.

Kitty keyboard flags

vtExtensions.kittyKeyboard: true is enabled in HeadlessEmulator, matching buildDefaultTerminalOptions (pane-terminal-options.ts:50). Risk is low: for the write-only daemon use, keyboard state never alters serialization; the change only makes the emulator parse CSI =/>/< u pushes instead of ignoring them, and lets the responder answer CSI ? u with the flags the hidden app actually pushed. Snapshot parity: add kittyKeyboardFlags to TerminalModes for emulator re-seed parity only. rehydrateSequences must not push kitty flags into a renderer xterm — POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C hazard, terminal-replay-cursor-state.test.ts) stays authoritative. Slice 3 wires the re-seed consumer: the daemon warm-reattach snapshot threads modes.kittyKeyboardFlags through the spawn result into seedHeadlessTerminal, which applies them to the fresh runtime emulator via its own CSI = flags ; 1 u parse (outside any forwarding window), so hidden CSI ? u reports the flags the hidden app actually pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer ?0u; protocol-conformant programs re-push.

ConPTY DA1 variant

The provider kind is known main-side: mirror isLocalNativeWindowsPty (windows-pty-compatibility.ts:59) from the spawn record (local/daemon provider, win32, not WSL). For such PTYs register a CSI c override on the emulator parser (the main-side twin of installConptyDeviceAttributesHandler) replying CSI ?61;4c, still gated by the forwarding predicate. The override is installed at emulator creation and retrofitted when the spawn mark lands (daemon stream data can create the emulator before the awaited spawn response marks the PTY). ConPTY blocking on a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is closed by the slice-3 initiallyHidden spawn flag (races section).

Suppression: when main never replies

  • Visible or unmarked PTY (chunk was delivered).
  • Renderer delivery interest registered (chunk was delivered to a sidecar).
  • Remote-runtime (remote:) PTYs — never markable (isHiddenDeliveryGateManagedPty), bytes never transit local main.
  • Remote view subscriber attached (mobile/web/remote desktop owns replies).
  • Seed/hydration/snapshot writes into the emulator, and option pushes.
  • Kill switches off — no marks exist, and terminalModelQueryAuthority is an independent off switch for the responder alone.
  • The daemon emulator: never, under any setting. The responder lives in main's runtime only; session.test.ts:163-190 stays pinned verbatim.

Transition races

Worst cases, per direction:

  • visible→hidden: chunks delivered between the visibility flip and the mark landing in main are hidden-skipped by the renderer write path without query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden skip behavior, bounded by one renderer→main IPC hop. After the mark lands, main answers everything it drops.
  • hidden→visible: unmark consumes the drop latch and emits the restore marker; the snapshot replay is replay-guarded, so queries main already answered are never re-answered from the snapshot; post-unmark live chunks are answered by xterm once (restore-queued live chunks reply late, not twice).
  • Split queries across the drop/deliver boundary: neither parser saw the whole sequence → no reply; the restore marker resets renderer cross-chunk state and replay hygiene resets the parser. At-most-once holds.

Safe-side rule per class: duplicates are structurally impossible (one decision point per chunk); where the race costs anything it costs a missing reply. That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or tolerate silence, as they did for every hidden pane before this phase). The one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible pane answers it from the renderer xterm. A PTY spawned hidden previously had no answerer until the renderer's hidden mark landed in main (one IPC hop after spawn). Slice 3 closes that window with the initiallyHidden spawn-record flag: the renderer declares hidden-at-spawn on pty:spawn (never for remote-runtime transports), and main marks the PTY hidden before the first byte — pre-spawn for daemon-host sessions whose id is minted up front, immediately after provider.spawn resolves otherwise — so the gate and responder own queries from byte one. The pane's first visibility sync then re-marks or unmarks through the existing Phase-4 machinery (unmark emits the restore marker for any spawn-window drops).

Invariants

  1. Exactly one party may answer any query, chosen by the chunk's delivery decision: delivered → the consuming live view's xterm; dropped → main's model responder; replayed/seeded → no one. The decision is captured once, synchronously, at ingestion.
  2. Main answers only from live PTY bytes parsed by the runtime emulator — never from snapshot, seed, hydration, or option-push writes.
  3. View-attribute answers are renderer-true or absent: no reply is ever fabricated from emulator defaults (the OSC-11 lesson).
  4. The daemon emulator stays write-only; daemon subprocess query writes stay zero (session.test.ts pins are permanent).
  5. Reply parity is structural for static and model-state classes: same xterm core, equivalent options, no hand-rolled grammar — the only overrides are the documented ConPTY DA1 variant and the view-attribute parser handlers the headless core cannot serve.
  6. Remote views keep view authority; main yields whenever a remote view subscriber is attached. When multiple desktop/mobile views coexist, the terminal driver and server-side mobile election keep one reply writer.

Contract amendmentterminal-model-view-contract.md invariant 6 is replaced by:

  1. Terminal query authority is singular and structural: the party that writes a chunk into a live terminal answers its queries. Visible renderer and remote views keep xterm authority. Chunks dropped by the hidden-delivery gate are answered exactly once by the main model responder, from runtime-emulator state plus renderer-pushed view attributes. Replayed, seeded, or snapshot bytes are answered by no one. The daemon emulator never answers.

The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11, or theme-sensitive queries" splits into: daemon emulator never answers (unchanged pins) / runtime responder answers only hidden-dropped chunks. The side-effect authority matrix row "DECSET 2031 reply — query authority stays with the view (contract invariant 6)" gains a pointer here; its reply path is otherwise untouched in this phase.

Test strategy

  • Responder unit tests beside orca-runtime.test.ts: marked vs unmarked vs interest-suppressed; each reply class; seed/hydrate silence; remote- subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip between ingestion and async emulator write (captured decision wins).
  • Parity harness: shared query byte fixtures through a renderer-configured xterm (onData capture) and through the responder; assert byte-identical replies for static + model-state classes, and for view-attribute classes after an attribute push.
  • session.test.ts:163-190: assertions stay; the comment is updated to name the main responder (not "the renderer") as the hidden answerer.
  • E2E: hidden claude /theme reports the configured theme; hidden TUI blocked on CPR/DA unblocks while gated; reveal shows no stray reply fragments (?1;2c, rgb: …) on the prompt; Windows ConPTY golden and terminal-hidden-view-parking.spec.ts stay green.

Cut-offs (shipped as three stacked slices)

  1. Responder core. Emulator onData wiring + per-write capture + main replay guard; kitty flag enable (+ TerminalModes.kittyKeyboardFlags); static + model-state classes; ConPTY DA1 override; remote-subscriber suppression; terminalModelQueryAuthority switch; unit + parity tests. Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock.
  2. View-attribute bridge. pty:terminalViewAttributes push, cursor option application under the guard, responder OSC/DSR parser handlers with per-PTY palette-mutation tracking, silent-until-push rule, /theme e2e.
  3. Contract alignment. Invariant-6 amendment in the contract doc, test bullet split, session.test.ts comment, side-effect matrix pointer, and the Phase 6 prerequisites below recorded as accepted.

Phase 6 (delete skip grammar + startup window): prerequisites from this design

Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s codex startup renderer-query window are deleted. Kill-switch-off hidden panes fall back to the pre-grammar path — hidden bytes ride the bounded background scheduler queue; overflow latches the model-snapshot restore — and never run a per-chunk content scan.

Accepted and shipped in slice 3 (except where noted):

  • Mark-before-first-byte (shipped): panes spawned without a visible view are hidden-marked at spawn via the initiallyHidden flag on pty:spawn (spawn-record flag, not a renderer round trip) so startup queries — including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6 removed the codex exclusion with the window: codex spawns are main-owned from byte zero too, the responder answering their startup probes.
  • Attributes before spawn (shipped): the renderer pushes composed view attributes once at app start (right after settings load, before terminal reconnect/spawn), so spawn-time view-attribute queries no longer fall into the silent-until-push rule. Per-pane appearance applies keep re-publishing through the same deduped publisher.
  • Daemon shell-ready write gating (verified): responder replies through ptyController.write → daemon Session.write are QUEUED pre-ready, never dropped, and the queue flushes at the shell-ready marker or the 15s SHELL_READY_TIMEOUT_MS bound (session.ts). The codex window was removed with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains worth adding.
  • With the skip grammar deleted, every chunk is either written to a live xterm or dropped — the delivered-but-skipped no-reply gap disappears and the only remaining loss window is the mark IPC race.
  • 2031 consolidation (optional follow-up): move the subscription registry into the responder (the headless core cannot serve 997 pushes any more than it can ?996n) and push 997 flips from the attribute cache, retiring the 2031-subscribe fact reply, the parked responder, and the parked-tab theme-flip gap.