Files
orca/mobile
Jinwoo Hong d253dd0e1d fix(mobile): the page bridge accepts every reply native accepts and settles what it refuses (OTA phase C, C0.8) (#21511)
* chore(mobile): repin the recording corpus to main's tip (OTA phase C, C0.8)

C0.5 pinned `baseline` to its own branch commit, which the squash-merge made
unreachable, so `rpc-recording-pin-guard.mts ancestry` fails on main and
`--record` refuses to run at all. Repin to main's tip and refresh every header
from it.

Header-only, and the corpus proves it: across all 787 goldens exactly two
distinct lines changed, the old `baseline` and the new one. `recorderSha256`
did not move, because nothing under the recorder's own directory did.

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

* fix(mobile): accept at the page bridge every reply the native client accepts

`BridgeReplyPayloadSchema` required `_meta` on both arms. The predicate the
native client applies to a reply off the wire, `transport/rpc-response-shape.ts`,
requires none, and `src/shared/runtime-rpc-envelope.ts` — the envelope clients
and runtimes share — makes `_meta` optional on a failure with a nullable
`runtimeId`. The page's reader was strictly narrower than the transport it
stands in for, so replies the phone accepts today were refused, dropped with a
diagnostic, and settled nothing.

The reader is now that predicate itself, imported rather than restated: one
module owns the shape, and a widened reader is safe in both directions. `{ ok:
true }` with no `result` key stays refused, because `isRpcResponse` drops it too.

Killer test: `the reply reader is the native acceptance predicate` compares the
page's verdict against `isRpcResponse` over eleven payloads; six of them were
red before this change.

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

* fix(mobile): settle the exchange a refused bridge frame was answering

A host frame the page's reader refuses was reported and dropped, and the
request it answered stayed pending for the life of the document. A screen has
no recovery from that: `sendRequest` never settles, so nothing rerenders and
nothing retries.

The page now salvages the id out of the refused frame, through the same caps
the reader applies, and settles it — but only when it already holds that
exchange, so a salvaged id reaches nothing the page did not open itself. A
request rejects with `BridgeReplyRefusedError`, which carries the refusal and is
now marked delivery-unknown at construction: the shell answered, so the desktop
has already run the request and a caller told this was a definite failure would
offer to retry what already happened. A subscription ends the way a refused
`subscribe` ends today.

`bridge-rpc-client.ts` was at the 300-line ceiling, so the inbound routing moved
to `bridge-client-inbound-frames.ts` and the diagnostic vocabulary, which both
sides raise, to `bridge-client-diagnostics.ts`. No `max-lines` disable.

Killer tests: `settles the request a reply it could not read was answering, on
the same turn` (hung to a 5s timeout before) and `ends the stream an event it
could not read belonged to`.

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

* test(mobile): name what the bridged replay still excludes, and ratchet the pin

The class this harness was landed to name is closed, so the pin says so:
`reply-meta-required` is 0 and has no exclusion reason, which is what
`divergence-classes.test.ts` now requires of any class the run is allowed to
count. `BRIDGED_PARITY_EXCLUSIONS` gives each remaining class the reason it is a
bound on the claim rather than a defect, the run prints the excluded total
beside those reasons, and one assertion states the whole claim: nothing diverges
that no predicate has named.

Measured, not argued, for `params-undefined`: all ten scenarios that send an own
property valued `undefined` already record the key as absent from the bytes
their request put on the wire — `tw-smart-search-all-providers` records
`{"filter":"assigned","limit":50}` with `workspaceId` already gone — so the
bridged run sends the identical frame. What differs is the object
`ScriptedRpcTransport.complete` matches a scenario step against, one level above
any serialization. The projection named as the fix is not where it lives:
`projectMobileRpcRequestParams` rewrites `worktree.ps` alone, none of the ten
calls it, and the bridge host forwards into the same `StableLogicalRpcClient`
the native screens hold, so there is no shell-side copy to move.

`divergence-evidence.test.ts` asserted the narrow reader that the `_meta`
widening removed; it now pins that the counterfactual changes no verdict, which
is what makes it a detector for that reader coming back.

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

* docs(mobile): say the remaining classes total 391 in one line

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

* fix(mobile): read the throw a bridged golden died on, not just the scenario

The `params-undefined` arm named a class from two facts that never met: the run
threw, and the scenario somewhere scripts a param key valued `undefined`. Any
failure inside those ten scenarios was that class, reported by nothing. A seeded
wire bug — one extra own key on every request's params — put 627 goldens in
`unclassified` and still left `params-undefined` at exactly 33, all corrupted;
scoped to `linear.listIssues` alone it stayed green.

The evidence now carries the step the scripted transport refused, the paths the
scenario values `undefined` on it, and every path where the params that arrived
differ from the ones it scripts — read off the frames the page posted, not off
the message. The class needs every path that moved to be one of the scripted
`undefined` ones, and at least one to have moved.

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

* fix(mobile): cancel the stream a refused frame belonged to, not just end it

Ending a stream deletes the page's record and posts nothing back. That is right
for `end` and `error`, where the shell has already retired it, and wrong for a
frame this reader refused: the shell is still serving that stream, so later valid
frames on the id were dropped with no diagnostic, no ack went back, and the only
release left was the host's overflow backstop, which counts unacked frames and so
never fires on a stream that has gone quiet. A newer shell adding a member to a
closed list — a new `binary.format`, say — lands exactly there.

`abandon` posts the cancel and tells the listener; `failExchange` picks it over
`end` for the one path where the shell has not let go.

Six goldens move to a new named class for what the cancel changes in the replay.
The unsubscribe it publishes is a physical payload the native run has no
counterpart for, and it takes the recorder's next occurrence name for that
method, so the scenario stops matching before there is a recording. Four came
from `result-absent-observation` and two from `write-ordinal`; the sum over the
corpus is unchanged and nothing stopped replaying byte-identically.

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

* test(mobile): hold the bridged pin to the size of the corpus

Each class in the pin is an upper bound and `identical` a lower one, so nothing
stopped a single class being loosened on its own: raising `params-undefined` by
one passed every assertion the flagged run makes. The comment already claimed the
total was fixed at the corpus; this counts the goldens on disk and checks it.

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

* test(mobile): name the two refusals that can settle nothing at all

`oversized` is decided on the raw string and `malformed-json` on a parse that did
not finish, so neither frame ever yields an id and neither can settle the
exchange it was answering. Nothing on the page settles those: `close` or a shell
replacement releases the slot, and otherwise it is held for the life of the
document. Neither arises from a host that is behaving — it chunks at the frame
cap and answers a body over `BRIDGE_MAX_REPLY_BYTES` with an `error` frame — but
the boundary was unstated and untested, which is how it reads as covered.

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

* docs(mobile): say which way each number in the bridged pin may move

The comment said the pin moves down and never up, two lines above a class that
went from 338 to 341 when a fix changed which difference a run meets first. Both
are true of different halves of it, so say which: `identical` only moves up, a
class only moves down, and two excluded classes may trade members in one edit
that leaves the sum alone. The sum is now checked, so that trade cannot hide a
golden that stopped replaying byte-identically.

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

* test(mobile): pin which goldens are in a class, not only how many

A count is blind to a trade. Every predicate reads the scenario rather
than the frame the page refused, so a golden that started refusing for
real can walk into an excluded class while another walks out, and the
counts, the sum and the `identical` floor all still hold.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-18 16:52: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 run check:tests-typecheck
pnpm lint
cd ..
pnpm typecheck:node

tsc --noEmit reads tsconfig.json, which excludes test files so Metro never bundles them. tsconfig.test.json puts them back, and pnpm run typecheck:tests shows their errors in full. check:tests-typecheck is the gate over it: a ratchet against tests-typecheck-baseline.txt, the 127 test files that do not typecheck yet. It fails when a file that checks today stops checking, and when a baseline entry starts checking (prune it with node scripts/check-tests-typecheck-ratchet.mjs --prune). The list may only shrink.

The same gate censuses the program first: every *.test.ts(x) on disk must be in it, or named in the script's TESTS_OUTSIDE_PROGRAM with a reason. Without that, a test excluded from tsconfig.test.json — or a Foo.test.tsx shadowed by a Foo.test.ts beside it, which a wildcard include drops for the higher-priority extension — would leave the ratchet silently.

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_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