Files
orca/mobile
Jinwoo Hong e628090ad4 perf(mobile): cut the relay reconnect critical path and admit dead sockets faster (mobile pass) (#19280)
* perf(mobile): cut the relay reconnect critical path and admit dead sockets faster

Phone medians put E2EE authentication at ~424ms but `connected` at ~630ms,
because the session serialized two RPC round trips behind it: the resume
confirm (`pairing.getEndpoints`) and the capability advisory. Both now ride
the authenticated socket concurrently and off the critical path, so the
session publishes `connected` as soon as E2EE authenticates. Peer identity
is already proven by then — the confirm carries credential/lease bookkeeping
and the cell assignment check, and it still fails the session on a bad answer
or a foreign relayHostId, only later. `persistResumeConfirmation` awaits the
new `whenResumeConfirmed()` instead of assuming the answer is present at
`connected`.

Foreground liveness on a retained relay: `notifyForeground('app-resume')`
now probes past the 10s voluntary minimum on urgent bounds (2s, one miss),
so a socket that died while the process was suspended is admitted in ~2s
instead of ~8s. Focus and network nudges keep the old minimum and bounds.
Relay sessions also gain a 25s idle sweep, gated on foreground so a
backgrounded app spends no probes.

Recovery is no longer blocked by the direct return probe. The probe's 12s
dial is a pure observation on its own socket, so it takes the supervisor's
operation mutex only for the cutover; a relay recovery landing during a
foreground return now starts immediately instead of waiting the budget out.
Requests that do land during the cutover are queued in a new
RelayRecoveryIntentQueue and replayed on release — an owning forced
replacement keeps its intent, everything else replays as a plain recovery.

Tests updated deliberately, for the new ordering:
- 'sends no periodic traffic while an authenticated relay is idle' asserted
  the absence of any relay idle probe, which is exactly the gap D3 closes.
  Replaced by a sweep test plus a backgrounded no-probe test.
- 'rate-limits foreground sequences without suppressing a retry' asserted
  that app-resume was suppressed inside the 10s minimum. An app resume is
  now the one nudge that must never be rate-limited.
- the session helpers waited for the confirm answer before `connected`;
  they now authenticate, read both concurrent frames, and settle them.

* fix(mobile): book backoff when a relay resume confirm fails after the cutover

Review round 1 on 352bfd2300.

P1: publishing `connected` at E2EE authentication made `migrateTo` resolve
before the resume confirm answered, so a confirm that failed afterwards —
a `relayHostId` mismatch from a rehomed desktop is the live case — was still
reported as an `established` dial. registerFailure was skipped, no cooldown
was booked, recordMigration()/setActiveSession() ran for a dying session, and
the queued-recovery replay redialled immediately: a tight loop with a
connected→disconnected blip per pass. The establisher now awaits
whenResumeConfirmed() after the cutover and, if the session is no longer
connected, reports a failed dial (or an aborted one when direct won or the
supervisor went inactive) exactly as a rejected migrateTo used to. The UI
still connects early; only the supervisor's bookkeeping waits.

The state check, rather than getFailure(), is the oracle: a live session can
carry a latched failure without having failed yet, and "is this session still
alive once the confirm settled" is precisely the question migrateTo used to
answer.

P2: the resume probe profile goes to two 2s misses instead of one. The first
frame after a resume rides a cold radio and a possibly distant cell, so one
slow answer is not proof of a dead link; the verdict still lands at 4s rather
than the previous 8s.

Nits: the direct probe's two early returns no longer close the candidate the
finally also closes (the second shape pre-existed); RelayRecoveryIntentQueue
is cleared in the supervisor's stop().

Mutex-hold note: persistResumeConfirmation, and now the establisher's own
await, are bounded by the confirm's request timeout. That would have been the
session's 30s default, so the confirm is pinned to RELAY_CONFIRM_TIMEOUT_MS
(12s) — the same bound migrateTo's waitForAuthenticated applied before.

Test: a supervisor-level case where every dial authenticates then fails the
confirm must book 250/500/1000ms backoff with no immediate redial, and must
never record a migration. It fails on the pre-fix establisher.

* fix(mobile): close three relay probe and liveness gaps from review

Review findings on this PR, fixed here so they ride along with the rest.

Direct return probe: schedule() guarded only the pending timer, so a caller
asking for an immediate probe while a dial was in flight started a second one
that overwrote activeProbe. stop() then reached only the newest socket and left
the earlier dial running out its 12s budget. Releasing the operation mutex for
the dial removed the only thing that had been serializing probes, and the
background bounce hits it directly: background() cancels the timer but leaves an
in-flight dial alone, and the matching foreground return asks for a probe at
once. The in-flight probe now owns the next slot and re-arms on the soonest
delay any caller asked for, so an urgent request is deferred rather than dropped
on the 15s floor.

Liveness watchdog: both retry paths in handleProbeTimeout, the tolerated-miss
one and the unfair-window one, retried without rechecking shouldIdleProbe. An
idle-sweep probe that started in the foreground could therefore keep spending
probes after the app backgrounded and terminate a healthy relay on misses that
were really iOS suspending the socket, which is the exact reading the foreground
gate exists to prevent. Probes now carry their origin, and an idle-sweep probe
that times out while backgrounded clears its state and re-arms the sweep with no
misses carried forward. Caller probes still reach a verdict.

Relay RPC session: whenResumeConfirmed() handed a pre-authentication caller an
already-resolved promise, so the documented contract only held after
authentication. No caller can reach that window today, since publishAuthenticated
assigns the promise before publishing 'connected' and both readers run after
migrateTo resolves, but the type comment promised more than the code delivered.
The deferred now exists from construction and settles on the confirm, on fail(),
and on close(), which are the only ways the session can end. Both endings had to
settle it and already shared nearly all of their teardown, so they are unified
behind one terminate().

* fix(mobile): give a resume probe its own miss budget

A resume probe supersedes an ordinary probe already in flight, but startProbe
carried the ordinary profile's missedProbes across the switch. Relay uses 2
misses for both profiles, so one earlier 4s miss plus a single slow 2s answer
terminated the session -- consuming the tolerated cold-radio answer the urgent
profile exists to provide. Switching profile now resets the count.
2026-09-07 13:12:24 -04:00
..

Orca Mobile

React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone.

Local development uses two processes:

  • Orca desktop/Electron from the repo root. This hosts the mobile WebSocket RPC server on port 6768.
  • Expo Metro from mobile/. This serves the React Native app on port 8081.

Unless a command says otherwise, run mobile app commands from the mobile/ directory.

Prerequisites

  • Node.js 24+
  • pnpm
  • Xcode and/or Android Studio tooling for simulator or device builds
  • Expo Go on your phone, or a development client build when native modules are needed
  • Phone and desktop on the same LAN when testing a physical phone

Start Desktop Orca

From the repository root:

pnpm install
pnpm dev

Confirm the mobile RPC server is listening:

lsof -nP -iTCP:6768 -sTCP:LISTEN

Restart pnpm dev after changing Electron main-process code. Metro hot reload only applies to the mobile JavaScript bundle.

Start The Mobile App

cd mobile
pnpm install
pnpm start

Scan the Expo QR code with your phone's camera on iOS, or Expo Go on Android.

For a native dev-client build:

pnpm exec expo run:android
pnpm exec expo run:ios
pnpm start --dev-client

Pair With Desktop Orca

  1. Open Orca desktop.
  2. Go to Settings > Mobile.
  3. Scan the pairing QR code from the mobile app.
  4. Confirm the mobile host endpoint is ws://<desktop-ip>:6768.

For the Android emulator, use ws://10.0.2.2:6768. For a physical phone, use the desktop LAN IP, for example ws://192.168.0.179:6768.

If the phone has a stale host entry, remove it from the app and pair again.

Development Paths

Android Phone

  1. Install Expo Go from Google Play
  2. Run pnpm start, scan QR with Expo Go
  3. For native modules: pnpm exec expo run:android
  4. Run with pnpm start --dev-client

iOS Simulator

  1. Install Xcode from the App Store
  2. Run pnpm start --ios to open in iOS Simulator

Physical Phone Debugging

The phone can be inspected through the connected device tooling:

orca snapshot --json
orca click --element @e3 --json
orca fill --element @e1 --value "ls" --json
orca screenshot --json

Use snapshot first to find the current element refs, then click/fill those refs. After mobile file edits, Metro usually hot reloads automatically, but navigating out of and back into the session screen can be useful because it re-runs terminal.subscribe.

Terminal Streaming Repro Without A Phone

Use this when terminal output does not render on device and you need to split server streaming bugs from WebView/UI bugs:

cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64>

You can pass a worktree selector as the third argument:

pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "path:/absolute/worktree/path"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "name:my-worktree"

The expected result includes:

streamSawMarker: true
readSawMarker: true

If this repro fails, debug the desktop runtime/PTY path before the mobile WebView. If it passes but the phone is blank, debug the session screen or TerminalWebView readiness/queueing path.

Terminal Color Repro Without A Phone

Use this when terminal colors disappear after switching tabs. Open a Claude Code terminal and at least one other terminal in the target worktree, then run:

cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/repro-terminal-colors.ts \
  <deviceToken> <serverPublicKeyB64> "id:<worktreeId>"

The script captures terminal.subscribe snapshots in an A → B → A sequence and writes raw snapshots to mobile/terminal-color-repro/. If the two A snapshots have different sgrColor counts, the desktop snapshot changed during the switch. If they match, the ANSI color data is still present and the bug is in mobile replay/rendering.

Validation

Run these checks before committing mobile terminal changes:

cd mobile
pnpm exec tsc --noEmit
pnpm lint
cd ..
pnpm typecheck:node

Protocol Version Compatibility

Mobile and desktop talk over a versioned protocol. Because mobile updates lag desktop by 24-48h via the App Store, both sides exchange version numbers on status.get so a genuinely incompatible combo can hard-block instead of silently misbehaving.

Constants live in two files (Metro can't resolve outside mobile/):

  • src/shared/protocol-version.tsDESKTOP_PROTOCOL_VERSION, MIN_COMPATIBLE_MOBILE_VERSION
  • mobile/src/transport/protocol-version.tsMOBILE_PROTOCOL_VERSION, MIN_COMPATIBLE_DESKTOP_VERSION

Today all four are set so evaluateCompat always returns { kind: 'ok' } — nothing blocks. The wire format is in place to flip a switch when needed.

When to bump

Bump DESKTOP_PROTOCOL_VERSION (and the mobile mirror MOBILE_PROTOCOL_VERSION when relevant) for breaking changes:

  • Removed RPC method or required parameter that mobile uses
  • Changed meaning (units, nullability) of an existing field mobile reads
  • Changed encryption, framing, or auth handshake

Do not bump for additive changes:

  • New RPC methods
  • New optional fields on existing methods
  • New event types in terminal.subscribe

Set MIN_COMPATIBLE_MOBILE_VERSION (kill-switch) when desktop ships a change that requires a minimum mobile version to function safely. Same for MIN_COMPATIBLE_DESKTOP_VERSION from the mobile side.

When a verdict is blocked, mobile/src/components/ProtocolBlockScreen.tsx renders a screen pointing the user at either the App Store (mobile too old) or GitHub Releases (desktop too old).

To exercise the block screen locally: set MIN_COMPATIBLE_DESKTOP_VERSION = 999 in mobile/src/transport/protocol-version.ts, rebuild, pair to any desktop. Revert before merging.

Mock Server

Develop the mobile app without a running Orca desktop instance:

pnpm mock-server           # starts mock WebSocket server on port 6768

Connect from the app using endpoint ws://localhost:6768 and token mock-device-token.

Environment variables

  • MOCK_NATIVE_CHAT=1 — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures.
  • MOCK_SERVER_KEY_FILE — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair.

Scenario control files

Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default.

  • MOCK_SEND_MODE_FILE (default orca-mock-send-mode in the system temporary directory) — accept (default) accepts the send, error fails it with mobile_input_floor_unavailable, anything else reports the send as rejected.
  • MOCK_TERMINAL_LIST_MODE_FILE (default orca-mock-terminal-list-mode in the system temporary directory) — omit returns an empty terminal list, other returns a list that omits the chat handle, anything else lists it.
  • MOCK_TERMINAL_STREAM_MODE_FILE (default orca-mock-terminal-stream-mode in the system temporary directory) — dead answers a subscribe with subscribed then end (a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.

Connecting to Real Orca

  1. Start Orca desktop with WebSocket transport enabled
  2. In Orca, go to Settings > Mobile and scan the QR code with this app
  3. The QR encodes the connection endpoint, device token, and TLS fingerprint

Project Structure

mobile/
├── app/                   # Expo Router screens (file-based routing)
│   ├── _layout.tsx        # Root layout with navigation stack
│   ├── index.tsx          # Home screen — paired hosts list
│   └── pair-scan.tsx      # QR code scanning screen
├── src/
│   ├── terminal/          # Terminal WebView and xterm bridge
│   └── transport/         # WebSocket RPC client
├── scripts/
│   ├── test-subscribe.ts  # Desktop streaming repro without a phone
│   └── mock-server.ts     # Standalone mock WebSocket server
└── assets/                # App icons and splash screen