Files
orca/docs/mobile-presence-lock.md

691 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).
```ts
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'\|'phone'` (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/phone |
| `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/phone 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/phone 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'|'phone'` 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:
```ts
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
```ts
// 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
```ts
// 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.