* test(mobile): ratchet the 201 unchecked RPC reply readers Step 4 moved every call-site cast into an RpcOperation's `read`, but 201 of those readers still answer `compatible: true` for any payload: `rpcUncheckedPayloadReader` (163), `rpcReadUnchecked` (26 outside its own module) and `rpcUncheckedMemberReader` (12), across 42 files. The cast moved; it did not become true. Held as data with an AST boundary test, shaped on the raw-request-port ratchet: a file that is not listed fails, a listed file that no longer has one fails, and a count that rises fails. Only a call counts, so an import is not a reader and prose never is. No behaviour change: this commit adds a list and a test. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): validate the source-control domain's RPC replies at arrival Replaces all 17 unchecked readers in mobile/src/source-control/ with `rpcResultVariant(variant, schema)`, so a malformed reply is an `RpcIncompatibleReplyError` naming the operation instead of a TypeError three frames downstream. The inventory drops 201 -> 184 and the five source-control operations files leave it entirely. This is a behaviour change, scoped to malformed replies. Six reply-matrix goldens move; every named-scenario golden and every `normal` partition is byte-identical, which is the parity claim. Schemas live one module per reply domain, beside the operations that read them: git-status, git-compare, git-history, hosted-review and worktree-metadata. A member is required only where a consumer reads it unguarded, and each schema records the consumer line that justifies it. Nothing is `.strict()`; every reply a consumer publishes verbatim keeps `z.looseObject` so an undeclared host member still passes through. Six replies have no reader anywhere in mobile and get `z.unknown()`, which is the honest schema for them, not a holdout. Three readers stay total by construction, because their contract is that an unreadable reply is a value rather than an error: the `git.status` projection (a null status three screens route on), the `session.tabs.list` reveal (a null list means poll again) and the generated commit message (a screen's copy, never a decode error in a text field). They gain the salvage report, not a verdict. Consumers take the schema's output type, so `MobileGitStatusResult` and the branch-compare aliases now name what mobile reads rather than the desktop aggregate, and seven call-site casts are gone. Three requirements came from the goldens, not from the host types: `git.history` sends `timestamp: null`, `hostedReview.getCreationEligibility` sends a `reviewLookupOutcome` the shared union does not list, and the `git.status` projection writes an absent member as a present `undefined`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the six source-control reply-matrix goldens step 7 moves Six goldens, all on malformed partitions. Every named-scenario golden and every `normal` partition is unchanged, which is the parity claim for this step. git.history-read / git.history#1 result-absent, result-null, inner-ok-missing, inner-false-string-error, inner-false-object-error: the load rejected with a TypeError reading 'items' or 'map' off undefined/null; it now rejects with `incompatible_reply: git.history-page (git.history)`. hostedReview.eligibility + create-intent / hostedReview.getCreationEligibility result-absent, result-null, inner-ok-*: the fetch fulfilled with the error envelope itself, re-typed as an eligibility and published into the compose prefill; it now rejects, and both callers already route that to the same "eligibility unavailable" state a null answer produced. hostedReview.create-chain + create-intent / hostedReview.create result-absent, result-null, inner-ok-missing, inner-false-object-error: the create form showed the raw TypeError text "Cannot read properties of undefined (reading 'ok')"; it now shows the incompatible-reply message. Every header digest is unchanged -- baseline, recorder, adapter, scenario and lockfile all match -- so the diff is the behaviour and nothing else. Recorded from this branch into a scratch directory and copied in, because there is no scoped honest alternative: scripts/rpc-recording.mts refuses to run unless the product tree equals the pinned baseline, and the README's remedy for an intended behaviour change is to repin, which rewrites the `baseline` header of all 667 goldens. So these six now carry a pin whose tree no longer produces them. That is a real gap in the oracle's design for behaviour changes, not a detail of this step, and it needs a decision before this lands. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the four reply-schema properties the goldens found Each of these cost a reply-matrix golden while writing the source-control schemas, and none of them follows from reading the consumers or the host types: a newer host's undeclared members must still decode, `git.history` sends `timestamp: null`, `hostedReview.getCreationEligibility` sends a `reviewLookupOutcome` the shared union does not list, and the `git.status` projection writes an absent member as a present `undefined`. The `.strict()` case is the one worth stating twice: at the top level it rejects the reply, and on the entry it drops the row, which shows a dirty worktree an empty Changes list. The fifth test pins the salvage report that makes such a drop visible instead of silent. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give an unreadable reply a message a user can read `RpcIncompatibleReplyError` put `incompatible_reply: <op> (<method>)` in `message`, and `message` is what the screens hand to a toast. Step 7 is the first change that can reach this error at all, so the token would have shipped to users as its own error copy. Fixed at the boundary rather than per site: `message` is now plain copy, and the machine token moved to `code` (`incompatible_reply`) and `name` (`RpcIncompatibleReplyError`), both readable by callers. The cross-bundle fallback in `isRpcIncompatibleReplyError` matched on the old message prefix, so it now matches on `name`, which a foreign copy of the module still carries. No existing test pinned the old text. Two new ones pin the copy, the token and the foreign-copy match. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the recording baseline to this branch and re-record Commitadeb5f9531recorded the six moved goldens into a scratch directory and copied them back, which left them pinned to `e7206f62`, a tree that no longer produces them. That is the one claim the `baseline` header exists to make, so this replaces it with the README's remedy done in full. `baseline` is nowf741b2ea82, the last commit on this branch that touches a fenced path, so the recording fence passes in place and every golden is pinned to the tree that produced it. All 667 were re-recorded through `scripts/rpc-recording.mts --record`; none were hand-edited. Decoding every value pool against the branch pointb8d4cde09fsorts the corpus into 661 header-only moves where `baseline` is the only key that moved, 6 whose body moved as well, 0 added and 0 deleted. The 6 are the disclosed step-7 delta, unchanged at 69 moved observation fields across malformed reply partitions, plus the readable incompatible-reply copy fromf741b2ea82. No `normal` partition and no named-scenario golden moved. `scenarioSha256` hashes the derived scenarios, not the manifest, so the repin moves no other header key; the README section this adds records that, the scratch-copy failure mode, and the follow-up repin main needs after a squash merge. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): narrow the incompatible-reply error by instanceof, not by cast The two new tests inf741b2ea82read the error through `as` casts, which the changed-code casting gate rejects. An `instanceof` guard narrows the same value and checks the class at the same time. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the recording baseline to the branch tip and re-record71d8c6a1e2touched a fenced path (`mobile/src`), so the pin from5f3f184fdfno longer named the tree that produces these goldens. The fence compares the whole of `mobile/src`, and a test file is inside it, so the pin follows the last commit that touches a fenced path rather than the commit whose behaviour moved. Re-recorded all 667 in place through `scripts/rpc-recording.mts --record`. Decoding every value pool against the branch pointb8d4cde09fstill gives 661 header-only moves with `baseline` the only moved key, 6 body moves, 0 added and 0 deleted; the six and their 69 moved observation fields are unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the four source-control reads that had no oracle git.status (host payload), git.branchCompare, git.commitCompare and git.branchDiff were migrated to checked readers with no recording observing them, so a required member a host omits would have surfaced only in production. Three families mount the owners rather than the senders, because each reply is only visible in what the owner then publishes: the Changes screen's loader hook (git.status, and the base-ref chain and git.branchCompare it triggers), the history list screen (git.history and the per-commit git.commitCompare), and the committed-diff opener hook (git.branchDiff). Ten goldens: three pilot recordings and seven reply matrices. Two adapter capabilities this needed. An inert FlatList never calls `renderItem`, so the history adapter renders one row through the screen's own callback, both to reach the handler that expands a commit and to read the file list back; without that the commit-compare reply changes nothing observable. And `lowlight` joins `react` and `zod` as a real library rather than a refusing proxy, because the branch diff highlights on its success arm before the preview reaches state, so the shipped text arm was otherwise unrecordable. No golden recorded its absence, so only `recorderSha256` moves. Recording the same scenarios against4b0009d414, the pre-refactor tree, is the before column. Decoding every value pool across the two gives 11 body moves and 666 header-only, 0 added, 0 deleted: the 6 already disclosed, plus the 5 new matrices at 63 moved observation fields. What moved is the point. A malformed git.status used to leave Changes `ready` over the malformed payload and go on to fetch a branch compare; it now says the host sent a reply it could not read. An absent git.branchDiff result used to put "Cannot read properties of undefined (reading 'kind')" on the screen. An unreadable git.commitCompare used to spin the expanded commit forever; it now says "No file changes". Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the recording baseline to the merge commit and re-record The merge is the last commit touching a fenced path, so it is the only tree the recorder's fence can match. Every golden moves `baseline` and picks up main's `recorderSha256` from #20920; the six the checked readers changed are the only bodies that move against main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): note the merge-commit pin and unwrap the recipe's record command `format:check` from `mobile/` caught the wrapped inline command the recipe had been carrying since it landed; pointing at the command above removes the duplicate and the wrap together. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): open the source-control reply enums so a newer host's arm degrades A closed `z.enum` in a reply schema is a version claim, and it refused replies every declared reader could have rendered: a `git.branchCompare` summary status of 'shallow-base' failed the whole Changes compare, a 'codeberg' provider failed the whole eligibility, and a 'typechange' entry status dropped the row. Main passed all three through. `openEnum` in zod-salvage declares the arm set open: an unrecognised arm reads as a member the consumers already handle, while absence and a non-string stay fatal. Not `.catch()`, which would swallow those two as well. `area` stays closed and says why: every arm grants stage, unstage or commit, so there is no member to degrade to that would not offer an action against a row this build cannot place. Main rendered such a row in no section either. Also drops two claims the code does not back. Nothing reads the salvage report, so the two comments promising a dropped entry "arrives as salvage.droppedPaths" are gone, and `hostKind` on the non-text diff arm had no reader. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs: write down the open-enum rule and the header keys a branch moves Rule 4 in the wire-compatibility page, beside the three rules it belongs with: an enum arm set is a wire surface, unknown arms degrade rather than reject, and leaving one closed is a decision to state where the schema is declared. The recorder recipe's step 4 said `baseline` would be the only moved header key, which is only true of a branch that never touched the recorder. It now names the three digests a branch's own edits move, so a reader recognises a clean result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop the recorder's own timeout killing a full re-record The corpus records in ~110s warm and 160s under load, against a 120s budget, so a full re-record was killed roughly half the time. A killed run wrote a partial reporter banner and exited 1, which reads as a failing scenario rather than as a run that never finished — it cost two investigations here. The budget is now ten minutes, and a killed run says so. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the recording baseline to the open-enum commit and re-record `baseline` is the only header key that moves and no golden body moves: no matrix partition scripts an unknown enum arm, so the corpus cannot see this change. The eight schema unit tests are its only oracle. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): stop an unresolvable eligibility claiming the branch is not ready Both fallback prefills set `canCreate: false`, which is a determination nobody made. It short-circuits getMobilePrCreateBlockMessage before reviewLookupOutcome is read, so a malformed, refused or rejected eligibility told the user "This branch is not ready for a pull request yet." instead of asking them to retry. Dropping it leaves `canCreate` undefined, which is what "unproven" means here. Only a host that determined `canCreate: false` still gets the blocked copy. `area` now degrades to absent rather than staying closed. Dropping the row also dropped it from the unresolved-conflict gate, which grants create on a conflicted worktree; absent withholds stage, unstage and commit while keeping the row, since every area reader is an equality check. Its four consumers narrow explicitly: the diff-review queue filters unplaceable rows, the opener withholds the route, and the commit-failure prompt pins 'staged' where its own filter already did. `git.branchCompare` entries are nullish, matching the `?? []` its consumers use. Deletions: `MobileGitStatusProjection` and `uncheckedReaderCount` lose `export`, the boundary test drops its dead inventory self-file (the AST counter finds zero calls there, only prose), and `isRpcIncompatibleReplyError` is gone — it had no caller in mobile, desktop or e2e. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): formatting and a thrown rejection in the round-2 tests Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the recording baseline to the round-2 tip and re-record The round-2 eligibility fix is a behaviour change, so the corpus has to be re-recorded at a pin that includes it. Four goldens move body: the two create-intent eligibility matrices on every non-normal partition, and the two prefill scenarios that lose the fallback's `canCreate: false`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the recording baseline to the main merge and re-record The merge is now the last commit touching a fenced path, so the corpus has to carry its sha. No body moves against the pre-merge corpus: main's engine change shifts `recorderSha256` on every golden and nothing else, and main's fifteen step-6 goldens re-record byte-identical. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): admit the three unchecked readers #20954 landed The ratchet is a ceiling against this branch adding readers, not a claim about what main may land. #20954 brought `notification-stream-closed`, `native-chat-session-page` and `terminal-buffer-cleared`, so the merge has to raise those lines and say where they came from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): repin the recording baseline to the inventory commit and re-record The ratchet inventory is a fenced path, so admitting #20954's three readers moved the fence head again. Baseline only; no body moves against the merge re-record. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): send the host's own provider token back instead of a fallback `provider` is not a member mobile only reads. The eligibility reply names it and the create call returns it, so `openEnum(..., 'unsupported')` did not soften a reading — it rewrote the bytes, and a host that had just named `codeberg` refused its own provider as unsupported. The action-sheet Create path has no provider gate, so nothing caught it. Passes the token through as a string from the reply to the create params. The allow-list that decides whether mobile may create stays supportsHostedReviewCreation(), which already answers no for a token this build does not know; its parameter widens to `string`, since answering for an unknown token is the whole job. The worktree-link switch gains a default, which also fixes an older hole: an unrecognised provider used to fall out of the switch as `undefined` params. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the provider pass-through in the corpus Repins to the provider fix and records `sc-create-intent-unlisted-provider`, whose eligibility reply names `codeberg` and whose recorded `hostedReview.create` params carry it back unchanged. Restoring the old enum fallback fails that golden on `Request params mismatch: hostedReview.create#1` and nothing else. 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 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_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