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

348 lines
22 KiB
Markdown

# 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`](./terminal-model-view-contract.md) (this
phase **amends invariant 6**),
[`terminal-side-effect-authority.md`](./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 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 amendment**`terminal-model-view-contract.md` invariant 6 is
replaced by:
> 6. 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.