orca/docs/mobile-presence-lock.md

33 KiB
Raw Blame History

Mobile Presence Lock for Desktop Terminal

Design doc for repurposing the existing "phone-fit" banner into a general presence-based interaction lock between the desktop renderer and mobile clients sharing a single PTY.

Problem

The desktop and mobile clients share full read/write access to the same underlying PTY. There is no synchronization between desktop's xterm onData (which calls provider.write(ptyId, bytes) via the pty:write IPC) and mobile's terminal.send RPC (which also calls provider.write through the runtime). When both parties type at the same time, bytes interleave at the TTY level — ls<enter> and pwd<enter> typed concurrently can produce lpws<enter>d<enter> and execute unintended commands.

The same is true for resize: desktop pty:resize and mobile terminal.resizeForClient both reach provider.resize(ptyId, ...) without coordination. The renderer-side safeFit has a partial guard, but the pty:resize IPC handler does not check the override server-side.

A teammate using the original mobile build reported "odd behavior using laptop + mobile at the same time." The most visible symptom is the desktop terminal jumping to phone dimensions; the more dangerous symptom is silent input interleaving.

Today's banner

The existing banner in TerminalPane.tsx is keyed on getFitOverrideForPty(ptyId):

  • It only appears when mobile is in auto/phone display mode AND a phone resize actually happened.
  • It says "Terminal resized for phone (W×H)" with a "Restore" button that calls runtime:restoreTerminalFit(ptyId).
  • That IPC handler sets the display mode to desktop and applies the display mode, which resizes the PTY back to desktop dims and clears the override. The banner unmounts.

In desktop display mode (mobile is subscribed but viewing at desktop dims) there is no override and no banner — the desktop user has no idea mobile is watching.

Goal

Repurpose the banner into a general presence-based lock with this single rule:

At any moment, each PTY has exactly one driver — desktop, a specific mobile client, or nobody. While mobile drives, desktop input and resize are dropped; the desktop banner explains why and offers Take back to hand the floor over. While desktop drives, mobile keystrokes silently reclaim the floor (no mobile-side banner — see "Asymmetric UX" below).

The lock exists at two layers:

  • Renderer guard (primary UX): pty-connection.ts drops xterm onData and onResize while a mobile client is the driver. Banner explains why.
  • Server-side defense (defense-in-depth): the pty:write and pty:resize IPC handlers consult the runtime's driver state and drop desktop-side calls while a mobile client is the driver.

Behavioral model

Driver state machine

Instead of a binary lock, each PTY carries a tagged driver state. There is exactly one driver per PTY at any moment, and transitions are atomic (updated and emitted together from the runtime).

type DriverState =
  | { kind: 'idle' }
  | { kind: 'desktop' }
  | { kind: 'mobile'; clientId: string }
  • idle — no mobile subscribers; desktop input and resize flow through normally. (We don't bother distinguishing "no one is here" from "only desktop is here" since they behave identically; idle is the catch-all for "nothing is currently locking the desktop out.")
  • desktop — at least one mobile client is subscribed but the desktop has reclaimed the floor; desktop input and resize flow through.
  • mobile{clientId}clientId is the mobile actor that most recently drove this PTY. The banner is mounted on desktop; desktop input and resize are dropped.

Invariants:

  • currentDriver(ptyId) is always exactly one of the three kinds.
  • On the main process, a transition mutates currentDriver(ptyId) and emits terminalDriverChanged(ptyId, driver) in the same critical section. The renderer's mirror in mobile-driver-state.ts is updated only when the IPC arrives — there is a brief IPC-hop window during which the renderer's getDriverForPty(ptyId) returns a stale value. This is why the server-side pty:write / pty:resize defenses are load-bearing, not redundant: a desktop keystroke fired during that window passes the renderer guard and is dropped by the server guard.
  • Only one driver per PTY at any moment. Multiple mobile clients can subscribe simultaneously (see "Multi-mobile subscriber model"), but only the most recent mobile actor is the driver.

Transitions

Current driver Trigger Next driver Side effect
idle mobile subscribes with displayMode='auto' (first client for this ptyId) mobile{clientId} banner mounts on desktop; PTY resizes to phone dims
idle mobile subscribes with displayMode='desktop' (first client for this ptyId) desktop inner subscriber map populated; no banner; PTY stays at desktop dims
idle desktop input or first PTY data after no subscribers idle (no transition)
mobile{A} desktop clicks Take back desktop banner unmounts; PTY snaps to desktop dims if at phone dims
mobile{A} mobile A sends input/resize/setDisplayMode mobile{A} (no transition)
mobile{A} mobile B sends input mobile{B} (no banner change; both are "mobile")
mobile{A} last mobile client unsubscribes idle banner unmounts
desktop any mobile client sends input/resize mobile{thatClient} banner mounts; PTY snaps to phone dims if that client's mode is auto
desktop mobile sets displayMode to auto or phone mobile{thatClient} banner mounts; PTY snaps to phone dims (deliberate "I want to drive" gesture)
desktop mobile sets displayMode to desktop desktop (no transition) — (already desktop-mode watching)
desktop mobile subscribes-fresh with auto/phone mobile{thatClient} banner mounts; PTY snaps to phone dims
desktop mobile subscribes-fresh with desktop desktop (no transition) inner map updated; no banner
desktop last mobile client unsubscribes idle (banner already unmounted)

Subscribe-in-desktop-mode rule. A mobile client subscribing in displayMode='desktop' is treated as a passive watch, not a take-floor gesture. The driver stays at idle/desktop, so the desktop user is not interrupted. The instant that client (or any peer) sends input, sets the display mode to auto/phone, or sends a resize, the runtime transitions to mobile{thatClient} and the banner appears. This matches the rest of the design: the lock engages on interaction, not on presence.

The protocol is "first-mover wins until the other party acts." Desktop clicks Take back, mobile types, desktop types again — banner ping-pongs as each side acts. The clientId carried in mobile{clientId} is updated each time a mobile actor takes the floor; this is the wire channel by which the runtime knows which phone last drove (useful for the forward-path coordinator described at the bottom of this doc, and for the multi-mobile semantics described below).

Take back and PTY dimensions

Take back has two sub-cases:

  1. Mobile was in phone/auto mode (PTY at phone dims). Take back resizes PTY back to desktop dims (existing behavior of applyMobileDisplayMode('desktop') when wasResizedToPhone is true).
  2. Mobile was in desktop mode (PTY already at desktop dims). Take back is a pure lock-flag flip; no resize. Today's applyMobileDisplayMode('desktop') already short-circuits the resize in this case.

In the symmetric direction, mobile reclaim:

  1. Mobile is in phone/auto mode but PTY is at desktop dims (because desktop just clicked Take back): mobile reclaim re-applies the phone resize via applyMobileDisplayMode(currentMode).
  2. Mobile is in desktop mode: pure lock-flag flip; no resize.

End-user UX

Driver Banner (desktop) Desktop input Desktop resize
idle hidden allowed allowed
mobile{*} "🔒 Mobile is driving this terminal — your input is paused. Click Take back to resume." blocked at xterm.onData (silent drop) blocked in renderer + dropped in pty:resize IPC server-side
desktop hidden allowed allowed

Walkthroughs:

Phone connects while you're typing. Banner pops in. PTY may resize to phone dims (existing). Your next keystroke is dropped. Click Take back to unlock and (if needed) restore desktop dims.

You click Take back. Banner gone. You type freely. Mobile stays connected; mobile sees the desktop-sized terminal because PTY snapped back (or stayed at desktop dims if mobile was in desktop mode).

Mobile types something while you're reclaimed. Banner reappears. Your next keystroke is blocked. PTY may snap back to phone dims (if mobile is in auto mode).

Mobile disconnects. Banner gone permanently. Driver returns to idle.

Multiple panes. Driver state is per-pane. Phone on pane A doesn't affect pane B.

Text selection / scrollback / copy. Always allowed. The lock is keystroke and resize only.

Output continues to render. While the banner is mounted, terminal output streams to xterm normally — it is the mobile actor's bytes you are seeing. Only desktop-side keystrokes and resize are dropped. This matters because "your input is paused" can read ambiguously; output flow is unaffected.

Asymmetric UX (accepted tradeoff)

Mobile sees no banner in this PR. The mobile UI is visually unchanged: no "Desktop is driving" indicator, no analog to the Take back affordance. This is asymmetric and we are accepting it deliberately:

  • Smaller mobile UI surface. Mobile already has limited screen real estate and a constrained component set; introducing a presence banner there is a non-trivial design + i18n + dismissibility task that we want to defer until the lock model has settled in production.
  • Faster ship. Driver state machine + multi-mobile subscriber fix + desktop banner is the smallest change that fixes the dangerous collision (silent input interleaving). Adding a mobile-side banner doubles the UI scope.
  • Mobile reclaim is naturally signaled. When a mobile user types while desktop drives, the runtime flips the driver to mobile{thatClient}, the desktop banner remounts, and (if mobile is in auto mode) the PTY snaps back to phone dims. The mobile user sees the pane reflow and their keystrokes appear in the output stream. There is no silent black-hole condition on mobile that a banner would resolve.
  • Mobile keystrokes always reclaim implicitly. The protocol on mobile is "just type to take the floor." There is no mobile-side button to surface; a banner with no actionable control is closer to noise than signal.

The flip side: a mobile user typing into a stale view won't be told "hold on, desktop is driving" before their keystroke lands. They just take the floor. We consider that acceptable because (a) they're explicitly acting, and (b) the desktop user gets the warning side of the tradeoff where the risk of unintended action is much higher.

If usage data later shows mobile users frequently being surprised by desktop activity, a mobile banner can be added without changing the wire format — terminalDriverChanged(ptyId, driver) already carries everything needed.

Architecture

Where the truth lives

The mobileSubscribers map (re-keyed; see "Multi-mobile subscriber model" below) plus a new currentDriver: Map<ptyId, DriverState> on the runtime are the source of truth. The renderer learns the driver state via a new IPC event terminal-driver-changed(ptyId, driver: DriverState) emitted from the runtime through the existing notifier path. The structured payload is intentional: a binary locked: boolean would lose the clientId we need for multi-mobile semantics today and for the unified write coordinator on the forward path.

The runtime exposes getDriver(ptyId): DriverState, replacing the old isLocked(ptyId): boolean. Server-side pty:write / pty:resize defenses check runtime.getDriver(ptyId).kind === 'mobile' to drop desktop-side calls.

A new renderer module mobile-driver-state.ts mirrors mobile-fit-overrides.ts: keyed by ptyId, supports getDriverForPty(ptyId): DriverState and a subscribe-style change listener. The TerminalPane banner mounts when getDriverForPty(ptyId).kind === 'mobile', and pty-connection's onData/onResize guards drop input/resize under the same predicate.

Why not reuse getFitOverrideForPty?

The fit override only fires when the PTY was actually resized (mode='auto' and wasResizedToPhone=true). It misses the desktop-mode case where mobile is subscribed but no resize happened. The driver state is broader than the fit override.

Why a new IPC event vs extending terminal-fit-override-changed?

Cleaner separation. The fit override is about dimensions; the driver state is about ownership. They happen to overlap in the auto/phone subcase, but conflating them long-term ties future work on either to the other.

Why a structured payload vs locked: boolean?

The runtime needs to know which mobile client most recently drove (for multi-mobile semantics today and for the future write coordinator). A structured DriverState payload makes that information first-class on the wire instead of smuggling it through a side channel. The cost is ~70 LoC over a binary lock; the payoff is a symmetric UX foundation and a future-proof wire format.

Multi-mobile subscriber model

The mobileSubscribers map is today shaped Map<ptyId, Subscriber> and each terminal.subscribe call overwrites the previous subscriber for that ptyId. That overwrite is fine for a binary has(ptyId) presence check but breaks the moment we treat presence as a real set: phone B subscribing silently evicts phone A, so phone A unsubscribing or its WS dropping looks like "no mobile clients" even while phone B is still on the line. The driver state machine sits directly on top of presence, so soundness here is a prerequisite, not a polish.

We rekey to Map<ptyId, Map<clientId, Subscriber>> and update each callsite:

  • handleMobileSubscribe(ptyId, clientId, subscriber) — get-or-create the inner map, then inner.set(clientId, subscriber). Do not overwrite peer clients. If the inner map was empty before insert, this is the first subscriber for the PTY; emit the idle → mobile{clientId} driver transition.

  • handleMobileUnsubscribe(ptyId, clientId)inner.delete(clientId), then if the inner map is now empty, delete the outer entry, run the existing restore-resize logic, and emit the * → idle driver transition. If the inner map is non-empty, do not fire restore / driver-change; peers still have the floor.

  • applyMobileDisplayMode(ptyId) — iterate the inner map. The restore-resize semantics need a single representative subscriber (the desktop dims to restore to are stored on the subscriber record). Pick the earliest by subscribe time so the restore target is stable as later phones come and go. The display mode itself is per-PTY runtime state, not per-client.

    Active phone-fit dim selection (multi-mobile). When two or more mobile clients subscribe with different viewports (e.g., iPhone + iPad), the PTY can only be at one phone-fit size at a time. Rule: the most recent mobile actor's viewport wins. This matches the driver state machine — whoever last took the floor (mobile{X}) also dictates the active phone-fit dims. When that client unsubscribes, the next-most-recent surviving subscriber's viewport wins; on the last client leaving, the inner map empties and we run the existing restore back to the earliest-recorded desktop dims. We do not use min(cols, rows) across subscribers because that produces no clear principal — multiple TUIs are forced to render for an aggregate viewport that nobody actually has, which is worse than picking a single owner. Most-recent-actor is consistent with the rest of the protocol (whoever acted last has the floor) and is cheap to reason about.

    Subscriber record extension. "Most-recent actor" and "earliest-by-subscribe-time" both require timestamps that today's Subscriber record does not carry. Extend it with two fields:

    interface Subscriber {
      clientId: string
      viewport: { cols: number; rows: number }
      wasResizedToPhone: boolean
      previousCols: number | null
      previousRows: number | null
      subscribedAt: number  // ms since epoch, set on insert
      lastActedAt: number   // ms since epoch, init = subscribedAt;
                            // updated on every mobileTookFloor for this client
    }
    
    • applyMobileDisplayMode(ptyId) for active phone-fit dims: iterate the inner map, pick argmax(lastActedAt), use that subscriber's viewport.
    • For restore-to-desktop semantics on last-client-leaves: pick argmin(subscribedAt) among subscribers with non-null previousCols/previousRows and use that subscriber's previousCols/Rows as the restore target. Desktop-mode subscribers carry previousCols/Rows = null by design (the existing handleMobileSubscribe short-circuits dim capture for desktop-mode joins to avoid capturing a stale full-width size), so they are not viable restore targets. If no surviving subscriber has captured dims, fall back to lastRendererSizes.get(ptyId) (the desktop renderer's most recent reported size) — same fallback the existing first-insert capture path uses.

    Both operations are O(n) over the inner map, which is bounded by the number of concurrently subscribed mobile clients (typically 1, rarely 2-3). No indexing needed.

  • isMobileSubscriberActive(ptyId) — returns true iff the inner map is non-empty.

  • Driver state for the mobile side stores the clientId of the most recent mobile actor. Whenever any mobile client sends input/resize/setDisplayMode/subscribes-fresh, that client becomes the currentDriver (mobile{thatClient}). Phone B taking over from phone A is an internal mobile→mobile transition: no banner change, but the recorded clientId updates so the runtime always knows who is the authoritative mobile actor.

The semantic upshot is that the desktop banner is governed by "is the inner map non-empty AND has the desktop not reclaimed?" rather than the old single-slot heuristic, and Take back / mobile-reclaim correctly ping-pong even when two phones are on the same PTY.

Implementation

Files touched:

  1. src/main/runtime/orca-runtime.ts — rekey mobileSubscribers to Map<ptyId, Map<clientId, Subscriber>>, add currentDriver map, getDriver(ptyId) getter, transition methods, notifier hook. Update handleMobileSubscribe / handleMobileUnsubscribe / applyMobileDisplayMode / isMobileSubscriberActive per the multi-mobile model above. Extend onPtyExit cleanup to currentDriver.delete(ptyId) and emit terminalDriverChanged(ptyId, { kind: 'idle' }) so any banner mounted on the dead pane unmounts. Without this, a dead PTY's last driver state lingers and the renderer banner could persist on a dead pane until tab teardown.

  2. src/main/runtime/orca-runtime.ts interface RuntimeNotifier — add terminalDriverChanged(ptyId, driver: DriverState).

  3. src/main/window-manager.ts (or wherever RuntimeNotifier is wired) — forward to renderer via webContents.send('terminal-driver-changed', …).

  4. src/preload/index.ts + src/preload/api-types.ts — expose the new event with the structured DriverState payload.

  5. src/main/ipc/runtime.ts — add runtime:reclaimTerminal IPC (or extend runtime:restoreTerminalFit) which drives the mobile{*} → desktop transition.

  6. src/main/runtime/rpc/methods/terminal.ts — drive the * → mobile{clientId} transition in terminal.send, terminal.resizeForClient, terminal.setDisplayMode, and the subscribe handler (with the subscribe-in-desktop-mode exception above).

    Wire-format changes required. The driver state machine tags the active mobile actor with clientId, so every mobile RPC method that can take the floor must carry the caller's identity. Today only terminal.subscribe and terminal.resizeForClient do; we extend the others.

    Canonical identity shape. Use the existing terminal.subscribe shape: client: { id: string; type: 'mobile' \| 'desktop' } (with type optional). TerminalResizeForClient keeps its grandfathered top-level clientId: string field for backward compatibility — implementers may add an aliased nested client.id setter for consistency at the call site, but the wire shape is preserved.

    New fields on existing schemas:

    • TerminalSend gains client: { id: string; type?: 'mobile' \| 'desktop' } (optional for backward compatibility — falls back to "the most recent mobile actor" when absent).
    • TerminalSetDisplayMode gains the same client field.
    • TerminalUnsubscribe gains client: { id: string } so the server can derive the composite cleanup key (see below); the existing subscriptionId field is kept and remains the primary lookup, with client.id used only when the caller passed a bare-handle subscriptionId.

    Subscribe-side composite key. Change subscriptionId from params.terminal to ${params.terminal}:${params.client.id} so two phones subscribing to the same terminal handle do not evict each other via registerSubscriptionCleanup. Without this fix, the multi-mobile rekey to Map<ptyId, Map<clientId, Subscriber>> would be silently defeated at the RPC subscription layer (phone B's subscribe runs phone A's cleanup → A's data listener tears down → A's handleMobileUnsubscribe fires).

    Unsubscribe-side composite key. The mobile RPC client today emits terminal.unsubscribe with params: { subscriptionId: stream.params.terminal } — the bare handle. With the subscribe side now keying by composite, a bare-handle unsubscribe will miss in subscriptionCleanups.get(bareHandle) and silently no-op, leaking the data listener and leaving mobileSubscribers populated forever (banner stuck, driver never returns to idle). Fix: the mobile RPC client emits { subscriptionId: ${terminal}:${clientId}, client: { id: clientId } }, and the server's cleanupSubscription uses whichever the caller sent — if both subscriptionId and client.id are present and the caller passed only terminal in subscriptionId, the server reconstructs the composite key from client.id. Belt-and-braces so a stale older mobile build still cleans up correctly.

    This is a coordinated client/server wire-format change. The mobile app must ship in lockstep; if a stale mobile client emits bare-handle unsubscribe to a new server, the server's reconstruction path catches it. If a new mobile client emits composite-handle unsubscribe to a stale server, the stale server ignores client.id and the composite key fails to match — but that direction is moot because the stale server has not adopted composite keys on subscribe either. No client newer than server, so this is safe.

  7. src/main/ipc/pty.ts — defense in depth: drop pty:write and pty:resize calls when runtime.getDriver(id).kind === 'mobile'. Preserve the existing runtime.isResizeSuppressed() short-circuit at the top of the pty:resize handler — the new driver-state guard is in addition to, not in place of, the suppression window. The two guards have different purposes: isResizeSuppressed() blocks the safeFit cascade after a take-back transition (preventing collateral resize corruption of background panes), while the driver-state check blocks desktop-side resizes whenever mobile is driving. Both must apply.

  8. src/renderer/src/lib/pane-manager/mobile-driver-state.ts — new renderer store mirroring mobile-fit-overrides.ts. Exposes getDriverForPty(ptyId): DriverState and a change listener.

  9. src/renderer/src/components/terminal-pane/pty-connection.ts — guard onData and onResize on getDriverForPty(ptyId).kind === 'mobile'.

  10. src/renderer/src/components/terminal-pane/TerminalPane.tsx — switch banner to consume driver state, update copy, rename Restore → Take back.

Server-side detail

// orca-runtime.ts
private currentDriver = new Map<string, DriverState>()

getDriver(ptyId: string): DriverState {
  return this.currentDriver.get(ptyId) ?? { kind: 'idle' }
}

private setDriver(ptyId: string, next: DriverState): void {
  this.currentDriver.set(ptyId, next)
  this.notifier?.terminalDriverChanged(ptyId, next)
}

reclaimTerminalForDesktop(ptyId: string): void {
  if (!this.isMobileSubscriberActive(ptyId)) return
  // Snap PTY back to desktop dims if currently at phone dims.
  // applyMobileDisplayMode is a no-op resize when already at desktop dims.
  this.setMobileDisplayMode(ptyId, 'desktop')
  this.applyMobileDisplayMode(ptyId)
  this.setDriver(ptyId, { kind: 'desktop' })
}

private mobileTookFloor(ptyId: string, clientId: string): void {
  // If mobile is in phone/auto mode, re-apply phone resize on the first
  // hand-off back from desktop. Mobile-to-mobile hand-offs are no-ops
  // for resize.
  const prev = this.getDriver(ptyId)
  if (prev.kind === 'desktop') this.applyMobileDisplayMode(ptyId)
  this.setDriver(ptyId, { kind: 'mobile', clientId })
}

handleMobileUnsubscribe(ptyId: string, clientId: string) {
  const inner = this.mobileSubscribers.get(ptyId)
  if (!inner) return
  inner.delete(clientId)
  if (inner.size > 0) return  // peer mobile clients still drive
  this.mobileSubscribers.delete(ptyId)
  // Drive transition fires synchronously so the desktop banner
  // unmounts immediately. The PTY restore runs inside the existing
  // 300ms pendingRestoreTimers debounce — see "Restore-debounce
  // window" under Edge cases.
  this.setDriver(ptyId, { kind: 'idle' })
  // Existing restore-resize logic (300ms debounced timer) runs here.
}

The mobile-took-floor path is invoked from the RPC method handlers (not from sendTerminal directly) so that the driver flip is bound to deliberate mobile actions, not to internal runtime calls.

Renderer guards

// pty-connection.ts onData
if (currentPtyId && getDriverForPty(currentPtyId).kind === 'mobile') {
  return  // Mobile is driving this PTY; banner explains.
}
// existing transport.sendInput(data)

// pty-connection.ts onResize
// Why: keep both predicates. getFitOverrideForPty fires synchronously
// in the renderer the moment safeFit runs; getDriverForPty arrives via
// IPC and lags by one round-trip. Removing the fit-override predicate
// would re-introduce the resize-over-mobile-fit bug mobile-fit-overrides
// was added to prevent.
if (
  currentPtyId &&
  (getFitOverrideForPty(currentPtyId) ||
    getDriverForPty(currentPtyId).kind === 'mobile')
) {
  return
}

Banner copy

🔒 Mobile is driving this terminal — your input is paused. [ Take back ]

When PTY is at phone dims, append the dim suffix:

🔒 Mobile is driving this terminal (80×24) — your input is paused. [ Take back ]

The phrasing makes Take back the discoverable resolution. There is no auto-unlock-on-WS-silence and no liveness probing; the banner stays up until the desktop user takes back, until mobile actor sends input (which keeps mobile as driver), or until the last mobile subscriber disconnects.

Edge cases

  • Mobile drops network. WS doesn't immediately know. The inner subscriber map retains the entry for ~30s until ping timeout. Banner stays. Take back still works during this window — it transitions to desktop immediately. When mobile eventually reconnects with terminal.subscribe, that client takes the floor again (desktop → mobile{thatClient}).
  • Two phones on same PTY. The inner map carries both subscribers. Driver state is mobile{whicheverActedLast}. Phone A unsubscribing while phone B is still on does not drop the banner; only the last client leaving the inner map transitions to idle.
  • Pre-locked input in flight. transport.sendInput is fire-and-forget; there is no in-flight queue to drain. The first dropped keystroke is on the renderer side, which is good enough.
  • Take back while phone is offline. Works (transitions to desktop). When phone reconnects with a fresh subscribe, driver flips to mobile{thatClient}.
  • Display mode toggle from mobile. terminal.setDisplayMode is treated as mobile interaction → that client takes the floor. Aligns with "any deliberate mobile action takes the floor."
  • Subscribe from a fresh client (reconnect with new clientId). The subscribe path runs the desktop → mobile{thatClient} (or idle → mobile{thatClient}) transition. A mobile reconnect takes back the floor from desktop — correct semantic since the user actively reopened the mobile view.
  • Restore-debounce window on last-subscriber-leaves. When the last mobile subscriber unsubscribes, the driver transitions to idle synchronously (banner unmounts immediately on desktop), but the PTY-dim restore runs inside the existing 300ms pendingRestoreTimers debounce. For up to 300ms after the banner unmounts, the PTY remains at phone dims while desktop input flows through. Desktop typing into a still-squished terminal is a brief visual mismatch, not a correctness bug — the existing debounce is there to absorb rapid tab switches without thrashing PTY size, and emitting idle synchronously preserves that behavior. Do not reorder these (driver-emit-first, then debounced restore) without re-introducing thrash.

Tests

  • orca-runtime.test.ts (driver state machine):
    • Every transition row in the table above has a unit test asserting (prev, trigger) → (next, side effect, emitted event payload).
    • getDriver defaults to { kind: 'idle' } for unknown ptyIds.
    • terminalDriverChanged is emitted exactly once per state change and carries the structured DriverState payload (not a boolean).
  • orca-runtime.test.ts (multi-mobile subscriber sequencing):
    • Phone A subscribes (auto) → driver is mobile{A}, banner emit fires.
    • Phone A subscribes (desktop) from idle → driver stays desktop (subscribe-in-desktop-mode does not take the floor); inner map has A.
    • Phone B subscribes (auto) while A is still on (auto) → driver flips to mobile{B} (subscribe-fresh-with-auto/phone counts as take-floor); inner map has both subscribers; active phone-fit dims switch to B's viewport.
    • Phone B unsubscribes while A is still on → driver remains mobile; active phone-fit dims revert to A's viewport (next-most-recent actor); banner stays up; no idle transition.
    • Phone A unsubscribes (last client leaves) → driver transitions to idle, banner unmounts, PTY restores to earliest-recorded desktop dims.
    • applyMobileDisplayMode picks the most-recent-actor's viewport for active phone-fit dims and the earliest-by-subscribe-time desktop dims for restore.
    • Mobile sets displayMode to desktop from mobile{*} does not transition to desktop automatically (existing setDisplayMode semantics stand) — but the runtime's driver transition rule for desktop → desktop (mode change to desktop while already in desktop driver) is a no-op.
  • terminal.test.ts (subscriptionId per-client keying):
    • Phone A subscribes to terminal handle T, phone B subscribes to the same handle T → A's data listener still receives bytes after B's subscribe (subscriptionId is ${T}:${clientId} so registerSubscriptionCleanup does not collide).
    • Regression: prior to the fix, the second subscribe ran the first subscribe's cleanup and tore down phone A's stream.
  • pty-connection.test.ts:
    • onData is dropped while getDriverForPty(id).kind === 'mobile'.
    • onData is delivered when driver flips to desktop or idle.
    • onResize is dropped while driver is mobile.
  • pty.test.ts (or new pty-driver-state.test.ts):
    • pty:write IPC is dropped when runtime.getDriver(id).kind === 'mobile'.
    • pty:resize IPC is dropped under the same predicate.

Out of scope

  • Mobile yanking desktop tab focus via terminal.focus.
  • Cold-restore ack from mobile-only attach.
  • Mobile-side "Desktop is driving" banner (see "Asymmetric UX" above).

These are independent collisions tracked separately.

Forward path

The natural next step (not in this PR) is unifying both writers — the desktop renderer's pty:write IPC and the mobile RPC's terminal.send — into a single coordinator that queues bytes through a runtime-owned write path. The driver state machine cleanly supports this: the coordinator can use currentDriver(ptyId) as the admission predicate (only the current driver's bytes are dequeued), and the mobile{clientId} payload tells it which queue head to drain when a specific phone is the active actor. Today's PR keeps two independent write paths and uses driver state purely as a drop-filter; the same state machine becomes the scheduling input on the forward path without a wire-format change.

Rollout

Single PR. No feature flag needed; behavior is strictly additive (locks an existing collision surface) and the existing banner UX continues to work under the new driver-state model with sharper copy.