Files
orca/mobile
Jinwoo Hong ccb4d2044b refactor(mobile): send the last session-route raw-port calls as operations (step 6, migration 2) (#21083)
* test(mobile): record the session startup, create and display-mode families

Three mount adapters and ten scenarios for the last raw-port sends in the
session route, recorded at the pinned main baseline before any product edit.

The three hooks were listed as blocked on a WebView-ref substitute. They are
not: none imports the terminal WebView, and all three send with no ref. The
display-mode toggle reads a `{cols, rows}` cell and a device-token cell; the
create path calls scope callbacks; the startup effect drives scope callbacks
only. Each stub is an effect sink, shapes no param and swallows no throw.

One scenario reaches both `worktree.activate` sites the way the product does:
the auto-create clears `created` off the route, the effect re-runs on the same
mount and takes the other branch, so the reply matrix drives both.

The create adapter mounts in its factory rather than as a scripted step. React
draws one `Math.random()` lazily the first time `enqueueTask` runs, and the
runner flushes through `await act` after every step, so a scripted mount would
make `clientMutationId` the second draw of the seeded sequence on the first
recording in a process and the first on every later one. The two determinism
runs caught it.

Recorded through the pinned-baseline worktree recipe, because main has moved
past `a28085adbf` in `src/shared` and this branch does not repin. 705 existing
goldens byte-identical, 15 added, 0 moved, 0 deleted.

Mutation census against the raw-port code, applied and reverted by hand, all
twelve killed: wrong method at each of the four sites; acceptance verdict
swapped at each of the four verdict-reading sites; dropped `unsubscribeTerminal`
on replace; the two activation branches swapped; a delayed `fetchTerminals` pass
dropped; the viewport pair not forwarded on `terminal.setDisplayMode`.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): send the last session-route raw-port calls as operations

Four references, three files, no behaviour change. Proven by replay: the
fifteen goldens recorded at the pin before this commit pass unchanged, so no
re-record.

- `use-mobile-session-startup.ts` both `worktree.activate` sends reuse
  host-screen's `worktreeActivate`. Its skip verdict was never read before;
  the startup effect is its first reader, and it reads exactly what main read
  off the envelope — whether an accepted reply says the host is headless.
- `use-mobile-session-terminal-create-actions.ts` `session.tabs.createTerminal`
  gets `sessionTabCreateTerminal`, a single-reader operation beside the other
  session-screen writes. `require-result-or-throw-message` replaces the
  `if (response.ok)` branch because the throw lands in the catch that already
  reported the host's message, character for character, including the empty
  message falling back to the screen's own copy. The reader stays the unguarded
  `.tab` read, because that policy rethrows a reader's exception rather than
  converting it, which is what keeps a null or absent result failing where it
  failed before.
- `use-mobile-session-terminal-stream-display.ts` `terminal.setDisplayMode` gets
  `terminalDisplayModeSet`, a skip whose verdict the caller does not read, the
  way `terminalBufferClear` already works: the server does the resize and
  reports it on the terminal's existing subscription, so main looked at nothing
  in the envelope and only a transport rejection was ever a failure.

The prompt `terminal.send` in the create path stays on the raw port. It is the
only `terminal.send` caller that falls back to its own copy when the host
refuses with an empty message, so no existing operation carries its acceptance
and a new one is a fourth method outside this migration's scope. It is recorded
either way.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): lower the raw-port inventory and refresh the session route pins

Pending raw-port inventory: two entries deleted and one lowered, 12 files / 21
references to 10 / 17. The startup and display-mode entries reach zero; the
create entry keeps the prompt `terminal.send` and states its own reason.

Three stale comments corrected. The startup, create and display-mode entries
claimed a WebView-ref or subscription wall that measurement did not find: none
of the three hooks imports the terminal WebView, the display-mode write is not
gated on an open subscription, and the create path's `subscribeToTerminal` is a
scope callback rather than a `client.subscribe`. The accounts screen's entry
said the runner is request-only, which stopped being true when `ScenarioStep`
gained `frame`; what actually blocks it is that no scenario has been written for
`accounts.subscribe`, so its entry now says that instead.

Unchecked-reader inventory: `mobile-session-write-operations.ts` 8 to 10 for the
two readers the migration added, named in the header the way #20954's three are.

Route parity: four pins refreshed with their reasons — the callback bodies for
the display-mode toggle, the effects for the startup activation pair, the nested
function bodies for the create, and the runtime strings, whose count falls 535 to
531 as four more method literals move to their operations' definitions. The
startup source pins now name `worktreeActivate` and still hold what they held:
the plain activation is fired rather than awaited, and it goes out before the tab
load.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile): say which part of the display-mode operation no golden holds

Post-refactor census survivor, measured rather than assumed: swapping
`terminalDisplayModeSet`'s acceptance for `require-result-or-throw-message`
moves none of the fifteen goldens. The call site reads no verdict and its own
`catch` swallows a throw either way, so no policy is observable there. The
method, the params and the viewport pair are what the goldens hold at that site.
The six other operation-level mutations all kill.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): record the empty cells the session guards are written for

Three session sends are gated on a cell every existing scenario filled: the
display-mode toggle carries `viewport` only once a surface has measured one and
`client` only once the phone holds a device token, and the startup sequence
swallows a refused tab load before loading terminals behind it. Every recording
declared those cells full, so the arm each guard exists for was never on the
wire and dropping the guard moved no golden.

The two device cells become scenario arguments rather than adapter constants, so
a scenario can declare them empty; the tab load may now be declared to reject,
which is the only way a refused scope callback is reachable at all. Declared, not
shaped: the stubs build no param and swallow no throw.

Three scenarios take the empty arm. The token and viewport ones send `auto`,
which is the direction both members ride, and the startup one records that the
terminal loads and the activation timer still run behind a refused tab load.

Recorded at the pinned baseline through the detached-pin worktree recipe, since
this branch may not repin. 705 goldens identical, 0 body moved, 3 added, 0
deleted; the 15 header-only moves are `adapterSha256` on the three edited
families and `scenarioSha256` on the four scenarios that now declare their token.

The create adapter's determinism comment now names the draw it works around:
React's lazy `("require" + Math.random())` in `enqueueTask`, the scheduler line
that seeds the sequence, and the mismatch a misplaced mount reports. #21088
retires the workaround.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): witness the three session guards the recordings had not pinned

Each mutation is the guard deleted: the display-mode send carries `client` with
an empty id, carries `viewport` before anything measured one, and the startup
sequence lets a refused tab load reject it so the terminal loads and activation
timer behind it never run. All three survived the whole suite before the
scenarios above; the witness asserts each is killed by its scenario and that
every other scenario of the same family still cannot see it.

A mutation that changes a param the scenario completes aborts at the transport's
params assertion instead of producing a divergent recording. That is the
scenario detecting it, so the witness reads that one message as a kill, narrowed
to it and taken only after the anchor is proved applied.

The README gains the class as its fifth bounding fact: a value an adapter holds
as a constant is a cell no scenario can empty, so the arm that reads it empty is
unreachable until the constant becomes an argument. Corpus counts refreshed to
what the suite measures.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): drop the terminal-create result type nothing reads

`TerminalCreateResult` wrapped the created tab for the old `sendRequest` reply
shape. The migrated call site reads the tab off the operation and names the tab
type directly, leaving the wrapper with zero readers repo-wide. Using it at the
cast site would have kept the cast and only renamed it, so it goes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): let the create scenarios declare what the create puts on the wire

The terminal-create adapter decided four of the members its own goldens hold:
the worktree, the tab a new one is inserted after, and every launch option but
the prompt and its two toasts. A value an adapter supplies itself is a cell no
scenario can empty, so `afterTabId`'s omission arm — the arm a fresh session and
a last-tab close both take — was unreachable, and the quick-command members were
recorded only as absent. All of it now comes from the scenario, and the mount
moves to the first action so the arguments are in place before the hook reads
them. It stays out of a scripted mount step for the determinism reason above it.

Four scenarios follow the new arguments: a create with no active tab, a shell
quick command, an agent quick command, and a second tap while the host is still
answering the first. The refused scenario stops declaring an `errorToast` the
adapter dropped: forwarding the toast independently of the prompt is what the
product does, so that golden now records the failure toast it always showed.

Recorded at the branch's pin, so 705 goldens stay byte-identical to the merge
base; five headers move on adapter and scenario digests and one body moves, the
refused create's new toast effect.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): witness the three create guards the recordings had not pinned

Each of the three new create scenarios closes a mutation that survived all 853
tests before it: putting the active tab on the wire as `null` instead of
omitting it, swapping the `command` and `agentPrompt` members the host reads,
and dropping the in-flight guard so a second tap opens a terminal nobody asked
for. The witness asserts the hole and the closure together, as the others do.

The params-mismatch abort the witness reads as a kill now rests on an assertion
rather than on an argument: no scenario in the manifest completes a request
after its last checkpoint, so a send whose params stopped matching always
suppressed an observation a golden holds.

Known-open holes loses its prose count and becomes a list that names the site,
the mutant and why no scenario can see it. Two entries join it: the display-mode
acceptance, which no call site reads, and the startup timer's attached-terminal
guard, which needs an adapter that can attach a terminal mid-scenario.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): interpret the activation reply where it is reported

`reportActivationOutcome` took a verdict, which left the timer site hand-building
`{ accepted: false }` for the case where there is no reply to interpret at all.
Taking `RpcResponse | null` and interpreting inside puts the operation's own
policy at both sites and spells the absent reply as absence. Nothing is lost:
`worktreeActivate` reads an unchecked payload and admits every success, so its
`interpret` cannot throw on a reply either site can receive.

No golden moves; the effect digest is repinned.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): give the create family its mount step back

The create adapter mounted inside its first action so the create would run
ahead of the flush that made React pay its one lazy `Math.random()` draw.
#21088 pays that draw in the scheduler before it installs the seed, so the
position of the mount no longer decides which seeded value `clientMutationId`
reads, and the family goes back to the shape every other one uses: a declared
`mount` step carrying the cells the hook reads as it renders — the worktree,
the active tab, the device token — and a `create` step carrying the launch
options it passes.

The display-mode family keeps mounting from its `mount` action, which is that
same declared shape and never was the workaround.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): re-record the corpus at main's pin 9add08bb59

Recorded at main's baseline in a detached-pin worktree with this branch's
recorder, adapters and manifest. Main's own recorder on that pin reproduces
main's 705 goldens byte-for-byte first, and this run reproduces the same 705
beside the 22 this branch adds, so the corpus is main's plus this family.

Every body moved against the branch's previous recording: main replaced the
`sent` request count with the shared write ordinal, which stamps every sender
call, payload and effect. Six goldens moved headers only, all of them
scenarios that send nothing and write nothing, so they had no entry to stamp.

Counts follow the corpus: 727 goldens, 888 tests. The corpus still carries no
`reply-salvage` effect — the 22 added goldens contribute no checked read at
all, since this family's readers are the unchecked ones the inventory lists.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 19:39:53 -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.ts — DESKTOP_PROTOCOL_VERSION, MIN_COMPATIBLE_MOBILE_VERSION
  • mobile/src/transport/protocol-version.ts — MOBILE_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_CHAT_AGENT=omp — with MOCK_NATIVE_CHAT=1, present an OMP tab and four decoded transcript messages, including a tool call and result, instead of the default Claude scenario. It deliberately omits transcriptPath to exercise legacy-hook readability discovery; current OMP hooks may report a path.
  • 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