* test(mobile): record the golden corpus through the page bridge (OTA phase C, C0.5) `ScriptedRpcTransport` gains one seam: an optional `wrapClient`, applied between the recorder's own instrumentation and the logical client, so a recording can be driven through another transport without the runner learning which one. `runRecording` passes it through. The instrumentation moves outside the seam. `ordinal` orders a logical `sendRequest` against the recording's device writes and physical payloads, and the operation makes that call at the same moment with or without a wrapper; stamping it under one times the wrapper's forwarded send instead, which the unwrapped recording has no counterpart for. Measured: 131 goldens move their sender ordinal under the seam, none above it. The name a physical send is filed by therefore becomes a queue rather than one slot, taken on the inside of the wrapper — a wrapper that forwards on a microtask arrives after the next logical call has been made, and one slot hands both sends the second name. Underflow throws; a wrapper that drops a send is a finding. `rpc-recording-through-bridge.test.ts` is the harness: every golden recorded again with `BridgeRpcClient` over an in-memory port pair to a `createBridgeHost` holding the scripted client, compared body for body against the committed file. One FIFO per direction, delivery on a microtask, and the `init` handshake delivered in place before anything mounts. It is opt-in behind `RPC_FOUNDATION_BRIDGE=1` and does not pass yet. 391 of 787 goldens diverge, for four causes that are findings about the bridge rather than about the corpus; the suite's header names each one with its count, and flipping the gate is one line once they close. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): repin the recording corpus and refresh its headers (OTA phase C, C0.5) The C0.5 seam edits `scripted-rpc-transport.ts` and `run-recording.ts`, both inside `RECORDER_DIRECTORY`, so `recorderSha256` moves and every golden's header names an engine that no longer exists. `baseline` moves with it, from1e3795de99toddbb194585, which is main's tip and the tree these were recorded against; the recorder's fence is clean at that commit, tracked and untracked alike. Recorded whole rather than in part, as the README requires: a partial refresh would leave the corpus pinned to two different trees. The delta is header-only, and that is checked rather than asserted. All 787 goldens changed exactly two lines each, and across the corpus and the manifest the only keys that moved are `baseline` and `recorderSha256`, to one value each. No observation moved, which is the claim the pair of digests exists to make: the engine changed, what it observes did not. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): correct the bridged-parity counts to what the landed tree measures The counts in the harness's header were read off an intermediate run: 391 is the divergence with the reply schema's `_meta` requirement already widened, not the divergence this tree has. Against the tree as it lands, 763 of 787 goldens diverge and 24 replay byte-identically; widening `_meta` is worth 372 of them and takes the count to 391. The same intermediate run is behind a wrong sentence in this lane's first commit message, which says no sender ordinal moves with the instrumentation above the seam. Seven do, plus six payload ordinals, and they are the subscribe reorder cause 3 now names. 131 was the count below the seam. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repartition the bridged-parity classes from a measured run The header's last two counts were read off an intermediate measurement. Classified from the failure text of a run with the `_meta` arms widened, the 391 that remain partition 345 / 33 / 13, not 340 / 13, and the ordinal class is not a reorder on the wire: the page posts its frames in call order and they publish in that order, while the writes the operation makes above the bridge land a delivery earlier. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): let the shared bridge port pair carry any shell client The golden recorder needs the pair the bridge tests already have, holding its own scripted client rather than the fake, delivering the handshake in place so a screen can mount in the same turn, and able to answer one counterfactual: what the page would have done had the shell posted a field it does not. Every other test keeps the shape it had under `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): catch a wrapper that swallows a send instead of mislabelling the next The seam took the next name off the queue without checking it belonged to the method now on the wire, so a wrapper that rejected without forwarding left its name behind and the following payload was filed under it. Underflow already threw; this is the other half, and it is the half that is silent. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the bridged-parity partition by rule instead of by reading it Counting the classes by eye is how two of them drifted. Every diverging golden is now named by a rule over the frames and the scenario, each name is disjoint, and a golden that fits none of them fails the run. The first class needs a second replay with `_meta` supplied, because nearly every golden is refused some reply for that field and only supplying it says which ones the field explains: 372 / 338 / 7 / 33 / 13 over 787, with 24 byte-identical. CI runs it so the counts cannot drift again. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): repin the recording corpus and refresh its headers The seam's name check and the export the parity suite reads both sit inside `recorderSha256`, and the port pair the suite now borrows sits inside the recorder's fence, so the pin moves to this branch's tip and all 787 headers follow. Two lines per golden, both of them header: baseline and recorderSha256, and no body byte anywhere. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the recorder's own boundary ratchets green Two of them were red, and the first has been since the seam landed, because that commit was checked against the recorder's directory instead of the whole mobile suite. The engine may not import a suite, so the parity classifier moves beside the recorder rather than inside it, where the golden digest would also stop claiming a recording's provenance for a rule that only reads failures. And the raw-port ceiling for the scripted transport rises from five to seven: the seam needs one layer between the operation's call and the logical client, and its two references are what that layer costs. A named argument type takes back the third. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore(mobile): repin the corpus onto the ratchet fixes and refresh its headers Supersedes the refresh two commits back: the scripted transport moved again, so both the digest and the pin do. Two lines per golden, both header, no body byte anywhere. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stamp the counterfactual on replies only, not on events An `event` frame carries a `payload` too, so keying off the key rather than the type put `_meta` into subscription bytes. The page reads an event payload as `z.unknown()`, so nothing refused it and the classes did not move, but the tool that names a divergence must not author one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
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 port8081.
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
- Open Orca desktop.
- Go to Settings > Mobile.
- Scan the pairing QR code from the mobile app.
- 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
- Install Expo Go from Google Play
- Run
pnpm start, scan QR with Expo Go - For native modules:
pnpm exec expo run:android - Run with
pnpm start --dev-client
iOS Simulator
- Install Xcode from the App Store
- Run
pnpm start --iosto 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.ts—DESKTOP_PROTOCOL_VERSION,MIN_COMPATIBLE_MOBILE_VERSIONmobile/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— withMOCK_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 omitstranscriptPathto 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(defaultorca-mock-send-modein the system temporary directory) —accept(default) accepts the send,errorfails it withmobile_input_floor_unavailable, anything else reports the send as rejected.MOCK_TERMINAL_LIST_MODE_FILE(defaultorca-mock-terminal-list-modein the system temporary directory) —omitreturns an empty terminal list,otherreturns a list that omits the chat handle, anything else lists it.MOCK_TERMINAL_STREAM_MODE_FILE(defaultorca-mock-terminal-stream-modein the system temporary directory) —deadanswers a subscribe withsubscribedthenend(a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.
Connecting to Real Orca
- Start Orca desktop with WebSocket transport enabled
- In Orca, go to Settings > Mobile and scan the QR code with this app
- 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