From bdecd764c365a66d4e011961d37ac12b31cfcf2f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:59:52 -0700 Subject: [PATCH] Finalize terminal model view architecture docs Co-authored-by: Orca --- .../reference/terminal-hidden-view-parking.md | 75 ++++--- .../reference/terminal-model-view-contract.md | 90 ++++++-- docs/reference/terminal-query-authority.md | 36 +-- .../terminal-side-effect-authority.md | 206 +++++++++--------- docs/terminal-main-owned-state.md | 7 + 5 files changed, 250 insertions(+), 164 deletions(-) diff --git a/docs/reference/terminal-hidden-view-parking.md b/docs/reference/terminal-hidden-view-parking.md index 61b1f832f3c..eeaf17877ea 100644 --- a/docs/reference/terminal-hidden-view-parking.md +++ b/docs/reference/terminal-hidden-view-parking.md @@ -1,8 +1,9 @@ # Terminal Hidden View Parking -Status: Phase 1 of the terminal model/view architecture. See +Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch +`terminalHiddenViewParking` (default on). See [`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the -invariants this design extends. +invariants this design extends and the full phase list. ## Problem @@ -32,8 +33,7 @@ A pure policy module decides which hidden terminal tabs may park: - Eligibility excludes: visible panes, hidden-measuring startup probes, activity-portal panes, tabs with pending startup commands or pending activation spawns, floating-panel tabs, and any tab whose PTY is not - snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded in - this phase). + snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded). - Kill switch: `settings.terminalHiddenViewParking === false` disables parking entirely. @@ -45,30 +45,41 @@ already exercise: transports detach but the PTY session, daemon model, and tab state all survive. The xterm instance, its buffers, DOM, and WebGL/addon resources are released. -### Parked byte watcher (the piece the reverted attempt lacked) +### Parked watcher (the piece the reverted attempt lacked) -While a tab is parked, a pane-less watcher subscribes to its PTYs through the -dispatcher sidecar mechanism (the same mechanism background agent launches -use). The watcher runs the transport-level byte parsers with no xterm: +While a tab is parked, a pane-less watcher +(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its +consumption mode is decided once at watcher start: -- OSC 0/1/2 titles → tab/pane title store updates (all-titles ordering, same - normalization as the live transport path). -- Title-transition agent tracker → agent-became-idle completion notification - and prompt-cache timer, agent-became-working cancellation. -- BEL detection (OSC-aware stateful detector) → worktree/tab unread plus the - delayed terminal-bell OS notification. -- DECSET 2031 subscribe scan → out-of-band color-scheme reply via - `transport.sendInput`, so TUIs that subscribe while parked still learn the - theme. -- GitHub PR link scan → worktree linked-PR detection keeps working for agents - that print PR URLs while parked. +- **Main side-effect authority on (default):** the watcher is purely + fact-driven — it registers exactly one `pty:sideEffect` fact consumer and + parses no bytes. Titles, agent working/idle/exited transitions, BEL + attention, and PR links arrive as main-tracker facts and drive the same + policy callbacks a mounted pane uses. With the hidden-delivery gate also on, + the watcher marks the PTY hidden so main stops renderer byte delivery + entirely; the DECSET 2031 color-scheme subscribe arrives as main's + `2031-subscribe` fact and the watcher replies out-of-band via + `transport.sendInput`. +- **Kill switch off:** the watcher subscribes to raw bytes through the + dispatcher sidecar mechanism (the same mechanism background agent launches + use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2 + titles (all-titles ordering, live-path normalization), the title-transition + agent tracker (completion notification, prompt-cache timer), the OSC-aware + stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031 + byte responder (`parked-terminal-mode2031-responder.ts`, whose + `subscribeToPtyData` registration doubles as the delivery-interest signal). -Main's synthetic agent-title/permission frames ride the same `pty:data` -channel, so they flow through the watcher unchanged. +The two modes drive one shared policy-callback block, so flipping the kill +switch never changes notification semantics. Main's synthetic +agent-title/permission frames feed the main tracker directly and arrive as +facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off +mode. -Out of scope while parked (documented behavior, unchanged from the hidden -skip-latch status quo): terminal query auto-replies other than mode 2031, -OSC 52 clipboard writes, Command Code output scraping. +Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside +hidden-dropped chunks are answered by main's model responder +([`terminal-query-authority.md`](./terminal-query-authority.md)); in +kill-switch-off byte mode only the 2031 reply is answered and Command Code +output is not scraped, matching the pre-gate status quo. ### Reveal @@ -91,10 +102,14 @@ disposed before the pane handlers re-register. 5. Memory: parked tabs hold no xterm buffers; renderer memory scales with visible panes. -## Cut-offs +## Relation to later phases (all shipped) -This phase is independently mergeable. Later phases (side-effect authority to -main, gating hidden delivery in main, model query authority) replace the -watcher's byte parsing for local/SSH PTYs and stop hidden delivery entirely; -the watcher remains the parser for remote-runtime PTYs, which never transit -local main. +Side-effect authority in main (Phase 3) replaced the watcher's byte parsing +with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4) +stops hidden byte delivery in main, moving the parked 2031 reply from the +byte sidecar to the `2031-subscribe` fact; the model query responder +(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser +mode survives only behind the kill switches. Parking still excludes +remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher +would return as a byte parser only if remote-runtime tabs — whose bytes never +transit local main — ever became parkable. diff --git a/docs/reference/terminal-model-view-contract.md b/docs/reference/terminal-model-view-contract.md index d303f43648e..79fb55575f3 100644 --- a/docs/reference/terminal-model-view-contract.md +++ b/docs/reference/terminal-model-view-contract.md @@ -6,9 +6,10 @@ Terminal output should have one authoritative model path and many disposable views. A renderer xterm is the fast interactive view, but it must not be the only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists. -This contract defines the boundary future terminal performance work should move -toward without changing the query-response behavior that real shells and TUIs -depend on. +This contract defines the boundary the shipped terminal stack implements — and +that future terminal work must preserve — without changing the query-response +behavior that real shells and TUIs depend on. See [Architecture +Status](#architecture-status) for the shipped phases. ## Terms @@ -30,14 +31,20 @@ depend on. status keep advancing from the PTY stream. 2. Active visible terminal input/output stays on the lowest-latency path. Bulk hidden or background output must not delay keystroke-sized foreground redraws. -3. Hidden views do not own unbounded output memory. When a hidden renderer view - cannot keep up, it becomes stale and restores from the model later. +3. Hidden views do not own unbounded output memory. Main's hidden-delivery + gate drops renderer-bound bytes for hidden-marked PTYs after model + ingestion and emits an out-of-band restore marker + (`pty:modelRestoreNeeded`) so the view restores from the model on reveal. + With the gate's kill switches off, hidden bytes ride a bounded renderer + queue whose overflow latches the same model restore. 4. Returning to a hidden or slept terminal must show model-correct output. A stale or replaced view may be cleared and replayed from a snapshot, but it must not show a warning fallback when model recovery is available. 5. Snapshots and live bytes have ordering metadata. A view restore must not duplicate bytes already included in the snapshot or drop bytes that arrived - after it. + after it. Main buffer snapshots report the pending-delivery start sequence + (`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a + restore without misreading foreign sequence domains as duplicates. 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 @@ -58,6 +65,10 @@ depend on. | Responsibility | Current owner | | --- | --- | | PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` | +| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` | +| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` | +| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` | +| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` | | Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` | | Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` | | Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` | @@ -89,7 +100,8 @@ A renderer or remote view may: - write active visible output immediately; - budget visible inactive output; -- skip hidden renderer writes when the model can recover the state; +- stop receiving hidden output entirely while main's hidden-delivery gate owns + the bytes (model restore on reveal); - request fresh snapshots for restore, mobile subscription, or explicit remote snapshot recovery. @@ -99,7 +111,8 @@ A view must: - apply generation or sequence checks before replaying a snapshot; - refresh/repaint after replay when xterm/WebGL needs an explicit paint; - keep side effects such as title, bell, cwd, and agent status flowing from the - PTY/model path even while renderer writes are skipped. + PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte + delivery is budgeted, gated, or parked. ## Transcript Contract @@ -140,25 +153,66 @@ Current coverage is spread across: - `src/main/daemon/headless-emulator.test.ts` - `src/main/daemon/session.test.ts` +- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers, + `pendingDeliveryStartSeq`) +- `src/main/ipc/pty-hidden-delivery-gate.test.ts` - `src/main/runtime/mobile-subscribe-integration.test.ts` - `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts` - `src/main/runtime/rpc/terminal-multiplex.test.ts` - `src/main/runtime/orca-runtime.test.ts` - `src/main/runtime/terminal-query-responder.test.ts` +- `src/shared/terminal-output-side-effects.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts` +- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts` - `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts` - `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts` +- `tests/e2e/terminal-hidden-view-parking.spec.ts` +- `tests/e2e/terminal-parked-memory.spec.ts` - `tests/e2e/terminal-sleep-wake-restore.spec.ts` - `tests/e2e/terminal-output-scheduler.spec.ts` - `tests/e2e/artificial-opencode-terminal-load.spec.ts` -## Migration Shape +## Architecture Status -1. Keep the current green ACK/backpressure and hidden-restore stack intact. -2. Add contract tests for one PTY surface at a time: local, SSH, remote runtime, - mobile, then CLI reads. -3. Move renderer-only restore authority behind model snapshots only where the - contract is already executable. -4. Remove renderer fallback paths only after the equivalent model path has - platform and TUI golden coverage. -5. Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a - local repaint quirk. +All six phases of the terminal model/view architecture are shipped; the kill +switches noted in parentheses default on: + +1. **Hidden view parking** — "Park hidden terminal views behind a byte + watcher": hidden terminal tabs unmount their xterm after a cold-park + hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects + alive while parked (`terminalHiddenViewParking`). See + [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md). +2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory": + renderer heap and live-terminal counts gate parking in the perf suite + (`tests/e2e/terminal-parked-memory.spec.ts`). +3. **Side-effect authority in main** — "Track terminal titles in main with + all-titles ordering", "Move terminal side-effect authority to a main facts + channel", "Complete terminal side-effect facts coverage", "Finish terminal + side-effect authority migration": every local/daemon/SSH PTY byte is + side-effect-parsed once in main and delivered as `pty:sideEffect` facts + (`terminalMainSideEffectAuthority`). See + [`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md). +4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views": + main drops renderer-bound bytes for hidden-marked PTYs after model + ingestion; delivery-interest registrations exempt sidecar byte consumers, + out-of-band restore markers latch model restore, and + `pendingDeliveryStartSeq` reconciles live output racing a restore + (`terminalHiddenDeliveryGate`). +5. **Model query authority** — "Answer hidden terminal queries from the + model", "Bridge renderer view attributes to the model responder", "Align + query authority contract and spawn-time ownership": hidden-dropped queries + are answered by the runtime emulator plus renderer-pushed view attributes, + and hidden-at-spawn PTYs are marked before byte one + (`terminalModelQueryAuthority`). See + [`terminal-query-authority.md`](./terminal-query-authority.md). +6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the + renderer's per-chunk hidden-skip eligibility grammar and the 10s codex + startup query window are deleted; the kill-switch-off fallback is the + bounded background queue with overflow-latched model restore. + +Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a +local repaint quirk. Renderer fallback paths retire only when their kill +switches do, and only after the equivalent model path has platform and TUI +golden coverage. diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md index a2fee4f7313..e05b5768836 100644 --- a/docs/reference/terminal-query-authority.md +++ b/docs/reference/terminal-query-authority.md @@ -1,6 +1,7 @@ # Terminal Query Authority -Status: Phase 5 of the terminal model/view architecture. Builds on +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) @@ -10,7 +11,7 @@ phase **amends invariant 6**), ## Problem Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion -(`src/main/ipc/pty.ts:1417,1506`, `src/main/ssh/ssh-relay-session.ts:931`). +(`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 @@ -21,7 +22,7 @@ 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:82-93`, pinned by `session.test.ts:163-187`). The danger +`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. @@ -59,7 +60,7 @@ Rejected alternatives: ## Mechanism: forwarded emulator onData, not a new grammar -`HeadlessEmulator` gains `onData` wiring behind a per-write capture flag. +`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 @@ -108,13 +109,13 @@ triggered by option mutations). | 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 (`pty-connection.ts:1627`, parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | +| 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 -New renderer→main push, `pty:terminalViewAttributes` — one global snapshot, +Renderer→main push, `pty:terminalViewAttributes` — one global snapshot, not per-PTY: the composed terminal `ITheme` (from -`applyTerminalAppearanceToPanes`, `terminal-appearance.ts:211-232`), +`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 @@ -141,8 +142,8 @@ documented hidden status quo. ### Kitty keyboard flags -Enable `vtExtensions.kittyKeyboard: true` in `HeadlessEmulator`, matching -`buildDefaultTerminalOptions` (`pane-terminal-options.ts:49`). Risk is low: +`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 @@ -161,7 +162,7 @@ pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer ### ConPTY DA1 variant The provider kind is known main-side: mirror `isLocalNativeWindowsPty` -(`windows-pty-compatibility.ts:48`) from the spawn record (local/daemon +(`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 @@ -182,7 +183,7 @@ closed by the slice-3 `initiallyHidden` spawn flag (races section). - 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-187` stays pinned verbatim. + main's runtime only; `session.test.ts:163-190` stays pinned verbatim. ## Transition races @@ -266,14 +267,14 @@ otherwise untouched in this phase. 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-187`: assertions stay; the comment is updated to name +- `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 (stacked, independently mergeable) +## Cut-offs (shipped as three stacked slices) 1. **Responder core.** Emulator onData wiring + per-write capture + main replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`); @@ -287,7 +288,7 @@ otherwise untouched in this phase. bullet split, `session.test.ts` comment, side-effect matrix pointer, and the Phase 6 prerequisites below recorded as accepted. -## What Phase 6 (delete skip grammar + startup window) requires from this design +## 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 @@ -311,9 +312,10 @@ Accepted and shipped in slice 3 (except where noted): - **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`). Spawn-time replies on - Windows daemon PTYs still need explicit e2e validation before the codex - window is removed. + `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. diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md index 5f22edf9be5..852a607d5bf 100644 --- a/docs/reference/terminal-side-effect-authority.md +++ b/docs/reference/terminal-side-effect-authority.md @@ -1,24 +1,26 @@ # Terminal Side-Effect Authority -Status: Phase 3 of the terminal model/view architecture. Builds on +Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch +`terminalMainSideEffectAuthority` (default on). Builds on [`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1). ## Problem -Main already parses every local/daemon/SSH PTY byte before renderer delivery -(`OrcaRuntimeService.onPtyData`, `src/main/runtime/orca-runtime.ts:3256`: -OSC 9999 agent status, last-OSC-title, headless emulator, tails, URL watchers; -SSH feeds the same path at `src/main/ssh/ssh-relay-session.ts:915`). Yet the -side effects users see — bell unread/notifications, title transitions, -agent-complete notifications, command lifecycle, PR links — are derived a -second time by renderer byte parsers (`pty-transport.ts`'s -`createPtyOutputProcessor`, `pty-connection.ts`, the parked byte watcher). -That duplication forces Phase 1's watcher to exist, forces main to fabricate -synthetic OSC title frames over `pty:data` (`src/main/index.ts:975-990, -1033-1112`) just so renderer parsers can see them, and blocks Phase 4 from -ever stopping hidden byte delivery. Phase 3 makes main the side-effect parser -for every PTY whose bytes transit local main. +Main parses every local/daemon/SSH PTY byte before renderer delivery +(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`: +side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL +watchers; SSH feeds the same path from `wireUpPtyEvents` in +`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects +users see — bell unread/notifications, title transitions, agent-complete +notifications, command lifecycle, PR links — were derived a second time by +renderer byte parsers. That duplication forced Phase 1's watcher to parse +bytes, forced main to fabricate synthetic OSC title frames over `pty:data` +just so renderer parsers could see them, and blocked Phase 4 from ever +stopping hidden byte delivery. Phase 3 made main the side-effect parser for +every PTY whose bytes transit local main; the renderer byte parsers +(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte +mode) survive only for remote-runtime PTYs and the kill-switch-off fallback. ## Authority Matrix @@ -28,45 +30,46 @@ Remote-runtime PTYs (`remote:`) never transit local main; the renderer | Side effect | local-daemon | SSH | remote-runtime | | --- | --- | --- | --- | -| OSC 9999 agent status | main (shipped: `orca-runtime.ts:3259` → `agentStatus:set`) | main (shipped) | renderer (`pty-connection.ts:1490-1541`) | +| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) | | OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer | | BEL attention (OSC-aware stateful detector) | main | main | renderer | | OSC 133;D command-finished exit code | main | main | renderer | | GitHub PR-link scan | main | main | renderer | -| Command Code output scrape | main (shipped: per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main (shipped) | renderer | +| Command Code output scrape | main (per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main | renderer | | DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer | | DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer | ## Main-Side Tracker -- Lift the side-effect core of `createPtyOutputProcessor` - (`pty-transport.ts:87-428`) into a shared module - (`src/shared/terminal-output-side-effects.ts`): all-titles ordering via +- The side-effect core shared with the renderer processor lives in + `src/shared/terminal-output-side-effects.ts`: all-titles ordering via `extractAllOscTitles` (coalesced working→idle transitions are why last-title is insufficient — issue #1083), `normalizeTerminalTitle`, the literal - `cursor agent` title drop (`pty-transport.ts:145-166`), the - `createAgentStatusTracker` transitions, the stale-working-title 3s timer - (`pty-transport.ts:51,363-379`), and the stateful BEL detector - (`bell-detector.ts`). + `cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`, + `src/shared/agent-detection.ts`), the `createAgentStatusTracker` + transitions, the stale-working-title 3s timer + (`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector + (`src/shared/terminal-bell-detector.ts`). - One tracker per PTY on `OrcaRuntimeService`, lazily created like - `agentStatusOscProcessorsByPtyId` (`orca-runtime.ts:3420-3427`); disposed in - `onPtyExit` (cancels the stale-title timer). -- It replaces `extractLastOscTitle` at `orca-runtime.ts:3286`: titles feed in - byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters, and - pending-message delivery see intermediate transitions instead of only the - chunk's last title. PTY/leaf records keep the **raw** last title (worktree - `ps` and mobile tab titles at `orca-runtime.ts:13209` expect raw); emitted - facts carry `(normalizedTitle, rawTitle)` like `onTitleChange` today. + `agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the + stale-title timer). +- It replaced the chunk-level last-title extraction in `onPtyData`: titles + feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters, + and pending-message delivery see intermediate transitions instead of only + the chunk's last title. PTY/leaf records keep the **raw** last title + (worktree `ps` and mobile tab titles expect raw); emitted facts carry + `(normalizedTitle, rawTitle)` like `onTitleChange`. - No deferred drain in main — the renderer's setTimeout(0) batching - (`pty-transport.ts:175-182,319-339`) protects xterm paint, which does not - exist in main. Apply synchronously, batch the IPC per flush. + (`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which + does not exist in main. Main applies synchronously and batches the IPC per + flush. - The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own last-title scan, untouched: synthetic titles must never reach it. ## Event Transport: `pty:sideEffect` -One new batched main→renderer channel (preload pattern of `agentStatus:set`, -`src/preload/index.ts:3586`). It is **not** routed through the pty dispatcher: +One batched main→renderer channel (`window.api.pty.onSideEffect`, +`src/preload/index.ts`). It is **not** routed through the pty dispatcher: the renderer fact-consumer registry (`terminal-side-effect-facts-handler.ts`) subscribes directly via `window.api.pty.onSideEffect` — one channel subscription per renderer, with @@ -74,8 +77,8 @@ exactly one registered fact consumer per PTY. Events are **facts, not decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title), `agent-exited`, `command-finished` (exit code), `pr-link`. Each carries `ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf -records, same resolution as `emitTerminalAgentStatusEvents`, -`orca-runtime.ts:3429-3460`), and the PTY `outputSequence`. +records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY +`outputSequence`. Ordering rules: @@ -88,64 +91,70 @@ Ordering rules: state, and today's renderer drain already decouples by many batches under timer throttling. 3. No attention replay: facts emitted while no renderer is subscribed are - dropped. On transport attach/park-handoff the renderer requests (or main - re-emits) a `title`+status snapshot marked `replay: true` — this reproduces + dropped. On transport attach/park-handoff the renderer pulls a title-only + snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces the eager-buffer behavior where replay restores titles but is barred from - bells/completions (`pty-transport.ts:656-714` `suppressAttentionEvents`). - The store handler ignores a replay title older (by `outputSequence`) than - the last live title fact it applied. + bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The + store handler ignores a replay title older (by `outputSequence`) than the + last live title fact it applied. ## Renderer Store Handler (policy stays in the renderer) -Verified current notification semantics, all preserved: +Notification semantics, all preserved across the authority flip: - BEL marks worktree+tab unread unconditionally — including the focused pane - (`pty-connection.ts:1232-1250`); pane unread only behind + (`onBell`, `pty-connection.ts`); pane unread only behind `experimentalTerminalAttention`; keydown clears unread - (`pty-connection.ts:959-999`). + (`onTerminalKeyDown`, `pty-connection.ts`). - BEL's OS notification is delayed 250 ms and yields to a pending - agent-task-complete (`pty-connection.ts:1259-1275`). + agent-task-complete (`scheduleTerminalBellNotification`, + `pty-connection.ts`). - working→idle starts the Claude cache timer (null settings = not hydrated, - treat enabled, `pty-connection.ts:1409-1430`) and schedules completion with - 250 ms grace + 1500 ms max wait + detail-wait store subscription - (`pty-connection.ts:1328-1390`). + treat enabled) and schedules completion with 250 ms grace + 1500 ms max + wait + detail-wait store subscription + (`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` / + `AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`, + `agent-task-complete-policy.ts`). - Completion unread is suppressed only for the exact visible foreground pane - (`use-notification-dispatch.ts:280-298`); BEL unread has no such check. -- Dispatch-time liveness/staleness guards (`use-notification-dispatch.ts: - 229-277`) and main's 5 s per-worktree cooldown (`src/main/ipc/ - notifications.ts:286-296`) remain the final gates. + (`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread + has no such check. +- Dispatch-time liveness/staleness guards + (`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's + 5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`, + `src/main/ipc/notifications.ts`) remain the final gates. These need live renderer store state (PTY/layout maps, pane visibility, settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer: -a pane-independent per-paneKey handler module consumes `pty:sideEffect` and +the pane-independent per-paneKey handler module +(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and subsumes both `pty-connection.ts`'s callbacks and the parked watcher's -callback block (`parked-terminal-byte-watcher.ts:96-213`) — one policy path -whether the tab is mounted, hidden, or parked. Main holds **no** notification -timers; only the stale-title timer (parser state) moves to main. +callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) — +one policy path whether the tab is mounted, hidden, or parked. Main holds +**no** notification timers; only the stale-title timer (parser state) lives +in main. ## Synthetic Frame Reroute -`driveSyntheticTitleFromHook` and the spinner tick (`src/main/index.ts: -1033-1112`) currently fabricate OSC title/BEL frames onto `pty:data` -(`sendSyntheticTitle`, `index.ts:975-990`) solely for renderer parsers. -Replace with `runtime.ingestSyntheticTitleFrame(ptyId, label, { bell })` -feeding the per-PTY tracker directly — **not** `onPtyData`, so emulator -state, tails, transcripts, and stats stay clean (today they never see these -frames either). Keep the decorative-frame visibility gating -(`shouldSendSyntheticTitleFrame`). Verified renderer dependencies on those -bytes: the visible xterm renders nothing from titles, but +`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`, +`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`, +so synthetic agent-title/BEL frames enter the per-PTY tracker directly — +**not** `onPtyData`, so emulator state, tails, transcripts, and stats never +see them. The decorative-frame visibility gating +(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data` +copy survives only in kill-switch-off mode, where renderer parsers still +need the bytes. The visible xterm renders nothing from titles, but `pane.terminal.onTitleChange` feeds `registerPtyTitleSource` -(`pty-connection.ts:1797-1799`) → renderer serialize-snapshot `lastTitle` -(mobile parity). After the reroute main must prefer its own tracker title -over renderer snapshot `lastTitle`. Side benefit: synthetic frames stop -producing phantom ACKs for bytes main never metered -(`pty-dispatcher.ts:124-129`). +(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile +parity); main prefers its own tracked title over renderer snapshot +`lastTitle` in both serialize paths. Under main authority synthetic frames +no longer produce phantom ACKs for bytes main never metered (`ackPtyData`, +`pty-dispatcher.ts`). ## Migration Switch and Double-Fire Prevention Authority is structural per PTY kind — the predicate is "bytes transit local -main", exactly the shipped `shouldOwnAgentStatusInRenderer` split -(`pty-connection.ts:1484-1545`). One renderer-consulted kill switch +main", exactly the `shouldOwnAgentStatusInRenderer` split +(`pty-connection.ts`). One renderer-consulted kill switch (`settings.terminalMainSideEffectAuthority`, default on, mirroring `terminalHiddenViewParking`): when on, IPC transports and the parked watcher do not register byte parsers for local/SSH and the store handler consumes @@ -162,17 +171,19 @@ Keep renderer byte access (input pacing / raw-output consumers, not side effects): `agent-paste-draft.ts` (DECSET 2004 readiness), `launch-agent-background-session.ts` (startup-injection pacing, onData passthrough), `automation-session-observer.ts` (onData passthrough), and -`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies while -parked). Their duplicated local OSC 9999 store writes are gated off under -main authority (shipped — the `onAgentStatus` automation callbacks still -fire; only the racing `setAgentStatus` store writes drop). Phase 4's -hidden-delivery gate must exempt PTYs with an active `subscribeToPtyData` -sidecar: that registration becomes an explicit delivery-interest signal -surfaced to main. With main authoritative, the parked watcher is purely -fact-driven: byte parsing exists only in kill-switch-off mode, and the 2031 -reply lives in the dedicated responder sidecar. The watcher file is deleted -outright only when the kill switch retires — it returns as a byte parser -only if remote-runtime tabs ever become parkable. +`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for +parked tabs while the delivery gate is off). Their duplicated local OSC 9999 +store writes are gated off under main authority (the `onAgentStatus` +automation callbacks still fire; only the racing `setAgentStatus` store +writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active +`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a +ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main +authoritative, the parked watcher is purely fact-driven: byte parsing exists +only in kill-switch-off mode, and the 2031 reply comes from the +`2031-subscribe` fact when the gate is on (the byte responder sidecar only +when it is off). The watcher file is deleted outright only when the kill +switch retires — it returns as a byte parser only if remote-runtime tabs +ever become parkable. ## Invariants @@ -183,17 +194,18 @@ only if remote-runtime tabs ever become parkable. 3. Notification policy (grace timers, yielding, suppression, dispatch guards) lives with the renderer store; main emits facts with ordering metadata. 4. Side-effect facts keep flowing while renderer byte delivery is - backpressured, parked, or (Phase 4) stopped. + backpressured, parked, or stopped by the hidden-delivery gate. 5. Synthetic agent frames feed the model tracker, never the emulator, tails, transcripts, or stats. ## Test Strategy -- Parity harness: shared byte fixtures (agent title cycles incl. coalesced - chunks, BEL inside/spanning OSC, CAN/SUB cancellation, cursor-agent literal, - stale-title timeout under fake timers, OSC 133;D, split PR URLs) run through - the renderer `createPtyOutputProcessor` and the main tracker; assert - identical ordered fact sequences. +- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte + fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning + OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under + fake timers, OSC 133;D, split PR URLs) run through the renderer + `createPtyOutputProcessor` and the main tracker; assert identical ordered + fact sequences. - Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle parity, tui-idle waiter transitions, synthetic ingestion); store-handler tests reusing `parked-terminal-byte-watcher.test.ts` scenarios. @@ -206,7 +218,7 @@ only if remote-runtime tabs ever become parkable. (parked tab, focused-pane suppression, kill switch off). SSH parity is exercised manually per the SSH test procedure before each slice ships. -## Cut-Offs (stacked, independently mergeable) +## Cut-Offs (shipped as four stacked slices) 1. **Shared tracker in main.** Extract the processor core to shared, run the per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity @@ -217,16 +229,12 @@ only if remote-runtime tabs ever become parkable. 3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`; OSC 133;D and PR-link facts; mobile `lastTitle` source preference. 4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked - watcher deletion, Phase 4 delivery-interest registration documented in the - gate design. + watcher shrunk to fact-driven mode (deletion waits on kill-switch + retirement), Phase 4 delivery-interest registration documented in the gate + design. -## Open Items (carried into Phase 4) +## Open Items -- **Delivery-interest registration.** Every remaining `subscribeToPtyData` - sidecar (`parked-terminal-mode2031-responder.ts`, `agent-paste-draft.ts`, - `launch-agent-background-session.ts`, `automation-session-observer.ts`) - must surface its registration to main as an explicit delivery-interest - signal before the hidden-delivery gate can stop byte delivery. - **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon `Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is diff --git a/docs/terminal-main-owned-state.md b/docs/terminal-main-owned-state.md index 48b1dce27c5..e3e7a125988 100644 --- a/docs/terminal-main-owned-state.md +++ b/docs/terminal-main-owned-state.md @@ -55,6 +55,13 @@ already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated and can serialize it. +Since the hidden-delivery gate shipped (`terminalHiddenDeliveryGate`, default +on — see the contract's Architecture Status), main drops hidden renderer-bound +bytes after model ingestion and emits an out-of-band restore marker, so a +gated hidden pane accumulates no renderer backlog at all. The overflow path +below is the fallback for kill-switch-off mode and for hidden PTYs with an +active delivery-interest sidecar. + The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded: 1. The scheduler replaces the queued backlog with a small warning fallback.