14 KiB
Terminal Side-Effect Authority
Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch
terminalMainSideEffectAuthority (default on). Builds on
terminal-model-view-contract.md and
terminal-hidden-view-parking.md (Phase 1).
Problem
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
"Main" means parsed once in onPtyData and delivered as derived facts.
Remote-runtime PTYs (remote:) never transit local main; the renderer
(remote-runtime-pty-transport.ts:74) stays their parser permanently.
| Side effect | local-daemon | SSH | remote-runtime |
|---|---|---|---|
| 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 (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 (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
- The side-effect core shared with the renderer processor lives in
src/shared/terminal-output-side-effects.ts: all-titles ordering viaextractAllOscTitles(coalesced working→idle transitions are why last-title is insufficient — issue #1083),normalizeTerminalTitle, the literalcursor agenttitle drop (CURSOR_NATIVE_TITLE_LOWER,src/shared/agent-detection.ts), thecreateAgentStatusTrackertransitions, 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 likeagentStatusOscProcessorsByPtyId; disposed inonPtyExit(cancels the stale-title timer). - It replaced the chunk-level last-title extraction in
onPtyData: titles feed in byte order, solastOscTitle/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 (worktreepsand mobile tab titles expect raw); emitted facts carry(normalizedTitle, rawTitle)likeonTitleChange. - No deferred drain in main — the renderer's setTimeout(0) batching
(
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 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
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), and the PTY
outputSequence.
Ordering rules:
- Per-PTY in-order; facts from one chunk are emitted in byte order (status payloads, then titles in sequence, then bell — the renderer drain's order).
- Deliberately not synchronized with
pty:data: side effects must keep advancing while renderer delivery is ACK-gated (contract invariant 1). A completion title may reach the store before the visible xterm paints the final output; that is acceptable — attention/title state is out-of-band UI state, and today's renderer drain already decouples by many batches under timer throttling. - No attention replay: facts emitted while no renderer is subscribed are
dropped. On transport attach/park-handoff the renderer pulls a title-only
snapshot (
pty:sideEffectSnapshot) markedreplay: true— this reproduces the eager-buffer behavior where replay restores titles but is barred from bells/completions (suppressAttentionEvents,pty-transport.ts). The store handler ignores a replay title older (byoutputSequence) than the last live title fact it applied.
Renderer Store Handler (policy stays in the renderer)
Notification semantics, all preserved across the authority flip:
- BEL marks worktree+tab unread unconditionally — including the focused pane
(
onBell,pty-connection.ts); pane unread only behindexperimentalTerminalAttention; keydown clears unread (onTerminalKeyDown,pty-connection.ts). - BEL's OS notification is delayed 250 ms and yields to a pending
agent-task-complete (
scheduleTerminalBellNotification,pty-connection.ts). - working→idle starts the Claude cache timer (null settings = not hydrated,
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
(
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:
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 (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 (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) → 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 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
pty:sideEffect; when off, renderer parsers register and pty:sideEffect
events are ignored. Main always parses and emits (its internal consumers need
the tracker regardless); main consults the same setting only to keep the
legacy synthetic-frame pty:data path alive while the switch is off. Exactly
one consumer per fact at any time — decided at transport/watcher creation, so
no per-chunk race.
Sidecar Consumers and Phase 4
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 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
- Every byte is side-effect-parsed exactly once, by exactly one authority, chosen structurally per PTY kind.
- Attention facts never replay: snapshot/eager/attach replays restore title state only.
- Notification policy (grace timers, yielding, suppression, dispatch guards) lives with the renderer store; main emits facts with ordering metadata.
- Side-effect facts keep flowing while renderer byte delivery is backpressured, parked, or stopped by the hidden-delivery gate.
- Synthetic agent frames feed the model tracker, never the emulator, tails, transcripts, or stats.
Test Strategy
- 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 renderercreatePtyOutputProcessorand 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 reusingparked-terminal-byte-watcher.test.tsscenarios. - Pinned tests that flip or retire:
pty-connection.test.tscallback wiring,parked-terminal-byte-watcher.test.ts(retires with the watcher);pty-transport*.test.tsstay (processor remains for remote + kill switch). - E2E gates that must stay green throughout:
terminal-attention.spec.ts,droid-notification.spec.ts,terminal-hidden-view-parking.spec.ts,terminal-parked-memory.spec.ts; add main-authority bell/completion cases (parked tab, focused-pane suppression, kill switch off). SSH parity is exercised manually per the SSH test procedure before each slice ships.
Cut-Offs (shipped as four stacked slices)
- Shared tracker in main. Extract the processor core to shared, run the
per-PTY tracker in
onPtyDatareplacingextractLastOscTitle, parity tests. Main-internal consumers only; no IPC or renderer change. - Authority flip.
pty:sideEffectchannel, renderer store handler, titles/bell/tracker authority to main for local+SSH behind the kill switch; parked watcher stops byte parsing for those kinds. - Inversion unwind. Synthetic frames into the tracker, off
pty:data; OSC 133;D and PR-link facts; mobilelastTitlesource preference. - Long tail. Command Code scrape to main, sidecar OSC 9999 dedup, parked watcher shrunk to fact-driven mode (deletion waits on kill-switch retirement), Phase 4 delivery-interest registration documented in the gate design.
Open Items
- Daemon checkpoint
lastTitleis write-only. The daemon sleep/periodic checkpoint (daemon-pty-adapter.checkpointSessions→ daemonSession.getSnapshot) persists the daemon emulator'slastTitle, which is derived from real PTY bytes only — synthetic hook title frames never reach the daemon process, so that field cannot carry hook-driven titles. Today no restore path reads it back (ColdRestoreInfodrops it; reattach snapshots surface only the ANSI payload), so there is nothing to fix. Main-side consumers of the renderer serializer'slastTitle(mobile snapshot reads and the headless hydration seed) prefer main's tracked title. If a future consumer starts reading checkpointlastTitle, it must route through the same tracked-title preference. - Kill-switch retirement. Once
terminalMainSideEffectAuthorityis removed, the parked watcher's byte-parser mode, the renderer transport parsers for local/SSH, and the legacy synthetic-framepty:datacopy all become dead code and the watcher byte path can be deleted outright.