Finalize terminal model view architecture docs

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-06-11 00:59:52 -07:00
co-authored by Orca
parent 0ba2412788
commit bdecd764c3
5 changed files with 250 additions and 164 deletions
+45 -30
View File
@@ -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.
+72 -18
View File
@@ -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.
+19 -17
View File
@@ -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.
+107 -99
View File
@@ -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
+7
View File
@@ -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.