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.onData→transport.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 aspty: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):
- gate enabled (
terminalMainSideEffectAuthorityandterminalHiddenDeliveryGateboth on) AND new kill switchterminalModelQueryAuthority !== false; - the chunk was hidden-dropped for this PTY (
shouldDropHiddenRendererPtyData— same module state, same tick as the drop sites); - the write is live PTY data — never
seedHeadlessTerminal,maybeHydrateHeadlessFromRenderer, option pushes, or any snapshot replay (main-side replay guard, mirror of the renderer'sreplay-guard.ts); - 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 JSONterminal.subscribestreams 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 (CLIterminal.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/cursorBlinkare 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 (
registerOscHandler4/10/11/12,registerCsiHandlerfor 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
terminalModelQueryAuthorityis 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-190stays 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
- 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.
- Main answers only from live PTY bytes parsed by the runtime emulator — never from snapshot, seed, hydration, or option-push writes.
- View-attribute answers are renderer-true or absent: no reply is ever fabricated from emulator defaults (the OSC-11 lesson).
- The daemon emulator stays write-only; daemon subprocess query writes stay
zero (
session.test.tspins are permanent). - 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.
- 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 amendment — terminal-model-view-contract.md invariant 6 is
replaced by:
- 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 /themereports 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 andterminal-hidden-view-parking.spec.tsstay green.
Cut-offs (shipped as three stacked slices)
- 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;terminalModelQueryAuthorityswitch; unit + parity tests. Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock. - View-attribute bridge.
pty:terminalViewAttributespush, cursor option application under the guard, responder OSC/DSR parser handlers with per-PTY palette-mutation tracking, silent-until-push rule,/themee2e. - Contract alignment. Invariant-6 amendment in the contract doc, test
bullet split,
session.test.tscomment, 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
initiallyHiddenflag onpty: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→ daemonSession.writeare QUEUED pre-ready, never dropped, and the queue flushes at the shell-ready marker or the 15sSHELL_READY_TIMEOUT_MSbound (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-subscribefact reply, the parked responder, and the parked-tab theme-flip gap.