72 KiB
Mobile scrollback parity: hydrate headless emulator from renderer on first PTY data
Problem
When a mobile companion client subscribes to a terminal, it sees substantially less scrollback than the desktop renderer for the same PTY. In the worst case observed (a claude-then-shell session in the edit-issues worktree), the desktop shows ~30 lines of post-exit agent summary while mobile shows a single bare prompt.
The mismatch is reproducible and not a transient race: it persists across resubscribes, app return-from-background, and PTY-clean shell prompts.
Where data lives today
Two separate xterm.js instances exist for every connected PTY:
-
Renderer xterm — owned by
TerminalPanein the renderer process. Created when the user mounts the pane. Listens topty:dataIPC for the lifetime of the pane. 50,000-row scrollback. Source of truth for the visible desktop terminal. -
Runtime headless emulator —
HeadlessEmulatorinsrc/main/runtime/orca-runtime.ts. Created lazily on firstruntime.onPtyData(ptyId, …)call. 5,000-row scrollback. Used to serveterminal.read,terminal.subscribe,lastAgentStatus, and TUI-idle detection. This is what mobile, the CLI, and any non-renderer consumer get.
| Consumer | Reads from | Lifetime |
|---|---|---|
| Desktop pane | Renderer xterm | Pane mount → unmount |
Mobile (terminal.subscribe) |
Headless emulator (primary), renderer (fallback) | Runtime startup → PTY exit |
terminal.read (CLI / agent) |
Headless emulator | Runtime startup → PTY exit |
lastAgentStatus |
Headless emulator (via OSC detection on onPtyData) |
Runtime startup → PTY exit |
The two xterms are fed by the same provider stream but are "born" at different moments:
- The renderer xterm gets hydrated on attach via cold-restore data (
result.coldRestore.scrollback) or warm reattach (result.snapshot) inpty-connection.ts, plus all live bytes since. - The headless emulator only sees bytes that flow through
runtime.onPtyDatasince runtime startup. After an Orca relaunch, the headless emulator starts empty and only catches up on new live data; it never replays the cold-restore payload. - Commit
8a5ea4b7addedseedHeadlessTerminal(ptyId, data, size?)which runs fromipc/pty.tsonprovider.spawnand seeds the emulator fromresult.snapshot/result.coldRestore.scrollback. That helps daemon-restored sessions but does not help when the daemon's persisted state is itself near-empty — theedit-issuesreproduction hascheckpoint.jsonwithscrollbackAnsi: ""because the daemon checkpoint only stores the visible screen, not real scrollback.
So the dominant residual case is: daemon checkpoint is near-empty, the user opens a desktop pane (which hydrates from the live PTY post-spawn and accumulates a rich 50k-row buffer), then the user opens mobile — and mobile reads from the headless emulator, which has only seen bytes since runtime startup with nothing to replay.
Goal
Mobile, CLI, and lastAgentStatus should display the same scrollback as the desktop renderer for any PTY whenever the user has a desktop pane mounted for it. The fix must:
- Be a small, targeted change to the runtime (no daemon, IPC schema, or persistence-format changes).
- Not regress the "no desktop pane mounted" cases (background terminals, headless agent runs, CLI on a freshly-restored daemon).
- Not introduce reflow / SGR drift between desktop and mobile.
- Not introduce a per-keystroke or per-subscribe renderer round-trip on the hot path.
- Reinforce — not violate — the existing invariant that the main process is authoritative for terminal state served to mobile/CLI/
terminal.read.
Non-goals
- Mobile-only-after-relaunch (Scenario A). A user who relaunches Orca and opens mobile without ever opening the desktop pane will still see only what the daemon checkpoint stored (often the visible screen and nothing more). Solving this requires daemon-side persisted scrollback, which is a separate, larger effort with its own privacy, format-stability, retention, and IO-cost considerations. This design explicitly does not solve Scenario A.
- Persisting full scrollback to disk (the "option 2" architecture).
- Unifying the renderer and headless emulators into a single main-process service.
- Changing mobile's xterm-side replay logic. Today it correctly handles either source.
- Changing the read-priority order in
serializeTerminalBufferFromAvailableState. Headless stays primary; renderer stays the no-headless fallback.
Proposed change
When the runtime first sees pty:data for a ptyId that already has a renderer pane registered (detected via a new ptyController.hasRendererSerializer(ptyId) predicate, backed by the serializersByPtyId registry in pty-buffer-serializer.ts), perform a one-time IPC round-trip to the renderer, get its serialized buffer, and seed the headless emulator with it before writing the live byte that just arrived. Subsequent pty:data calls for the same ptyId skip hydration via a per-PTY guard. If no renderer serializer is registered yet on the first byte, the guard is left absent so a later byte can retry once the pane mounts.
This is structurally the same operation seedHeadlessTerminal already does on provider.spawn from daemon-restored state. The new path covers the gap the existing seeding doesn't: the renderer has a richer buffer than the daemon does, and we want that richer buffer mirrored into the headless emulator on first runtime touch.
After hydration, all consumers (terminal.read, terminal.subscribe, lastAgentStatus, mobile, CLI) get the same fuller scrollback through the existing read paths. No priority flip is required. No per-consumer fallback. No per-subscribe IPC.
System context — before
┌─────────────────────────────────────────────┐
│ Renderer process │
│ │
│ TerminalPane → xterm.js (50k rows) │
│ ▲ │
│ │ replayIntoTerminal(coldRestore. │
│ │ scrollback) on pane mount │
│ │ + live pty:data writes │
└─────┼───────────────────────────────────────┘
│
pty:data│IPC
│
┌─────┴───────────────────────────────────────┐
│ Main process │
│ │
│ ipc/pty.ts (provider.spawn) │
│ ├─→ seedHeadlessTerminal(snapshot) │
│ │ ↓ │
│ │ HeadlessEmulator (5k rows) ───┐ │
│ │ ▲ │ │
│ │ │ trackHeadlessTerminalData│ │
│ │ │ on every onPtyData │ │
│ └─→ runtime.onPtyData(...)────────┘ │
│ ▼ │
│ serializeHeadless │
│ (primary read source) │
│ │ │
│ ▼ │
│ mobile / CLI / lastStatus │
└─────────────────────────────────────────────┘
▲
pty:data│from daemon
│
┌─────┴───────────────────────────────────────┐
│ Daemon │
│ checkpoint.json (visible screen only) │
└─────────────────────────────────────────────┘
Bug: when daemon checkpoint is near-empty but the renderer xterm has
accumulated a rich live buffer, mobile reads from the headless emulator
which only saw bytes since runtime startup — and so sees less than desktop.
System context — after
┌─────────────────────────────────────────────┐
│ Renderer process │
│ │
│ TerminalPane → xterm.js (50k rows) │
│ │ ▲ │
│ │ │ unchanged hydration path │
│ │ └─── live pty:data writes │
│ │ │
│ │ ptyController.serializeBuffer(ptyId) │
│ │ called ONCE per (ptyId, runtime) │
│ │ on first onPtyData │
│ ▼ │
└─────┼───────────────────────────────────────┘
│
pty:data│IPC + one-time serialize round-trip
│
┌─────┴───────────────────────────────────────┐
│ Main process │
│ │
│ onPtyData(ptyId, data) (first time) │
│ ├─→ if ptyController.serializeBuffer │
│ │ returns non-empty: │
│ │ hydrateHeadlessFromRenderer → │
│ │ seedHeadlessTerminal(rendered) │
│ │ mark hydrated │
│ ├─→ trackHeadlessTerminalData(data) │
│ │ (chained via writeChain after seed) │
│ │ │
│ HeadlessEmulator (5k rows, now seeded │
│ with renderer's view) │
│ ↓ │
│ serializeHeadless (UNCHANGED PRIMARY) │
│ ↓ │
│ mobile / CLI / lastAgentStatus / read │
└─────────────────────────────────────────────┘
Read priorities are unchanged. Headless emulator is still the primary
source of truth; renderer is consulted exactly once, at hydration time,
not per consumer or per subscribe.
Ordering invariant (DO NOT REORDER)
In onPtyData(ptyId, data, at), the call sequence is:
this.agentDetector?.onData(ptyId, data, at)
this.maybeHydrateHeadlessFromRenderer(ptyId) // ← MUST come BEFORE
this.trackHeadlessTerminalData(ptyId, data) // ← this line
maybeHydrateHeadlessFromRenderer MUST be invoked before trackHeadlessTerminalData. If reordered, trackHeadlessTerminalData's lazy-create branch will populate headlessTerminals with a fresh empty emulator first, and maybeHydrateHeadlessFromRenderer's !headlessTerminals.has(ptyId) precondition (the "live bytes already arrived" guard) will short-circuit — silently disabling hydration for the lifetime of the PTY. There is no test that catches this regression cheaply (the failure mode is "less scrollback than expected", which is the original bug); the guarantee lives in the call-site comment in orca-runtime.ts.
A // DO NOT REORDER — see "Ordering invariant" in mobile-prefer-renderer-scrollback.md comment is mandatory at this call site.
Pseudocode
// Per-PTY hydration tracker. Key absent = idle (next byte may attempt).
// 'pending' = round-trip in flight; 'done' = succeeded or unrecoverably
// finished; 'skipped' is no longer used — instead we leave the key absent
// when the precondition fails so a later byte can retry.
private headlessHydrationState = new Map<string, 'pending' | 'done'>()
onPtyData(ptyId: string, data: string, at: number): void {
this.agentDetector?.onData(ptyId, data, at)
this.maybeHydrateHeadlessFromRenderer(ptyId)
this.trackHeadlessTerminalData(ptyId, data)
// …existing OSC / tail / leaf bookkeeping unchanged
}
private async maybeHydrateHeadlessFromRenderer(ptyId: string): Promise<void> {
if (this.headlessHydrationState.has(ptyId)) return
if (this.headlessTerminals.has(ptyId)) {
// Live bytes already arrived for this ptyId — re-seeding now would
// duplicate them. Mark done; never retry. (Daemon-snapshot seeding
// is handled separately: see "Cooperation with seedHeadlessTerminal"
// — that path is gated on `!hasRendererSerializer(ptyId)` so it does
// not pre-empt this hydration when a renderer pane is mounted.)
this.headlessHydrationState.set(ptyId, 'done')
return
}
// Why: leave the state absent (not 'done') when there's no renderer
// serializer or controller. The pane may mount one byte later; the
// next onPtyData call gets another chance. This is the contract change
// motivated by Open Question P2-2.
if (!this.ptyController?.serializeBuffer) return
if (!this.ptyController.hasRendererSerializer?.(ptyId)) return // not yet registered
this.headlessHydrationState.set(ptyId, 'pending')
// Why: eagerly create the headless state at PTY dims so concurrent live
// writes from trackHeadlessTerminalData chain onto the same writeChain.
// This matches seedHeadlessTerminal's pattern (orca-runtime.ts:780-803);
// without it, a live byte arriving during the serializeBuffer await would
// lazy-create a separate state that we'd later overwrite, dropping the
// live byte.
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
const state: RuntimeHeadlessTerminal = {
emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }),
writeChain: Promise.resolve()
}
this.headlessTerminals.set(ptyId, state)
// Why: append the renderer-fetch + seed-write to the chain. Live writes
// that arrive before the chain head resolves are queued behind it via
// trackHeadlessTerminalData's existing `state.writeChain.then(...)` pattern.
state.writeChain = state.writeChain.then(async () => {
try {
const rendered = await this.ptyController!.serializeBuffer!(ptyId, {
scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS,
altScreenForcesZeroRows: true
})
if (!rendered || rendered.data.length === 0) {
this.headlessHydrationState.set(ptyId, 'done')
return
}
// Why: resize to the renderer's reported dims before writing the seed
// so the serialized layout reflows correctly. Then resize back to the
// PTY's current dims after the seed lands so subsequent live writes
// use the right cell grid.
state.emulator.resize(rendered.cols, rendered.rows)
await state.emulator.write(rendered.data)
const ptyDims = this.getTerminalSize(ptyId)
if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) {
state.emulator.resize(ptyDims.cols, ptyDims.rows)
}
// Why: status parity. SerializeAddon does NOT round-trip OSC 0/1/2
// title bytes, so extracting from rendered.data would always be null.
// applySeededAgentStatus runs detectAgentStatusFromTitle and writes
// leaf.lastAgentStatus only when the result is non-null; it MUST NOT
// call resolveTuiIdleWaiters or deliverPendingMessages because seed-
// derived status reflects historical state.
if (rendered.lastTitle) {
this.applySeededAgentStatus(ptyId, rendered.lastTitle)
}
} catch {
// Hydration is best-effort. Live writes continue via the same
// writeChain that this catch-arm leaves intact.
} finally {
this.headlessHydrationState.set(ptyId, 'done')
}
})
}
Eager-state ordering invariant. Even though the chain head blocks live writes until the seed completes, the LIVE BYTE ITSELF (the one that triggered onPtyData) is enqueued onto state.writeChain BY trackHeadlessTerminalData AFTER maybeHydrateHeadlessFromRenderer returns synchronously and registers its seed-write link. The live byte's chain link executes after the seed-write resolves. That is the ordering invariant the eager-state pattern preserves.
Resize-during-hydration note. resizeHeadlessTerminal (orca-runtime.ts:828) deliberately bypasses writeChain and calls state.emulator.resize(...) directly — that's existing behavior we preserve. If a pty:resize IPC arrives during the hydration window (between headlessTerminals.set and seedPromise resolution), the resize lands on the emulator while the seed-write is mid-flight; this can produce a one-frame visual artifact in the seed but cannot corrupt subsequent live writes (which the seed-then-resize-back invariant restores). The narrow window — typically <200ms — is bounded by the renderer-IPC round-trip duration.
The cap and alt-screen rule on the renderer side mirror serializeHeadlessTerminalBuffer: 1000-row limit on rehydration payload, force scrollbackRows = 0 when the renderer xterm reports buffer.active.type === 'alternate'. This unifies semantics between hydration source and live-serialize source — the user sees the same depth regardless of which path served them, and an alt-screen TUI never bleeds normal-buffer scrollback into the seed.
IPC contract changes
Hydration relies on the renderer-side serializer accepting an option bag. Today the chain is single-arg end to end, so changes must land in lockstep:
src/preload/index.ts— thepty:serializeBuffer:requestpayload extends to{ requestId, ptyId, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } }. Renderer-side type for the listener callback widens accordingly.src/main/ipc/pty.ts—requestSerializedBuffer(ptyId, opts?)accepts the opts object and forwards it on the IPCwebContents.sendpayload. Snapshot-shape validation widens to accept an optionallastTitle: string. Existing 750 ms timeout unchanged.src/main/runtime/orca-runtime.ts— theRuntimePtyController.serializeBuffer?signature widens to(ptyId, opts?) => Promise<{ data: string, cols: number, rows: number, lastTitle?: string } | null>. A new optionalhasRendererSerializer?(ptyId): booleanlets the runtime check pane mount status before paying the IPC cost (implementation: a getter againstserializersByPtyIdinpty-buffer-serializer.ts).src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts— the registeredSerializeFnwidens to(opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …. The pane's serializer reads the opts: ifaltScreenForcesZeroRows && pane.terminal.buffer.active.type === 'alternate', callpane.serializeAddon.serialize({ scrollback: 0 }); otherwisepane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows }). The response payload includeslastTitle: lastTitleByPtyId.get(ptyId)?.title(omitted when no title has been observed for this PTY). Default behavior (no opts) preserves today's full-buffer serialize for any caller that hasn't migrated.serializeTerminalBufferFromAvailableState(the renderer-fallback read path inorca-runtime.ts:843) forwardsscrollbackRowsso the fallback's depth matches the primary path. It does NOT passaltScreenForcesZeroRows: true— see "alt-screen flag scoping" below.
alt-screen flag scoping (only on hydration path). altScreenForcesZeroRows: true is passed ONLY by maybeHydrateHeadlessFromRenderer (the hydration path). The read-fallback path (serializeTerminalBufferFromAvailableState) passes altScreenForcesZeroRows: false (or omits the flag, default false). Reasoning:
- Hydration: wants the normal buffer's scrollback. xterm discards alt-screen content when alt-screen exits anyway, so suppressing scrollback during alt-screen prevents transient TUI bytes from bleeding into the seed that the headless emulator will hold for the rest of the session.
- Read fallback: is invoked by
terminal.read/terminal.subscribefor an actively-running TUI when the headless emulator returned null. The caller wants whatever is currently visible — including alt-screen content — so suppressing it would produce a worse result than today.
Both call sites pass explicit opts in the pseudocode (no implicit defaults relied on). The read-fallback shape:
// In serializeTerminalBufferFromAvailableState (read-fallback):
rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, {
scrollbackRows: opts.scrollbackRows,
altScreenForcesZeroRows: false // read-fallback wants visible alt-screen content
}) ?? Promise.resolve(null))
Renderer-side prerequisite
Today, registerPtySerializer(ptyId, …) is only called inside handleReattachResult (pty-connection.ts:571). Fresh spawns — the dominant case — never register, so hasRendererSerializer(ptyId) would always return false and hydration would no-op. The renderer change required:
- Move (or duplicate) the
registerPtySerializercall so it fires for both reattach and fresh spawn paths. Cleanest shape: extract the body (closure capturingpane,deps.pendingWritesRef,deps.replayingPanesRef) into aregisterPaneSerializerFor(ptyId)helper and call it fromhandleReattachResultAND fromstartFreshSpawn's.then(spawnedPtyId => …)once aptyIdis known. - The helper accepts the new
optsarg and threads it intopane.serializeAddon.serialize({ scrollback: … })per the IPC contract above.
Cleanup contract & StrictMode safety. Two requirements, both motivated by React 18 StrictMode double-mount and async pane disposal:
-
if (disposed) returnguard at the top of every post-spawn registration call (both reattach and fresh-spawn paths). Rationale: StrictMode mounts the pane twice; the first mount is disposed before the second runs, but itspty:spawnIPC may have already resolved by the timedisposedflips to true. Without the guard, the disposed first mount would callregisterPaneSerializerForafter teardown — replacing the live registration from the second mount or registering a closure over a torn-downpane.terminal. -
Unregister via ownership token, not unconditional delete.
serializersByPtyIdbecomesMap<ptyId, { fn: SerializeFn, owner: symbol }>. Each call toregisterPtySerializermints a freshownersymbol and returns an unregister closure that only deletes the entry when the current entry'sowner === thisOwner. If a later registration has overwritten the entry (StrictMode second mount, fast pane swap), the unregister is a no-op so the live registration is preserved. The body of the unregister closure also clears any module-level state keyed off the same owner (lastTitle bookkeeping introduced below) AND disposes the xtermonTitleChangeIDisposable that was installed at register time.export function registerPtySerializer(ptyId, fn): () => void { const owner = Symbol(ptyId) serializersByPtyId.set(ptyId, { fn, owner }) ensureSerializerListener() return () => { const current = serializersByPtyId.get(ptyId) if (current?.owner === owner) { serializersByPtyId.delete(ptyId) } } } -
Spawn-rejection cleanup is mandatory. The renderer MUST call
pty:clearPendingPaneSerializer(paneKey, gen)from the catch-arm ofpty:spawnAND from pane unmount AFTER pre-signal but BEFORE settle. Without this, a failed-spawn paneKey leaks a pending intent that suppresses the next legitimate daemon-snapshot seed for that paneKey. -
Pre-signal applies to BOTH fresh-spawn AND reattach paths.
declarePendingPaneSerializer(paneKey)is mandatory on both — mirroring theregisterPaneSerializerFor(ptyId)requirement. Reattach paths today already register the serializer inhandleReattachResult, but the pre-signal must also fire on the reattach path so the cooperation gate inpty:spawnbehaves consistently regardless of whether the renderer is taking over a pre-existing PTY or a fresh one.
The unregisterSerializer() call is wired into onDataDisposable.dispose so the registration is torn down on pane unmount, regardless of which path registered it.
Renderer pre-signal handshake
The cooperation gate (ipc/pty.ts skipping seedHeadlessTerminal when a renderer serializer is registered) has a fundamental timing problem on fresh spawns: the renderer cannot have registered the serializer for the new ptyId yet, because it doesn't know the ptyId until pty:spawn resolves. So at the moment the cooperation gate runs (immediately after provider.spawn returns inside pty:spawn's handler), runtime.hasRendererSerializerFor(ptyId) === false for every fresh spawn — and the daemon-snapshot seed always wins, defeating the gate for the dominant case.
The renderer does, however, know its paneKey before spawn: every pane has a stable ORCA_PANE_KEY env var that is part of args.env on pty:spawn. We use the paneKey as a pre-signal, on a channel that doesn't depend on knowing the ptyId yet:
-
Pre-signal IPC with generation token. Renderer calls
pty:declarePendingPaneSerializer(paneKey)BEFORE invokingpty:spawn. Main process maintainspendingByPaneKey: Map<paneKey, generation: number>keyed by paneKey. On each pre-signal, main mints a fresh monotonicgenvalue (incrementing a module-levelgenSeqcounter) and overwrites the entry, then returnsgento the renderer. The renderer awaits this IPC (capturinggen), then awaits the spawn IPC — guaranteeing the main process has the pending intent recorded by the timepty:spawnruns.let genSeq = 0 const pendingByPaneKey = new Map<string, number>() // paneKey -> generation function declarePending(paneKey: string): number { const gen = ++genSeq pendingByPaneKey.set(paneKey, gen) return gen // returned to renderer, echoed back on settle/clear } function settlePending(paneKey: string, gen: number): void { if (pendingByPaneKey.get(paneKey) === gen) pendingByPaneKey.delete(paneKey) } paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen))Why a generation counter: today's design hooks
pendingByPaneKey.delete(paneKey)intoclearProviderPtyStateviapaneKeyTeardownListeners. If the OLD PTY'sclearProviderPtyStatefires AFTER mount-2's pre-signal but BEFORE mount-2's spawn, an unconditional teardown deletes mount-2's pending entry — defeating the gate. The generation counter solves this: thepaneKeyTeardownListenerswiring captures the generation at the time the old PTY was REGISTERED (via the existingregisterPtycall site) and the teardown only firessettlePendingwith that generation. If mount-2 has already replaced the entry with a new gen,pendingByPaneKey.get(K) !== oldGenand the teardown is a no-op. -
Cooperation gate consults the pre-signal. Inside
pty:spawn, afterprovider.spawnreturns, the gate becomes:const paneKey = args.env?.ORCA_PANE_KEY const rendererPreSignaled = typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256 && pendingByPaneKey.has(paneKey) const rendererAlreadyRegistered = runtime?.hasRendererSerializerFor?.(result.id) ?? false if (!rendererPreSignaled && !rendererAlreadyRegistered) { // No renderer is or will be authoritative for this PTY — daemon-snapshot // seed runs as today. runtime?.seedHeadlessTerminal(result.id, /* …snapshot/coldRestore… */) }The
rendererAlreadyRegisteredbranch covers reattach, where the pane mounted before spawn returned and registration completed in the meantime. TherendererPreSignaledbranch covers fresh spawn, where registration has not happened yet but is imminent. Note: the gate matches whenpendingByPaneKey.has(paneKey)is true regardless of generation; the generation is only used by cleanup paths (teardown listeners, settle, clear) to prevent cross-generation deletion. Invariant: fresh-spawn always relies on the pre-signal branch (hasRendererSerializerFor(ptyId)is necessarily false at gate-time since the renderer hasn't receivedptyIdyet); the registered branch only fires for reattach where the pane mounted before spawn returned. -
Settle / clear (echo gen back). After the spawn IPC resolves and the renderer calls
registerPtySerializer(ptyId, …), it callspty:settlePaneSerializer(paneKey, gen)to remove the pending intent (echoing thegenvalue it captured from declarePending). The handler runssettlePending(paneKey, gen)which only deletes if the stored gen still matches. (The simpler "clear on first onPtyData" or "clear on receipt ofpty:registerPaneSerializer" alternatives work too — the design picks the explicitsettleIPC because it makes the contract auditable from the renderer side and avoids coupling main-process state to onPtyData flow.) On pane unmount or registration failure, the renderer also callspty:clearPendingPaneSerializer(paneKey, gen)(sibling handler with the samesettlePendingsemantics) to drop the pending intent.
Sequence diagram (fresh spawn, happy path):
Renderer Main / ipc-pty.ts Runtime
│ │ │
│ pty:declarePendingPaneSerializer │ │
│ (paneKey) ───────────────────────────▶│ pendingByPaneKey.set(K, gen) │
│ ◀──────────────────────── ack(gen) ──│ │
│ │ │
│ pty:spawn(args incl. ORCA_PANE_KEY) ─▶│ │
│ │ provider.spawn(...) → result │
│ │ pendingByPaneKey.has(paneKey)│
│ │ === true → SKIP │
│ │ seedHeadlessTerminal │
│ │ │
│ ◀──────────────────────────── ptyId ──│ │
│ │ │
│ registerPtySerializer(ptyId, fn) │ │
│ pty:settlePaneSerializer(paneKey, │ │
│ gen) ──────────────────────────────▶│ settlePending(paneKey, gen) │
│ │ if gen matches → delete │
│ │ │
│ │ first onPtyData(ptyId,data)──▶│
│ │ │ maybeHydrateFromRenderer
│ │ │ (headlessTerminals empty,
│ │ │ hasRendererSerializer true
│ │ │ → IPC round-trip → seed)
Renderer-side call shape. The renderer threads gen through every call site so cleanup (settle/clear) can be matched against the generation it captured at declare time:
// In pty-connection.ts startFreshSpawn / handleReattachResult:
const gen = await window.api.pty.declarePendingPaneSerializer(paneKey)
try {
const ptyId = await window.api.pty.spawn(args)
registerPaneSerializerFor(ptyId)
await window.api.pty.settlePaneSerializer(paneKey, gen)
} catch (err) {
await window.api.pty.clearPendingPaneSerializer(paneKey, gen)
throw err
}
Correctness properties:
- Pre-signal arrives before
pty:spawn. The renderer awaits the pre-signal IPC, then awaits the spawn IPC. ElectronipcRenderer.invoke/sendordering on a single renderer is preserved on the main side, so the main process processes the pre-signal beforepty:spawn's handler runs. - Missing paneKey degrades to today's behavior. If
args.env?.ORCA_PANE_KEYis absent (e.g., a pane that isn't yet wired to set it, an old renderer build, or a non-pane spawn path), the pre-signal lookup returns false,rendererAlreadyRegisteredis also false on a fresh spawn, and the daemon-snapshot seed runs as today. No regression for callers that don't participate in the handshake. - Pre-signal without settle (renderer crashes between spawn and registerPtySerializer). The pending intent stays in the map, but it has no observable effect: the cooperation gate already ran, so the daemon snapshot was suppressed; the headless emulator stays empty; the first
onPtyDatafalls throughhasRendererSerializer(ptyId) === falseand hydration's precondition leaves the state map absent.trackHeadlessTerminalDatathen lazy-creates the emulator and live writes populate it. We lose the daemon snapshot for this PTY in that narrow window — accepted limitation, since the renderer was clearly going to be authoritative but didn't make it. (To bound staleness, the entry inpendingByPaneKeyis cleared whenever a paneKey teardown event fires via the existingpaneKeyTeardownListenersregistry, gated by the generation token.)
Files-touched implications: see "IPC contract changes" and "Files touched" — both pty:declarePendingPaneSerializer and pty:settlePaneSerializer need preload bindings AND main handlers.
Status parity
Seeding the emulator bypasses onPtyData's OSC-extraction path, so without intervention leaf.lastAgentStatus stays whatever it was (typically null for daemon-restored leaves) until the next live byte happens to carry an OSC title. Mobile would then see a stale or missing agent badge despite a rich seed.
A previous draft of this design called extractLastOscTitle(rendered.data) on the seed payload to recover the title. That doesn't work: xterm's SerializeAddon.serialize() writes out the visible buffer as a stream of cursor moves, SGR resets, and printable characters — it does NOT round-trip OSC 0/1/2 (window/icon title) escape sequences. The renderer xterm consumes the OSC and sets terminal.options.title, but the bytes never appear in serialize()'s output. So extractLastOscTitle on a seed payload is dead code — it always returns null even when the renderer's pane currently shows a perfectly good title. We need the title via a different channel.
Approach: ship now via IPC widening, document as best-effort. The renderer pane already wires pane.terminal.onTitleChange (in pty-connection.ts) to keep the tab title in sync. We capture the latest observed title in module state alongside the serializer registration, and widen the serialize-buffer IPC payload to carry it back to main:
-
Renderer side (
pty-buffer-serializer.ts):- Module-level
lastTitleByPtyId: Map<ptyId, { title: string, owner: symbol, disposable: IDisposable }>keyed by the same ownership-token symbol asserializersByPtyId(see "Renderer-side prerequisite"). Populated by a wrapper aroundpane.terminal.onTitleChangeinstalled at the same time asregisterPtySerializer. The entry stores BOTH the IDisposable returned frompane.terminal.onTitleChange(...)AND the owner symbol alongside the latest title. - When the pane unregisters, the
unregisterSerializerclosure disposes the IDisposable AND deletes the map entry, both gated by the owner-token match — same ownership semantics as the serializer map, so a StrictMode disposed first-mount cannot wipe the live second-mount's title or leak a dangling listener. - The IPC response payload widens from
{ data, cols, rows }to{ data, cols, rows, lastTitle?: string }. Default omitted when no title was observed for this PTY (e.g., fresh shell pre-prompt).
- Module-level
-
Main side (
ipc/pty.ts,orca-runtime.ts):requestSerializedBuffersnapshot-shape validation acceptslastTitle?: string.RuntimePtyController.serializeBuffer(ptyId, opts?)returnsPromise<{ data, cols, rows, lastTitle?: string } | null>.maybeHydrateHeadlessFromRenderercallsapplySeededAgentStatus(ptyId, rendered.lastTitle)only whenrendered.lastTitleis a non-empty string (the helper itself also short-circuits on null/empty for safety). The helper internally runsdetectAgentStatusFromTitleand only writesleaf.lastAgentStatuswhen the result is one of'idle' | 'working' | 'permission'.
-
applySeededAgentStatus(ptyId, title)(new helper inorca-runtime.ts). Behavior:- If
title == nullor empty, no-op. - Look up the leaf for
ptyIdvia the existing pty→leaf mapping. RundetectAgentStatusFromTitle(rendered.lastTitle)(the helper fromsrc/shared/agent-detection.ts). If the result is non-null, setleaf.lastAgentStatus = result. If null (title is not a recognizable agent title), leavelastAgentStatusuntouched — the next live OSC byte will populate it through the normal onPtyData path. - MUST NOT call
resolveTuiIdleWaiters(leaf)ordeliverPendingMessages(leaf). The seeded status reflects the renderer's last-observed historical title, not a live transition; resolving idle waiters or delivering pending messages on stale data could mistakenly unblock orchestration callers that should only react to a fresh prompt-return. - Why (mandatory comment in the helper definition):
// seed-derived status reflects historical state; orchestration waiters must only react to live transitions to avoid resolving on stale data. - Rationale for routing through
detectAgentStatusFromTitle: we mirror the live path (onPtyDataalready usesdetectAgentStatusFromTitle) so seeded and live paths produce the same union value, and downstreamleaf.lastAgentStatus === 'idle'consumers (orchestrationtui-idlewaiters, pending-message gating) keep working.leaf.lastAgentStatusis typedAgentStatus | nullwhereAgentStatus = 'working' | 'idle' | 'permission'; assigning the raw title string would be type-incorrect.
- If
This delivers status parity for the dominant case via the lastTitle field (any pane that has rendered a prompt or had a TUI emit OSC titles will have populated lastTitleByPtyId long before mobile subscribes). Edge cases where the renderer never observed a title — fresh shell pre-prompt, pane that mounted but never received bytes — leave lastAgentStatus as null; that's acceptable since lastAgentStatus is documented best-effort and the next live OSC title will populate it through the normal onPtyData path.
Status parity / accepted limitations. Side effect of seeding lastAgentStatus: if the seed sets status to 'idle', the next live OSC byte that re-emits the same 'idle' title will see prevStatus === 'idle' and the existing transition gate (agentStatus === 'idle' && prevStatus !== 'idle') skips. Orchestration tui-idle waiters registered between seeding and the next idle transition will hang until either (a) a non-idle status arrives followed by another idle transition, or (b) the waiter times out. This is an accepted limitation. If it becomes a real problem, future work could split into lastSeededAgentStatus (informational, not gating) vs lastAgentStatus (gates transitions).
Files touched
src/main/runtime/orca-runtime.ts— addheadlessHydrationState,maybeHydrateHeadlessFromRenderer, wire intoonPtyData(with mandatory// DO NOT REORDERcomment per "Ordering invariant"). WidenRuntimePtyController.serializeBuffer?to(ptyId, opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null>and addhasRendererSerializer?(ptyId). AddapplySeededAgentStatus(ptyId, title)helper that writesleaf.lastAgentStatusonly — must NOT callresolveTuiIdleWaitersordeliverPendingMessages(mandatory "Why" comment in the helper definition). AddhasRendererSerializerFor(ptyId)runtime-service-level method that delegates toptyController.hasRendererSerializer(consumed byipc/pty.ts's cooperation gate). ForwardscrollbackRowsfromserializeTerminalBufferFromAvailableStateinto the renderer-fallback call (do NOT passaltScreenForcesZeroRows: trueon this path — see "alt-screen flag scoping").src/main/ipc/pty.ts—requestSerializedBuffer(ptyId, opts?)accepts opts and forwards them on thepty:serializeBuffer:requestIPC payload. WirehasRendererSerializerthrough a renderer-registry getter. Cooperate with hydration: when a renderer serializer is registered for the spawning PTY or the renderer pre-signaled intent forargs.env.ORCA_PANE_KEY, skip seeding fromresult.snapshot/result.coldRestore.scrollbackso the daemon's bare-prompt state cannot pre-empt the richer renderer hydration that follows on first byte. Add three new IPC handlers with consistent paneKey validation (typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256— invalid input is rejected before mutating state):pty:declarePendingPaneSerializer(paneKey): Promise<number>(renderer → main, beforepty:spawn) mints a fresh generationgen, storespendingByPaneKey.set(paneKey, gen)in a module-levelpendingByPaneKey: Map<string, number>, and returnsgento the renderer;pty:settlePaneSerializer(paneKey, gen)(renderer → main, afterregisterPtySerializer) runssettlePending(paneKey, gen)which deletes only if the stored gen still matches;pty:clearPendingPaneSerializer(paneKey, gen)(renderer → main, on spawn-rejection / unmount-before-settle) has the samesettlePendingsemantics. HookpaneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen))so a torn-down PTY's listener captures the generation it was registered under and only clears the map when that generation still matches — ensuring a teardown for the OLD PTY cannot delete a NEW PTY's pending intent if mount-2's pre-signal landed in between. (See "Renderer pre-signal handshake" and "Cooperation with seedHeadlessTerminal" in Open questions.)src/preload/index.ts— extend thepty:serializeBuffer:requestlistener payload type to includeopts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }. Add three new bindings underpty:declarePendingPaneSerializer(paneKey: string): Promise<number>(returns thegentoken),settlePaneSerializer(paneKey: string, gen: number): Promise<void>, andclearPendingPaneSerializer(paneKey: string, gen: number): Promise<void>, all forwarded asipcRenderer.invoke('pty:declarePendingPaneSerializer', …)/'pty:settlePaneSerializer'/'pty:clearPendingPaneSerializer'so the renderer canawaitthem and rely on main-side ordering before issuingpty:spawn/ afterregisterPtySerializer. The renderer capturesgenfrom declare and threads it through every settle/clear call.src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts— widenSerializeFnto(opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …; default to today's full-buffer behavior when opts is absent. ConvertserializersByPtyId: Map<ptyId, SerializeFn>toMap<ptyId, { fn: SerializeFn, owner: symbol }>and haveregisterPtySerializermint a fresh owner symbol per call, returning an unregister closure that deletes only whencurrent?.owner === thisOwner(StrictMode safety — see "Renderer-side prerequisite"). Add a parallellastTitleByPtyId: Map<ptyId, { title: string, owner: symbol, disposable: IDisposable }>populated by a wrapper aroundpane.terminal.onTitleChange, with the unregister closure both disposing the IDisposable AND deleting the entry under the same owner-token check. The serialize listener reads fromlastTitleByPtyIdto populatelastTitlein the IPC response.src/renderer/src/components/terminal-pane/pty-connection.ts— register the serializer for fresh spawns as well as reattach. Extract the registration body into a sharedregisterPaneSerializerFor(ptyId)helper called from bothhandleReattachResultAND the post-spawn callback instartFreshSpawn. Thread opts (scrollback cap, alt-screen flag) intopane.serializeAddon.serialize. Addif (disposed) returnguard at the top of every post-spawn registration call (StrictMode safety — see "Renderer-side prerequisite"). Track the latestpane.terminal.onTitleChangevalue in module state alongside the serializer registration so the serializer's IPC response can includelastTitle(see "Status parity"). Callconst gen = await window.api.pty.declarePendingPaneSerializer(paneKey)BEFOREpty:spawnon every fresh-spawn path (capturing the returned generation token), andsettlePaneSerializer(paneKey, gen)AFTERregisterPaneSerializerFor(ptyId)succeeds. On spawn-rejection (catch-arm) AND on pane unmount AFTER pre-signal but BEFORE settle, callclearPendingPaneSerializer(paneKey, gen)— this is mandatory (see "Renderer-side prerequisite" item 3). Unregister on pane unmount via the existingonDataDisposable.disposehook (now using the ownership-token contract from "Renderer-side prerequisite").src/main/runtime/scrollback-limits.ts(new) — main-process module exportingMOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000. Imported by bothsrc/main/runtime/rpc/methods/terminal.tsandsrc/main/runtime/orca-runtime.ts. (The constant is main-process-only; promoting it tosrc/shared/would expose a runtime cap to the renderer for no benefit.)src/main/runtime/orca-runtime.test.ts— add hydration unit tests (see "Test plan").src/main/runtime/mobile-subscribe-integration.test.ts— extend with cross-process hydration scenario.
No changes to:
pty:databatching, daemon adapter.serializeTerminalBufferFromAvailableStatepriority order (headless still primary; renderer still no-headless fallback).- Mobile RPC method signatures (
terminal.subscribe,terminal.resizeForClient). - Mobile WebView /
TerminalWebView.tsx. - Persistence (
HistoryManager,checkpoint.jsonshape).
Why this is structurally sound
The renderer xterm is, in practice, the fuller and more authoritative state for any mounted pane:
-
It hydrates on attach.
pty-connection.tscallsreplayIntoTerminal(pane, …, coldRestore.scrollback)and writessnapshotimmediately after pane mount. The headless emulator never replays either. So even on a session that started before the current Orca run, the renderer xterm contains the full history; the headless emulator does not. -
It has a bigger buffer. 50,000 rows vs 5,000.
-
It is updated on the same IPC tick as the visible UI. No staleness vs what the user sees.
Hydrating the headless emulator from the renderer on first runtime touch is the same shape of operation as the existing seedHeadlessTerminal(provider.spawn snapshot) path. We are not introducing a new architectural pattern; we are extending an existing one to the case where the daemon snapshot is empty but the renderer is rich. The runtime remains authoritative for everything mobile, CLI, terminal.read, and lastAgentStatus see — the renderer is consulted exactly once per (ptyId, runtime) pair.
This is a structural-adjacent improvement that fixes the dominant case (user has both desktop and mobile open) while consciously deferring Scenario A (mobile-only after relaunch) to a separate daemon-persistence design. It is honest about what it does and does not solve.
Data flow paths
Happy path — renderer mounted, hydration succeeds
T0 user opens desktop pane.
- Fresh spawn: pty-connection.ts calls
const gen = await pty:declarePendingPaneSerializer(paneKey)
BEFORE pty:spawn, then awaits both. Main mints a fresh gen and
records the pending intent under it. After spawn resolves with
ptyId, registerPaneSerializerFor(ptyId) +
pty:settlePaneSerializer(paneKey, gen) clear the intent (only if
the stored gen still matches). On spawn-rejection or unmount-
before-settle, pty:clearPendingPaneSerializer(paneKey, gen) runs
instead (mandatory — see "Renderer-side prerequisite" item 3).
- Reattach: registerPaneSerializerFor(ptyId) runs in
handleReattachResult once the ptyId is known.
Either way, ipc/pty.ts's cooperation gate sees that the renderer
is or will be authoritative for this PTY (via either
hasRendererSerializerFor(ptyId) === true OR a pre-signaled paneKey)
and SKIPS the daemon-snapshot seedHeadlessTerminal call.
T1 PTY emits live byte → ipc/pty.ts forwards to renderer (xterm grows)
→ ipc/pty.ts forwards to runtime.onPtyData
T2 onPtyData: maybeHydrateHeadlessFromRenderer
- hasRendererSerializer(ptyId) === true → state: absent → pending
- serialize round-trip with opts {scrollbackRows, altScreenForcesZeroRows}
- on resolve: HeadlessEmulator built at rendered.cols/rows,
applySeededAgentStatus(ptyId, rendered.lastTitle), write(rendered.data)
- resize emulator to PTY dims if they diverge from rendered dims
T3 trackHeadlessTerminalData(data) chains AFTER the seed via writeChain,
so the live byte is appended on top of the seeded buffer — order
preserved.
T4 hydration state → done. Subsequent onPtyData calls skip the guard.
T5 user opens mobile → terminal.subscribe → serializeHeadless returns
seeded buffer + everything since. Mobile == desktop, and
leaf.lastAgentStatus is populated from rendered.lastTitle
(sourced from pane.terminal.onTitleChange, NOT from the seed payload —
SerializeAddon does not round-trip OSC titles).
Nil path — Scenario A: no renderer ever mounts
T0 user relaunches Orca, opens mobile only.
T1 ipc/pty.ts: provider.spawn returns coldRestore.scrollback = ""
(daemon checkpoint had scrollbackAnsi: "" — visible screen only)
or a thin visible-screen snapshot. hasRendererSerializerFor(ptyId)
is false (no pane mounted), so the cooperation gate does NOT skip:
seedHeadlessTerminal runs as today.
T2 PTY emits live byte → onPtyData → maybeHydrateHeadlessFromRenderer
- hasRendererSerializer(ptyId) is false → state map left absent.
(Pane could still mount later in this runtime; we'd retry then.)
T3 trackHeadlessTerminalData populates from live bytes; emulator
already contains the daemon-snapshot seed.
T4 Mobile sees daemon's visible-screen snapshot + live bytes since.
KNOWN LIMITATION; out of scope for this design.
Error path — renderer throws or unmounts mid-hydration
T0–T1 same as happy path through `pending` state.
T2 ptyController.serializeBuffer rejects (renderer disposed,
channel closed, IPC timeout, etc.).
- catch swallows the error.
- hydration state → done (we do not loop on every byte).
T3 Live bytes continue to populate the emulator via
trackHeadlessTerminalData (which lazily creates the headless
state at PTY dims). We lose the seed for this runtime but do
not block live data flow.
The "advance to done on serialize failure" rule is intentional. Re-trying on every subsequent byte would create a renderer round-trip on every byte until success — too costly for a best-effort path. The "leave state absent when the renderer isn't ready yet" rule (the precondition checks before pending) is the inverse trade-off: those checks are cheap (Map lookups), so retrying them is fine.
SSH note
SSH-backed PTYs flow through the same runtime.onPtyData entry point as local and daemon-backed PTYs (see ipc/pty.ts provider listener — all providers call runtime.onPtyData(id, data, at) uniformly). Hydration applies to SSH PTYs without special-casing: when a desktop pane is mounted on an SSH-backed terminal, its registerPtySerializer registration is identical to the local case, and the runtime sees hasRendererSerializer(ptyId) === true regardless of provider. The mobile + SSH + relaunch case (rare in practice) inherits Scenario A's known-limitation behavior.
Concurrent path — live data arrives during hydration
The renderer round-trip is asynchronous; live pty:data will continue to arrive before serializeBuffer resolves. The pattern from seedHeadlessTerminal already handles this: every emulator write is appended to state.writeChain, so:
writeChain: Promise.resolve()
→ seed write ← scheduled by maybeHydrateHeadlessFromRenderer
→ live byte 1 ← scheduled by trackHeadlessTerminalData
→ live byte 2
→ live byte N
emulator.write is invoked in chain order, preserving the byte sequence exactly as the wire delivered it. The seed always lands before the live bytes that triggered hydration, because the trigger logic schedules the seed write before it returns control to onPtyData, which then schedules the live write.
Performance
ptyController.serializeBuffer(ptyId) does an IPC round-trip to the renderer where xterm's SerializeAddon runs synchronously over the buffer. Cost is roughly proportional to scrollback size: empirically ~50–200 ms for a full 50k-row buffer.
The cost profile is materially different from the rejected priority-flip approach:
| Approach | Renderer round-trip frequency |
|---|---|
| Priority flip (rejected) | Once per terminal.subscribe AND terminal.resizeForClient |
| Hydrate-on-first-touch (this) | Once per (ptyId, runtime) pair, ever |
For a typical session — Orca starts, user opens desktop pane, agent runs for hours, user opens mobile periodically — this approach pays the IPC cost exactly once for hydration per (ptyId, runtime) pair. The existing renderer-fallback inside serializeTerminalBufferFromAvailableState is preserved — it can still hit the renderer on terminal.read / terminal.subscribe when the headless emulator returns null — but that path is the read-time fallback, not the hydration path. The priority-flip approach paid the IPC cost on every mobile re-subscribe (foreground/background, navigation between worktrees, phone-fit toggle). The "first byte after pane mount" trigger ties the hydration cost to a moment that is already doing IO work, so the latency is hidden inside the existing flow.
There is no per-keystroke cost; trackHeadlessTerminalData is unchanged.
Reflow asymmetry
Hydration happens at whatever cols the renderer is currently at — typically the desktop's natural size (e.g., 105 cols). When mobile later subscribes and triggers a 49-col phone-fit resize, the headless emulator (now seeded with 105-col data) reflows to 49 cols just like the renderer would. Both buffers reflow from the same starting content, so they stay in sync.
This is a real improvement over the priority-flip proposal, which was sensitive to the order in which renderer and headless reflow completed across the IPC tick boundary. With hydrate-on-mount the seed has already landed before any phone-fit resize is even requested, so the reflow asymmetry concerns largely go away: at subscribe time both buffers contain the same content and respond to the same resize event in the same handleMobileSubscribe synchronous block.
Risks
-
Live data arrives during pending hydration. Mitigated by chaining through
state.writeChain, exactly asseedHeadlessTerminalalready does. Order is preserved. -
Renderer pane unmount mid-round-trip. Caught by
try/catch; the guard advances todoneso we don't loop on every subsequent byte, and live writes continue via lazy emulator creation intrackHeadlessTerminalData. We lose the seed for that PTY, which is acceptable given how rare unmount-during-IPC is. -
Wire-payload size on hydration. Capped at 1000 rows from day one via the shared
MOBILE_SUBSCRIBE_SCROLLBACK_ROWSconstant. Alt-screen forces 0 rows. This matches the existing serialize path's behavior, so users see identical depth from both sources. The headless emulator's own 5,000-rowDEFAULT_SCROLLBACK(inHeadlessEmulator) is comfortably above the 1k hydration cap; if a future change raises the hydration cap,HeadlessEmulator's default must be bumped in lockstep or the seed will be silently truncated as it lands in the emulator. -
Renderer xterm is behind the runtime. Theoretically possible if
pty:datais delivered to the runtime before the renderer (depends on dispatch order inipc/pty.ts). The renderer's xterm uses an asynchronouswrite(callback), so even when bytes are delivered the buffer may not yet contain them. In practice this is sub-millisecond and the seed will be at most one frame behind the live byte that triggered it — better than today's headless-only path which has none of those bytes. -
Hydration races registerPty. If
onPtyDatafires beforeruntime.registerPtyhas been called for this PTY,getTerminalSizemay return null and we default to 80×24 for the post-seed PTY dims. The seed itself is constructed at the renderer's reported dims (see Pseudocode), so xterm reflow only happens on the post-seedresizecall — at most one reflow, and only when PTY and renderer dims actually diverge. -
PTY-size vs renderer-dim divergence (resize-after-seed reflow). Under mobile-fit overrides, or in the brief window before a
pty:resizelands on the main process, the renderer's xterm dims and the runtime'sptySizesentry can disagree. Constructing the headless emulator at PTY-size and then writing a seed captured at renderer-dim would force xterm to wrap/reflow the seed in the wrong column count — visible as broken line breaks or doubled prompts. Mitigation: build the emulator atrendered.cols / rendered.rows, then resize to PTY-size after the seed lands. See Pseudocode. At extreme size deltas (e.g., 105→32), the seed-then-reflow path can produce minor visible divergence vs the renderer's own reflow (xterm reflow is not strictly invertible across very different widths, and the headless emulator runs the reflow on already-laid-out cells rather than the original byte stream). This is an accepted limitation, not a correctness issue: the reflow result is still valid xterm output, just potentially line-broken slightly differently than what the desktop pane shows for the same scrollback. Mobile users have always seen a phone-fit reflow distinct from desktop's; this design does not change that. -
Note: writes are flushed before serialize. The renderer-side serializer (
pty-buffer-serializer.ts:registerPtySerializer) flushespendingWritesRefviareplayIntoTerminalAsyncbefore callingpane.serializeAddon.serialize(), so the seed faithfully includes hidden-pane buffered writes. No additional sequencing logic is needed in the runtime. -
pendingWritesRefdouble-write window (severity-adjusted P2). When a pane is hidden at the moment bytes arrive, the renderer-sidependingWritesRefbuffers them whileruntime.onPtyDatasimultaneously routes the same bytes into the headless emulator viatrackHeadlessTerminalData. The renderer's serializer flushespendingWritesRefbefore serializing, so the seed payload includes those bytes. If hydration runs aftertrackHeadlessTerminalDatahas already written the same bytes, the headless emulator ends up with a duplicated stretch.Mitigation: the P0-A2 pre-signal handshake makes this rare in practice. With pre-signal, hydration is the FIRST writer for a fresh-spawn-with-pre-signal —
trackHeadlessTerminalData's lazy-create check sees the emulator already exists (created at hydration time) and just appends, so live bytes between seed-fetch and seed-resolve are queued behind the seed viastate.writeChain. The double-write only manifests in the narrow combo of: (a) reattach path, (b) hidden pane buffering live bytes intopendingWritesRef, AND (c) hydration runs aftertrackHeadlessTerminalDatahas already populated the headless emulator. The impact is bounded: at most one ~8 ms PTY-batch's worth of bytes can be duplicated, the duplicate is visible only in mobile/CLI scrollback (desktop renders from its own xterm which never duplicates), and a future shell prompt overwrites the area anyway. Document as a known limitation; not a correctness-blocking issue. If it becomes user-visible, the fix is to track a per-pty "live bytes seen since last serialize request" cursor on the renderer and trim the seed payload to bytes the renderer observed before hydration started — out of scope for this design.
Test plan
Unit (orca-runtime.test.ts)
- Hydration runs on the first
onPtyDatafor aptyIdwhenhasRendererSerializeris true andserializeBufferreturns non-empty data. Assert the emulator's serialized output contains both the seeded prefix and the live byte. - Subsequent
onPtyDatacalls do not re-trigger hydration after a successful run. MockserializeBufferand assert it was called exactly once across NonPtyDatainvocations for the same ptyId. - "Exactly once" claim is qualified: the renderer round-trip happens once per
(ptyId, runtime)for hydration. The existingserializeTerminalBufferFromAvailableStaterenderer-fallback (consulted only when the headless emulator returns null) is preserved and may still hit the renderer onterminal.read/terminal.subscribepaths. - When
ptyControlleris null (CLI / headless test runs), hydration leaves the state map untouched, headless emulator is created lazily as today, no IPC attempt. - When
hasRendererSerializer(ptyId)returns false on the first byte but true on a later byte, hydration does run on the later byte (state is left absent on idle, not consumed). Confirms the contract change for Open Question P2-2. - When
ptyController.serializeBufferrejects, the guard advances todone(no infinite retry), the emulator is created lazily bytrackHeadlessTerminalData, live bytes still write through. - Live data arriving during pending hydration is queued behind the seed write. Schedule the seed-write as a long pending promise, fire several
onPtyDatacalls, awaitwriteChain, assert the final emulator content has the seed prefix followed by the live bytes in correct order. - The 1000-row cap is applied to the renderer-sourced hydration: mock
serializeBufferto return a payload representing >1000 rows, assert the requested option bag includesscrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS. - Alt-screen flag is forwarded: assert the request includes
altScreenForcesZeroRows: true. (The alt-screen branch is exercised in renderer-side serializer tests where it has access to a real xtermbuffer.active.type.) - Status parity (lastTitle field): mock
serializeBufferto return{ data, cols, rows, lastTitle }wherelastTitleis a recognizable agent title. FireonPtyData. Assertleaf.lastAgentStatusis one of'idle' | 'working' | 'permission'(whateverdetectAgentStatusFromTitlereturns for that title) after hydration and before any live OSC title bytes are observed. Conversely, whenlastTitleis absent / undefined ORdetectAgentStatusFromTitle(lastTitle)returnsnull(unrecognizable title), assertleaf.lastAgentStatusis left unchanged from its prior value (no overwrite). - Multi-PTY concurrent hydration: fire
onPtyDatafor ptyA and ptyB before eitherserializeBufferresolves. Assert each PTY's hydration completes with its own payload (no cross-talk through the IPC response listener pool — each request has a uniquerequestId). - Re-entrant
onPtyData: whenagentDetector.onDatasynchronously triggers anotheronPtyDatafor the same ptyId (or a different ptyId), confirm the hydration state machine still holds — no double-pending, no skipped seed, no out-of-order emulator writes. - Cooperation with
seedHeadlessTerminal: when a renderer serializer is registered forptyId, the spawn-time call fromipc/pty.tsskips seeding (no daemon-snapshot bytes written to the emulator). When no serializer is registered, the daemon-snapshot path runs as today. - Fresh-spawn timing race (P0-A2 pre-signal handshake): simulate the renderer pre-signaling intent for paneKey via
const gen = await pty:declarePendingPaneSerializer(paneKey), then call thepty:spawnhandler withargs.env.ORCA_PANE_KEY === paneKey. AssertseedHeadlessTerminalis NOT invoked even thoughhasRendererSerializerFor(result.id)is false at that moment (the renderer hasn't received the ptyId yet). The renderer mock thenregisterPtySerializer(ptyId, …)andpty:settlePaneSerializer(paneKey, gen). FireonPtyData(ptyId, liveByte). AssertmaybeHydrateHeadlessFromRendererruns and the headless emulator is populated from the renderer's serialize payload, with the live byte appended after the seed viawriteChain. - Pre-signal without settle (renderer crash): simulate
pty:declarePendingPaneSerializer(paneKey)followed bypty:spawn(gate skips daemon seed) but the renderer never callsregisterPtySerializerorsettlePaneSerializer. FireonPtyData. Assert hydration's preconditions leave the state map absent,trackHeadlessTerminalDatalazy-creates the emulator, and live bytes populate it. The pending-paneKey entry is dropped on the next paneKey teardown event. - Pre-signal preserved across paneKey reuse during teardown: simulate (a) old PTY for paneKey K is exiting, its
paneKeyTeardownListeneris queued (captured at registration time withgen1). (b) Mount-2 callspty:declarePendingPaneSerializer(K)and stores generationgen2(gen2 > gen1). (c) The queued teardown for the old PTY runs; the listener invokessettlePending(K, gen1)and SKIPS the delete becausependingByPaneKey.get(K) === gen2. (d) Mount-2 callspty:spawn; the cooperation gate matches (pendingByPaneKey.has(K) === true) and seed is suppressed. Assert daemon-snapshot seed did NOT run. - Test imports for
scrollback-limits.ts: tests inorca-runtime.test.tsandmobile-subscribe-integration.test.tsimportMOBILE_SUBSCRIBE_SCROLLBACK_ROWSfrom../scrollback-limitsso cap-assertion lines match the production constant (no hardcoded1000). - Status parity (real
SerializeAddonround-trip): in a renderer-side unit test (using a realxtermTerminal+SerializeAddon), write OSC 0/1/2 title bytes (e.g.,\x1b]0;some-title\x07) into the terminal, await theonTitleChangelistener, then callserializeAddon.serialize(). Assert the serialize output does NOT contain the OSC title bytes — this codifies the limitation that motivated thelastTitlefield. Then, in a runtime-side unit test, mock the renderer-IPC response to include{ data, cols, rows, lastTitle }wherelastTitleis a known agent title, fireonPtyData, and assertapplySeededAgentStatuswas called with that title andleaf.lastAgentStatusis the corresponding'idle' | 'working' | 'permission'value returned bydetectAgentStatusFromTitleafterward. applySeededAgentStatusdoes not resolve waiters: with a leaf that has pending TUI-idle waiters and pending orchestration messages registered, callapplySeededAgentStatus(ptyId, title)wheretitleis a recognizable agent title. Assertleaf.lastAgentStatusmatchesdetectAgentStatusFromTitle(title)(one of'idle' | 'working' | 'permission'), AND assertresolveTuiIdleWaitersanddeliverPendingMessageswere NOT invoked. (Spy on the methods or assert the waiter / pending-message queues are unchanged.) This guards against future drift where a refactor lifts shared logic between the live and seeded paths.
Integration (mobile-subscribe-integration.test.ts)
- Cross-process scenario with a mocked renderer that registers a richer scrollback than the headless adapter would produce. After the runtime sees its first live byte, mobile subscribes; assert the
scrollbackframe'sserializedfield includes the seeded prefix. - Scenario A regression guard: no renderer registered, mobile subscribes, assert mobile receives only what the daemon snapshot + live bytes provide, hydration was never invoked. (Documents the known limitation.)
Manual
On a real device, reproduce the original edit-issues post-claude-exit scenario, confirm the mobile WebView now shows the same agent summary the desktop shows.
Open questions
-
Hydration trigger point. Three plausible options: (a) first
onPtyData, (b)runtime.registerPty, (c) an explicit renderer-pane-mount notification from the renderer. (a) is the chosen approach: cheap, ties cost to a moment already paying IO, and retryable within a runtime. The state map is left absent (notdoneorskipped) when the renderer serializer isn't yet registered, so a later byte can attempt hydration once the pane mounts. The only consume-on-idle case isheadlessTerminals.has(ptyId)(live bytes already arrived), where re-seeding would duplicate. (b) fires before the renderer has any data to serialize. (c) needs a new wire frompty-connection.ts → ipc/pty.ts → runtime. -
Cooperation with
seedHeadlessTerminal. TodayseedHeadlessTerminalwrites to the headless emulator onprovider.spawnfromresult.snapshot/result.coldRestore.scrollback. The daemon'sresult.snapshotis the visible-screen-only ANSI fromcheckpoint.json— almost always non-empty (it includes the bare prompt) but typically much thinner than what the renderer pane has. If both fire for the same PTY, the daemon path lands first andheadlessTerminals.has(ptyId)becomes true; the hydration guard would then mark itselfdoneand the richer renderer payload is lost. Decision: gateseedHeadlessTerminalon (a) an already-registered renderer serializer OR (b) a renderer pre-signal for the spawning PTY's paneKey. Inipc/pty.ts, before callingruntime.seedHeadlessTerminal(...), check bothruntime.hasRendererSerializerFor(result.id)ANDpendingByPaneKey.has(args.env?.ORCA_PANE_KEY)(after validating the paneKey shape — see P1-B2 in the cooperation gate snippet). If either is true, skip the daemon-snapshot seed entirely and let renderer hydration be canonical. (a) covers reattach, where the pane mounted before spawn returned. (b) covers fresh spawn, where the renderer pre-signaled intent beforepty:spawnbut cannot have registered yet because it doesn't know the ptyId. If neither is true (Scenario A — mobile-only, no desktop pane), the daemon-snapshot path runs unchanged. See "Renderer pre-signal handshake" for the full sequence and correctness properties. This is cleaner than a length-comparison "richness heuristic" and lets the renderer be authoritative whenever it's mounted or about to mount.
Rollout
Single change touching the runtime and the renderer-side serialize controller. No feature flag — the hydration is best-effort and degrades to today's behavior when the renderer cannot answer or has nothing useful to share.
Ready for implementation review. Scenario A remains a known limitation tracked for a future daemon-persistence design.