orca/docs/mobile-prefer-renderer-scro...

72 KiB
Raw Blame History

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:

  1. Renderer xterm — owned by TerminalPane in the renderer process. Created when the user mounts the pane. Listens to pty:data IPC for the lifetime of the pane. 50,000-row scrollback. Source of truth for the visible desktop terminal.

  2. Runtime headless emulatorHeadlessEmulator in src/main/runtime/orca-runtime.ts. Created lazily on first runtime.onPtyData(ptyId, …) call. 5,000-row scrollback. Used to serve terminal.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) in pty-connection.ts, plus all live bytes since.
  • The headless emulator only sees bytes that flow through runtime.onPtyData since 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 8a5ea4b7 added seedHeadlessTerminal(ptyId, data, size?) which runs from ipc/pty.ts on provider.spawn and seeds the emulator from result.snapshot / result.coldRestore.scrollback. That helps daemon-restored sessions but does not help when the daemon's persisted state is itself near-empty — the edit-issues reproduction has checkpoint.json with scrollbackAnsi: "" 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 — the pty:serializeBuffer:request payload extends to { requestId, ptyId, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } }. Renderer-side type for the listener callback widens accordingly.
  • src/main/ipc/pty.tsrequestSerializedBuffer(ptyId, opts?) accepts the opts object and forwards it on the IPC webContents.send payload. Snapshot-shape validation widens to accept an optional lastTitle: string. Existing 750 ms timeout unchanged.
  • src/main/runtime/orca-runtime.ts — the RuntimePtyController.serializeBuffer? signature widens to (ptyId, opts?) => Promise<{ data: string, cols: number, rows: number, lastTitle?: string } | null>. A new optional hasRendererSerializer?(ptyId): boolean lets the runtime check pane mount status before paying the IPC cost (implementation: a getter against serializersByPtyId in pty-buffer-serializer.ts).
  • src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts — the registered SerializeFn widens to (opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …. The pane's serializer reads the opts: if altScreenForcesZeroRows && pane.terminal.buffer.active.type === 'alternate', call pane.serializeAddon.serialize({ scrollback: 0 }); otherwise pane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows }). The response payload includes lastTitle: 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 in orca-runtime.ts:843) forwards scrollbackRows so the fallback's depth matches the primary path. It does NOT pass altScreenForcesZeroRows: 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.subscribe for 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 registerPtySerializer call so it fires for both reattach and fresh spawn paths. Cleanest shape: extract the body (closure capturing pane, deps.pendingWritesRef, deps.replayingPanesRef) into a registerPaneSerializerFor(ptyId) helper and call it from handleReattachResult AND from startFreshSpawn's .then(spawnedPtyId => …) once a ptyId is known.
  • The helper accepts the new opts arg and threads it into pane.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:

  1. if (disposed) return guard 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 its pty:spawn IPC may have already resolved by the time disposed flips to true. Without the guard, the disposed first mount would call registerPaneSerializerFor after teardown — replacing the live registration from the second mount or registering a closure over a torn-down pane.terminal.

  2. Unregister via ownership token, not unconditional delete. serializersByPtyId becomes Map<ptyId, { fn: SerializeFn, owner: symbol }>. Each call to registerPtySerializer mints a fresh owner symbol and returns an unregister closure that only deletes the entry when the current entry's owner === 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 xterm onTitleChange IDisposable 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)
        }
      }
    }
    
  3. Spawn-rejection cleanup is mandatory. The renderer MUST call pty:clearPendingPaneSerializer(paneKey, gen) from the catch-arm of pty:spawn AND 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.

  4. Pre-signal applies to BOTH fresh-spawn AND reattach paths. declarePendingPaneSerializer(paneKey) is mandatory on both — mirroring the registerPaneSerializerFor(ptyId) requirement. Reattach paths today already register the serializer in handleReattachResult, but the pre-signal must also fire on the reattach path so the cooperation gate in pty:spawn behaves 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:

  1. Pre-signal IPC with generation token. Renderer calls pty:declarePendingPaneSerializer(paneKey) BEFORE invoking pty:spawn. Main process maintains pendingByPaneKey: Map<paneKey, generation: number> keyed by paneKey. On each pre-signal, main mints a fresh monotonic gen value (incrementing a module-level genSeq counter) and overwrites the entry, then returns gen to the renderer. The renderer awaits this IPC (capturing gen), then awaits the spawn IPC — guaranteeing the main process has the pending intent recorded by the time pty:spawn runs.

    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) into clearProviderPtyState via paneKeyTeardownListeners. If the OLD PTY's clearProviderPtyState fires 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: the paneKeyTeardownListeners wiring captures the generation at the time the old PTY was REGISTERED (via the existing registerPty call site) and the teardown only fires settlePending with that generation. If mount-2 has already replaced the entry with a new gen, pendingByPaneKey.get(K) !== oldGen and the teardown is a no-op.

  2. Cooperation gate consults the pre-signal. Inside pty:spawn, after provider.spawn returns, 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 rendererAlreadyRegistered branch covers reattach, where the pane mounted before spawn returned and registration completed in the meantime. The rendererPreSignaled branch covers fresh spawn, where registration has not happened yet but is imminent. Note: the gate matches when pendingByPaneKey.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 received ptyId yet); the registered branch only fires for reattach where the pane mounted before spawn returned.

  3. Settle / clear (echo gen back). After the spawn IPC resolves and the renderer calls registerPtySerializer(ptyId, …), it calls pty:settlePaneSerializer(paneKey, gen) to remove the pending intent (echoing the gen value it captured from declarePending). The handler runs settlePending(paneKey, gen) which only deletes if the stored gen still matches. (The simpler "clear on first onPtyData" or "clear on receipt of pty:registerPaneSerializer" alternatives work too — the design picks the explicit settle IPC 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 calls pty:clearPendingPaneSerializer(paneKey, gen) (sibling handler with the same settlePending semantics) 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:

  1. Pre-signal arrives before pty:spawn. The renderer awaits the pre-signal IPC, then awaits the spawn IPC. Electron ipcRenderer.invoke/send ordering on a single renderer is preserved on the main side, so the main process processes the pre-signal before pty:spawn's handler runs.
  2. Missing paneKey degrades to today's behavior. If args.env?.ORCA_PANE_KEY is 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, rendererAlreadyRegistered is 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.
  3. 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 onPtyData falls through hasRendererSerializer(ptyId) === false and hydration's precondition leaves the state map absent. trackHeadlessTerminalData then 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 in pendingByPaneKey is cleared whenever a paneKey teardown event fires via the existing paneKeyTeardownListeners registry, 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:

  1. Renderer side (pty-buffer-serializer.ts):

    • Module-level lastTitleByPtyId: Map<ptyId, { title: string, owner: symbol, disposable: IDisposable }> keyed by the same ownership-token symbol as serializersByPtyId (see "Renderer-side prerequisite"). Populated by a wrapper around pane.terminal.onTitleChange installed at the same time as registerPtySerializer. The entry stores BOTH the IDisposable returned from pane.terminal.onTitleChange(...) AND the owner symbol alongside the latest title.
    • When the pane unregisters, the unregisterSerializer closure 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).
  2. Main side (ipc/pty.ts, orca-runtime.ts):

    • requestSerializedBuffer snapshot-shape validation accepts lastTitle?: string.
    • RuntimePtyController.serializeBuffer(ptyId, opts?) returns Promise<{ data, cols, rows, lastTitle?: string } | null>.
    • maybeHydrateHeadlessFromRenderer calls applySeededAgentStatus(ptyId, rendered.lastTitle) only when rendered.lastTitle is a non-empty string (the helper itself also short-circuits on null/empty for safety). The helper internally runs detectAgentStatusFromTitle and only writes leaf.lastAgentStatus when the result is one of 'idle' | 'working' | 'permission'.
  3. applySeededAgentStatus(ptyId, title) (new helper in orca-runtime.ts). Behavior:

    • If title == null or empty, no-op.
    • Look up the leaf for ptyId via the existing pty→leaf mapping. Run detectAgentStatusFromTitle(rendered.lastTitle) (the helper from src/shared/agent-detection.ts). If the result is non-null, set leaf.lastAgentStatus = result. If null (title is not a recognizable agent title), leave lastAgentStatus untouched — the next live OSC byte will populate it through the normal onPtyData path.
    • MUST NOT call resolveTuiIdleWaiters(leaf) or deliverPendingMessages(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 (onPtyData already uses detectAgentStatusFromTitle) so seeded and live paths produce the same union value, and downstream leaf.lastAgentStatus === 'idle' consumers (orchestration tui-idle waiters, pending-message gating) keep working. leaf.lastAgentStatus is typed AgentStatus | null where AgentStatus = 'working' | 'idle' | 'permission'; assigning the raw title string would be type-incorrect.

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 — add headlessHydrationState, maybeHydrateHeadlessFromRenderer, wire into onPtyData (with mandatory // DO NOT REORDER comment per "Ordering invariant"). Widen RuntimePtyController.serializeBuffer? to (ptyId, opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null> and add hasRendererSerializer?(ptyId). Add applySeededAgentStatus(ptyId, title) helper that writes leaf.lastAgentStatus only — must NOT call resolveTuiIdleWaiters or deliverPendingMessages (mandatory "Why" comment in the helper definition). Add hasRendererSerializerFor(ptyId) runtime-service-level method that delegates to ptyController.hasRendererSerializer (consumed by ipc/pty.ts's cooperation gate). Forward scrollbackRows from serializeTerminalBufferFromAvailableState into the renderer-fallback call (do NOT pass altScreenForcesZeroRows: true on this path — see "alt-screen flag scoping").
  • src/main/ipc/pty.tsrequestSerializedBuffer(ptyId, opts?) accepts opts and forwards them on the pty:serializeBuffer:request IPC payload. Wire hasRendererSerializer through a renderer-registry getter. Cooperate with hydration: when a renderer serializer is registered for the spawning PTY or the renderer pre-signaled intent for args.env.ORCA_PANE_KEY, skip seeding from result.snapshot / result.coldRestore.scrollback so 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, before pty:spawn) mints a fresh generation gen, stores pendingByPaneKey.set(paneKey, gen) in a module-level pendingByPaneKey: Map<string, number>, and returns gen to the renderer; pty:settlePaneSerializer(paneKey, gen) (renderer → main, after registerPtySerializer) runs settlePending(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 same settlePending semantics. Hook paneKeyTeardownListeners.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 the pty:serializeBuffer:request listener payload type to include opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }. Add three new bindings under pty: declarePendingPaneSerializer(paneKey: string): Promise<number> (returns the gen token), settlePaneSerializer(paneKey: string, gen: number): Promise<void>, and clearPendingPaneSerializer(paneKey: string, gen: number): Promise<void>, all forwarded as ipcRenderer.invoke('pty:declarePendingPaneSerializer', …) / 'pty:settlePaneSerializer' / 'pty:clearPendingPaneSerializer' so the renderer can await them and rely on main-side ordering before issuing pty:spawn / after registerPtySerializer. The renderer captures gen from declare and threads it through every settle/clear call.
  • src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts — widen SerializeFn to (opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …; default to today's full-buffer behavior when opts is absent. Convert serializersByPtyId: Map<ptyId, SerializeFn> to Map<ptyId, { fn: SerializeFn, owner: symbol }> and have registerPtySerializer mint a fresh owner symbol per call, returning an unregister closure that deletes only when current?.owner === thisOwner (StrictMode safety — see "Renderer-side prerequisite"). Add a parallel lastTitleByPtyId: Map<ptyId, { title: string, owner: symbol, disposable: IDisposable }> populated by a wrapper around pane.terminal.onTitleChange, with the unregister closure both disposing the IDisposable AND deleting the entry under the same owner-token check. The serialize listener reads from lastTitleByPtyId to populate lastTitle in 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 shared registerPaneSerializerFor(ptyId) helper called from both handleReattachResult AND the post-spawn callback in startFreshSpawn. Thread opts (scrollback cap, alt-screen flag) into pane.serializeAddon.serialize. Add if (disposed) return guard at the top of every post-spawn registration call (StrictMode safety — see "Renderer-side prerequisite"). Track the latest pane.terminal.onTitleChange value in module state alongside the serializer registration so the serializer's IPC response can include lastTitle (see "Status parity"). Call const gen = await window.api.pty.declarePendingPaneSerializer(paneKey) BEFORE pty:spawn on every fresh-spawn path (capturing the returned generation token), and settlePaneSerializer(paneKey, gen) AFTER registerPaneSerializerFor(ptyId) succeeds. On spawn-rejection (catch-arm) AND on pane unmount AFTER pre-signal but BEFORE settle, call clearPendingPaneSerializer(paneKey, gen) — this is mandatory (see "Renderer-side prerequisite" item 3). Unregister on pane unmount via the existing onDataDisposable.dispose hook (now using the ownership-token contract from "Renderer-side prerequisite").
  • src/main/runtime/scrollback-limits.ts (new) — main-process module exporting MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000. Imported by both src/main/runtime/rpc/methods/terminal.ts and src/main/runtime/orca-runtime.ts. (The constant is main-process-only; promoting it to src/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:data batching, daemon adapter.
  • serializeTerminalBufferFromAvailableState priority order (headless still primary; renderer still no-headless fallback).
  • Mobile RPC method signatures (terminal.subscribe, terminal.resizeForClient).
  • Mobile WebView / TerminalWebView.tsx.
  • Persistence (HistoryManager, checkpoint.json shape).

Why this is structurally sound

The renderer xterm is, in practice, the fuller and more authoritative state for any mounted pane:

  1. It hydrates on attach. pty-connection.ts calls replayIntoTerminal(pane, …, coldRestore.scrollback) and writes snapshot immediately 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.

  2. It has a bigger buffer. 50,000 rows vs 5,000.

  3. 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

T0T1  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 ~50200 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

  1. Live data arrives during pending hydration. Mitigated by chaining through state.writeChain, exactly as seedHeadlessTerminal already does. Order is preserved.

  2. Renderer pane unmount mid-round-trip. Caught by try/catch; the guard advances to done so we don't loop on every subsequent byte, and live writes continue via lazy emulator creation in trackHeadlessTerminalData. We lose the seed for that PTY, which is acceptable given how rare unmount-during-IPC is.

  3. Wire-payload size on hydration. Capped at 1000 rows from day one via the shared MOBILE_SUBSCRIBE_SCROLLBACK_ROWS constant. 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-row DEFAULT_SCROLLBACK (in HeadlessEmulator) 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.

  4. Renderer xterm is behind the runtime. Theoretically possible if pty:data is delivered to the runtime before the renderer (depends on dispatch order in ipc/pty.ts). The renderer's xterm uses an asynchronous write(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.

  5. Hydration races registerPty. If onPtyData fires before runtime.registerPty has been called for this PTY, getTerminalSize may 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-seed resize call — at most one reflow, and only when PTY and renderer dims actually diverge.

  6. PTY-size vs renderer-dim divergence (resize-after-seed reflow). Under mobile-fit overrides, or in the brief window before a pty:resize lands on the main process, the renderer's xterm dims and the runtime's ptySizes entry 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 at rendered.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.

  7. Note: writes are flushed before serialize. The renderer-side serializer (pty-buffer-serializer.ts:registerPtySerializer) flushes pendingWritesRef via replayIntoTerminalAsync before calling pane.serializeAddon.serialize(), so the seed faithfully includes hidden-pane buffered writes. No additional sequencing logic is needed in the runtime.

  8. pendingWritesRef double-write window (severity-adjusted P2). When a pane is hidden at the moment bytes arrive, the renderer-side pendingWritesRef buffers them while runtime.onPtyData simultaneously routes the same bytes into the headless emulator via trackHeadlessTerminalData. The renderer's serializer flushes pendingWritesRef before serializing, so the seed payload includes those bytes. If hydration runs after trackHeadlessTerminalData has 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 via state.writeChain. The double-write only manifests in the narrow combo of: (a) reattach path, (b) hidden pane buffering live bytes into pendingWritesRef, AND (c) hydration runs after trackHeadlessTerminalData has 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 onPtyData for a ptyId when hasRendererSerializer is true and serializeBuffer returns non-empty data. Assert the emulator's serialized output contains both the seeded prefix and the live byte.
  • Subsequent onPtyData calls do not re-trigger hydration after a successful run. Mock serializeBuffer and assert it was called exactly once across N onPtyData invocations for the same ptyId.
  • "Exactly once" claim is qualified: the renderer round-trip happens once per (ptyId, runtime) for hydration. The existing serializeTerminalBufferFromAvailableState renderer-fallback (consulted only when the headless emulator returns null) is preserved and may still hit the renderer on terminal.read / terminal.subscribe paths.
  • When ptyController is 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.serializeBuffer rejects, the guard advances to done (no infinite retry), the emulator is created lazily by trackHeadlessTerminalData, 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 onPtyData calls, await writeChain, 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 serializeBuffer to return a payload representing >1000 rows, assert the requested option bag includes scrollbackRows: 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 xterm buffer.active.type.)
  • Status parity (lastTitle field): mock serializeBuffer to return { data, cols, rows, lastTitle } where lastTitle is a recognizable agent title. Fire onPtyData. Assert leaf.lastAgentStatus is one of 'idle' | 'working' | 'permission' (whatever detectAgentStatusFromTitle returns for that title) after hydration and before any live OSC title bytes are observed. Conversely, when lastTitle is absent / undefined OR detectAgentStatusFromTitle(lastTitle) returns null (unrecognizable title), assert leaf.lastAgentStatus is left unchanged from its prior value (no overwrite).
  • Multi-PTY concurrent hydration: fire onPtyData for ptyA and ptyB before either serializeBuffer resolves. Assert each PTY's hydration completes with its own payload (no cross-talk through the IPC response listener pool — each request has a unique requestId).
  • Re-entrant onPtyData: when agentDetector.onData synchronously triggers another onPtyData for 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 for ptyId, the spawn-time call from ipc/pty.ts skips 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 the pty:spawn handler with args.env.ORCA_PANE_KEY === paneKey. Assert seedHeadlessTerminal is NOT invoked even though hasRendererSerializerFor(result.id) is false at that moment (the renderer hasn't received the ptyId yet). The renderer mock then registerPtySerializer(ptyId, …) and pty:settlePaneSerializer(paneKey, gen). Fire onPtyData(ptyId, liveByte). Assert maybeHydrateHeadlessFromRenderer runs and the headless emulator is populated from the renderer's serialize payload, with the live byte appended after the seed via writeChain.
  • Pre-signal without settle (renderer crash): simulate pty:declarePendingPaneSerializer(paneKey) followed by pty:spawn (gate skips daemon seed) but the renderer never calls registerPtySerializer or settlePaneSerializer. Fire onPtyData. Assert hydration's preconditions leave the state map absent, trackHeadlessTerminalData lazy-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 paneKeyTeardownListener is queued (captured at registration time with gen1). (b) Mount-2 calls pty:declarePendingPaneSerializer(K) and stores generation gen2 (gen2 > gen1). (c) The queued teardown for the old PTY runs; the listener invokes settlePending(K, gen1) and SKIPS the delete because pendingByPaneKey.get(K) === gen2. (d) Mount-2 calls pty: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 in orca-runtime.test.ts and mobile-subscribe-integration.test.ts import MOBILE_SUBSCRIBE_SCROLLBACK_ROWS from ../scrollback-limits so cap-assertion lines match the production constant (no hardcoded 1000).
  • Status parity (real SerializeAddon round-trip): in a renderer-side unit test (using a real xterm Terminal + SerializeAddon), write OSC 0/1/2 title bytes (e.g., \x1b]0;some-title\x07) into the terminal, await the onTitleChange listener, then call serializeAddon.serialize(). Assert the serialize output does NOT contain the OSC title bytes — this codifies the limitation that motivated the lastTitle field. Then, in a runtime-side unit test, mock the renderer-IPC response to include { data, cols, rows, lastTitle } where lastTitle is a known agent title, fire onPtyData, and assert applySeededAgentStatus was called with that title and leaf.lastAgentStatus is the corresponding 'idle' | 'working' | 'permission' value returned by detectAgentStatusFromTitle afterward.
  • applySeededAgentStatus does not resolve waiters: with a leaf that has pending TUI-idle waiters and pending orchestration messages registered, call applySeededAgentStatus(ptyId, title) where title is a recognizable agent title. Assert leaf.lastAgentStatus matches detectAgentStatusFromTitle(title) (one of 'idle' | 'working' | 'permission'), AND assert resolveTuiIdleWaiters and deliverPendingMessages were 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 scrollback frame's serialized field 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 (not done or skipped) 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 is headlessTerminals.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 from pty-connection.ts → ipc/pty.ts → runtime.

  • Cooperation with seedHeadlessTerminal. Today seedHeadlessTerminal writes to the headless emulator on provider.spawn from result.snapshot / result.coldRestore.scrollback. The daemon's result.snapshot is the visible-screen-only ANSI from checkpoint.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 and headlessTerminals.has(ptyId) becomes true; the hydration guard would then mark itself done and the richer renderer payload is lost. Decision: gate seedHeadlessTerminal on (a) an already-registered renderer serializer OR (b) a renderer pre-signal for the spawning PTY's paneKey. In ipc/pty.ts, before calling runtime.seedHeadlessTerminal(...), check both runtime.hasRendererSerializerFor(result.id) AND pendingByPaneKey.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 before pty:spawn but 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.