* refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's `recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its merge with main conflicted on that one line in 153 files; every future domain PR would collide with every other in flight the same way. Split the directory at a real seam instead of a filename convention: `adapters/` holds one module per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers the engine only. A new `adapterSha256` covers the source of the module that mounts each operation a golden's scenarios drive, read off the same `mounts` calls that build the table the recording runs against, so the pin cannot name a file the runner did not use. Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file, and an adapter importing a sibling each fail. The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new header field; the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the split recorder/adapter digest Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers `adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and recording ran against the same pinned product tree. git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l 0 The seven `adapterSha256` values partition the 153 goldens by the module each was recorded through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory, 9 tasks, 8 workspace settings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): stop pinning goldens to recorder inputs no recording can read The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the per-family mutant registry beside it, and the probe-hole witness. None can change a recording -- the loader consults a mutant only when a mutant test asks for one, and no suite but the two recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have and charged every domain a full re-record for it. `mutants/` now holds the table, the registry, the reference states, the mutant suites and the probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts` checks exactly that, and fails if an engine file names the directory or anything outside imports from it. `recorderSha256` also pins only the suites in `recording-drivers.ts`, which `scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or writes one to a scratch directory, is no longer provenance for a recorded file. `OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden. Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to `root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed anything. Each call now spells the root differently. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens under the mutant and driver exclusions Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153 because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that module now carries its own exposure declaration. git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256)":' | wc -l 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): restore the preferences actions the merge resolution dropped #20568 added `resume` and `trust` actions to the `settings.task-preferences` adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the `pilot-mount-adapters.ts` conflict in favour of the registry merge silently discarded them and `tw-task-preferences-resume-write` failed to record at all ("Missing or completed request: ui.set#1"). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens at main's tip after the merge All 208 goldens, header-only. `baseline` moves from50e752fc66to main's tipc6a7216984, `goldenFormatVersion` from 4 to 5, `recorderSha256` to the value of the engine with `adapters/` and `mutants/` carved out, and `adapterSha256` is new on every file. Nine distinct adapter digests over 208 goldens: each golden now pins only the module that mounts it. No observation moved. The whole-diff census against origin/main reports exactly four changed keys and nothing else: 208 "adapterSha256": 416 "baseline": 416 "goldenFormatVersion": 416 "recorderSha256": Recorded in place rather than through the README's detached-baseline dance: this branch changes no product file, so its tree at the merge is byte-identical toc6a7216984under mobile/src, src/shared and the lockfile, and the parity claim stays non-circular. README says so now. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): hold the recording drivers to the engine's mutant-seam rule The name scan exempted every `.test.ts` on the ground that a test cannot change a recording. Two of them can: the recording drivers are the recording path. A driver that read the mutant table by path rather than importing it passed both seam checks — the import scan sees no import, and the name scan waved it through as a test: const table = resolve(import.meta.dirname, 'mutants/operation-mutations.ts') console.log(readFileSync(table, 'utf8').length) at the top of `pilot-recordings.test.ts` gave 2 passed before, and after this change fails with ["pilot-recordings.test.ts"]. Only non-driver tests are exempt now. This file lives in `mutants/`, which `recorderSha256` skips, so no golden moves: the recorder suite is green on the existing 208 with zero dirty. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop the registry parameter no caller varies `pilotMountAdapters` took `registered` so a caller could mount a different module set; all six callers take the default. The header-digest tests vary the registry through `goldenRecording`, which keeps its own parameter and is where the stub roots need it. Engine source, so `recorderSha256` moves and the goldens follow in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens after the registry parameter came out All 208, `recorderSha256` only. The re-record against the previous commit moves 416 lines, every one of them that field: 416 "recorderSha256": Against origin/main the picture is unchanged from the merge: 208 goldens, 0 added or deleted, 0 non-header lines, and exactly four keys differing — 208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256" Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): wrap the recording README at the width the rest of it uses Seven lines this branch added ran past 100 columns, worst 124. No wording changed. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): name the worktree overlay, not the archive that cannot work `git archive` was offered alongside a detached checkout as a way to lay this branch's recorder over the pinned baseline. It cannot work: the fence in scripts/rpc-recording.mts runs `git diff --quiet <baseline>` and an untracked-file check, both of which need a real `.git`. In an archive tree git exits non-zero for lack of a repository and the script reports "Product sources or lockfile differ from the pinned main baseline", which reads as a product mismatch that is not there. The transport agent lost time to exactly that. Names `git worktree add --detach` only, and says what the misleading failure looks like if someone tries an archive anyway. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): close two ways an adapter module escapes its own digest Two holes, one class: the seam was checked by how an import was spelled and by what the register's values evaluated to, never by where they resolve or where they were written. Inward imports: the scan dropped every specifier starting with `..`, so `'../adapters/settings-mount-adapters'` climbed out of the directory and back into it unseen. A reviewer had `new-tab-agent-mount-adapters.ts` project a value read from the settings module, edited that module, and watched the mounted state change while the new-tab adapter digest held. Specifiers now resolve against the directory and anything landing back inside it fails: ["new-tab-agent-mount-adapters.ts imports ../adapters/settings-mount-adapters"] The register: `adapters/mounted-operation-modules.ts` is pinned by nothing — `recorderSha256` skips the directory and `adapterSha256` reads each entry's `source`. An `exposes` written inline there drives the mounted product module with no digest covering it. The same reviewer replaced the new-tab entry's `exposes` with a literal overriding `loadMobileNewTabAgentOptions`; twelve fence tests passed. Both `mounts` and `exposes` must now be identifiers the register imports from that entry's own module: ["new-tab-agent-mount-adapters.ts writes exposes inline instead of importing it"] Checked on the register's syntax, not its values, because an inline literal and an imported binding are indistinguishable once evaluated. Pinning the register in the engine digest would also close it, and is the wrong trade: every domain adding a register line would re-digest all 208 goldens, which is the conflict this PR exists to remove. Keeping the register an index costs nothing and keeps a domain's line local. Both fixes live in a `.test.ts` outside the drivers, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the mutant seam from the drivers out, not by spelling The seam rested on a grep for the literal `mutants`, which the exported `MUTANT_DIRECTORY` spells without containing. A reviewer had `pilot-mount-adapters.ts` read the mutant table through that constant and both checks passed. The README's claim — that nothing on the recording path names the directory — was false as written. Three changes, in order of strength: Reachability is now proved forward. The suite walks the static import graph from the two recording drivers and fails if any module under `mutants/` is in it. That answers the real question, what a golden's bytes can depend on, instead of the old inward scan's question, who mentions this directory. Non-emptiness is asserted on both sides so a graph that resolved nothing cannot pass by reaching nothing. The name scan covers both spellings, for paths a module can be read by rather than imported. The reviewer's probe now fails as ["pilot-mount-adapters.ts"]. `MUTANT_DIRECTORY` is no longer exported. Its two consumers were both tests of the digest, and they now spell the path instead, which is strictly better for them: a test that imports the constant follows a rename silently, while one that spells it fails on a rename — and that specific directory name is the whole soundness argument. This edits `recorder-digest.ts`, so the goldens re-record in the next commit. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the RPC goldens after MUTANT_DIRECTORY stopped being exported All 208, `recorderSha256` only. Against the previous commit the diff is 416 lines and every one of them is that field: 416 "recorderSha256": Against origin/main, unchanged: 208 goldens, 0 added or deleted, 0 non-header lines, four keys differing — 208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256" Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the mutant seam's actual argument, and its edge The README claimed nothing on the recording path names `mutants/`. That was the old inward scan's claim and a reviewer falsified it with the exported constant. It now describes what the check does: a forward walk of the import graph from the two recording drivers, plus a name scan in both spellings for read-by-path, plus the constant no longer being exported. It also names the case neither closes — a path assembled from fragments at runtime. Markdown is outside `recorderSha256`, so no golden moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): prove the engine/adapter seam in both directions The inward scan only held adapters to the seam. An engine file importing an adapter executes code its own digest skips and that every golden recorded through another domain leaves out of `adapterSha256`, so the register is now the only crossing allowed from the engine side. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): name what the driver walk missed instead of counting it Seeding `seen` with the drivers made the driver-presence check true by construction, and the size bound compared a graph inflated by `typeof import` product modules against a recorder-sized number. Both go; the walk now reports the recording files it failed to reach, which is empty today and names an orphan engine file the moment one appears. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): reflow four paragraphs left ragged by the rewrap Orphan fragments only, no wording change: the golden-schema field list, the mutant-evidence paragraph, the probe-witness sentence and the re-anchor note. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the dictation, terminal, notification and browser domains against main Adds scenarios and mount adapters for six small feature areas before any product file moves, so the migration that follows has a frozen main oracle to be measured against: dictation setup and session, agent history, terminal input and viewport refit, push registration and the hosted browser's pointer, keyboard and dialog commands. Five new adapter modules, one per domain, each pinned by its own `adapterSha256`. Two declare exposures: `sendRegister`/`sendUnregister` are module-private in push-registration and their exported callers read the keychain host catalog first, and the history hook reaches its client through the shared per-host context rather than a parameter, so the provider is the mounting boundary. `native-mounting-substitutes.ts` is copied verbatim from the transport migration (#20667) so the two branches merge, extended with the members these hooks read: `AppState` and `useWindowDimensions` on react-native, the two-way audio module, `expo-keep-awake`, and `buffer`. Every device event source is inert — no listener is fired — because each send is driven through the operation's own API instead. Wiring the substitute table into the loader moves `recorderSha256`, so every golden's header re-digests. The product tree is unchanged, so `baseline` is unchanged and the 208 existing goldens move exactly one line each; no observation moves. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): send the dictation, terminal, notification and browser domains through typed RpcOperations Replaces 42 raw `sendRequest` reaches across six feature areas with declared operations: dictation setup and session, agent history, terminal input and viewport refit, push registration and the hosted browser's page commands. No wire change — every golden recorded in the previous commit still compares byte-identical, which is the evidence. Acceptance is preserved site by site rather than unified. Every dictation and browser refusal already raised the host's message with a screen fallback, so those share one policy and keep their own copy at the call site. The terminal's two input sites read one boolean off an object result, which `object-result-or-null` gives them without a throw. The worker-takeover report and the three enrichment reads in the resume sheet are skips. Two latent behaviours are preserved deliberately rather than tidied. `repo.list` reads `.repos` at the return statement, so a null result throws a raw TypeError there and not a wrapped refusal message; the three enrichment reads beside it are optional-chained and tolerate the same null. `speech.dictation.finish` checks its refusal before the staleness guard and reads `.text` after it, so the member read stays at the call site. `RpcSendArguments` now admits an explicit `null` where the catalog declares no params. Four shipped senders put `params: null` on the wire for such a method, and a frame carrying a null is not the frame that omits the key; without this the migration would have rewritten those bytes. `src/settings/native-voice-settings-operations.ts` widens its client type for the same reason — it holds the port only to hand it to dictation. Three references are left behind, each listed in the inventory with what blocks it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): matrix the dialog-dismiss site and name the three reaches left behind The reply matrix drives every reply a family's base scenario scripts, and the browser dialog family scripted only the accept leg, so repointing `browser.dialogDismiss` at another method survived the whole suite. A scenario for the dismiss leg closes it; the mutation is killed now. The inventory loses five section headers that no longer list anything, and the three entries that resisted migration each carry what blocks them: a `worktree.ps` inside the history screen component's own effect, an unsubscribe closed over inside a `subscribe` callback, and a `notifications.getMissedSince` gated behind the OS notification tray and the keychain host catalog. Faking either of the last two would record device state, not a wire. Eighteen goldens move on `adapterSha256` alone: removing two inner casts from the browser and history adapters re-digests the goldens mounted through them and nothing else. All 263 were re-recorded from the pinned baseline and compare byte-identical to the previous recording apart from those headers and the new file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): fold five refusal-to-message spellings into one helper The same three lines appeared five times in this PR: interpret a reply, and turn a refusal into a plain Error carrying the host's message or the screen's copy. `interpretOrThrowRefusalMessage` lives beside `refusedRpcMessageOrFallback` and takes the interpretation as a thunk, so the caller still awaits the request outside the catch and a transport rejection reaches it as the object the transport threw, delivery-unknown mark intact. That removes the browser hook's own `assertBrowserCommandAccepted`, whose first parameter named an operation the helper then used only to call `interpret`. Every browser page command is built by one factory with one acceptance, so substituting one operation's `interpret` for another's was unobservable and the parameter read as load-bearing when it was not. The call sites now name the operation where they request and where they interpret, the shape the dictation sites already used. Main's eight source-control spellings are deliberately untouched; folding those in is its own PR. No wire change and no golden moves: `git diff --name-status origin/recorder-adapter-digest...HEAD -- mobile/rpc-foundation` is byte-identical before and after at 55 added and 209 modified. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): record the history screen holding for a late worktree list The hook holds loading when a scoped tab has no active worktree and the worktree list has not arrived, rather than firing an unscoped scan that would briefly show unrelated host history. The adapter hardcoded `worktreesLoaded` to true and its `worktrees-loaded` action was dead, so no golden reached that branch and the hold was unrecorded. The list and the flag now move together, which is how the screen learns them, and `mount` takes `worktreesLoaded: false` to start unloaded. The new scenario records the hold, then the late load. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the goldens at the pinned baseline Recorded from a detached worktree atc6a7216984with this branch's rpc-recording/ and pilot-scenarios.json overlaid, per the README's migration-branch procedure. Only header fields moved; no body line in any golden changed. recorderSha256 moved on all 208 pre-existing goldens: this branch adds native-mounting-substitutes.ts at the top of rpc-recording/ and routes operation-module-loader.ts through it, and both are recorder-engine inputs. That resolves itself when #20667 lands the same substitution on main. adapterSha256 moved on exactly four goldens, all from the late-worktree-list scenario's edit to adapters/agent-history-mount-adapters.ts: aivault-history-scan-fulfilled, aivault-history-scan-unsupported, matrix-aivault.history-aivault.listsessions-1, matrix-aivault.history-status.get-1. aivault-history-scan-worktrees-late.json is new. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): ask these call sites for a request port, not a whole client The migration widened seven files from `Pick<RpcClient, 'sendRequest'>` to the full `RpcClient` for no reason: a bound operation's `request` takes `UnvalidatedRpcRequestPort`, which is structurally that same single member. Name the port instead, so the signature says what each function actually needs and a caller holding only a port still satisfies it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): make these adapters reject an action no scenario dispatches Each of the five new mount adapters ended its `action` with an unnamed default, so a misspelled or engine-introduced name silently ran the last branch: probing `remount` on the history adapter ran `onRefresh()` and put `status.get` on the wire. Twelve pre-existing adapters throw `Unknown X action` instead; these now do too. Deleted with it: every branch no scenario can reach. `unmount` is dispatched only by the lifecycle derivation in derived-goldens.ts, which is restricted to LIFECYCLE_BASES, and none of these scenarios is in it; teardown goes through `dispose`, which already unmounts. Same for the history adapter's `retry`, `select-scope` and refresh, the dictation start flow's `disable`, and the viewport adapter's `reconnect`. None of them handled `remount`, which the schedule driver always pushes after `unmount`, so the pairing was never whole. The three single-action entries keep `_name`, matching six pre-existing entries that do the same. Goldens move on adapterSha256 only and are re-recorded separately. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): drop four fallback strings no catch can read Both pointer paths wrap their whole sequence in `catch {}` with an empty body, so the message the refusal helper builds is discarded. `interpret` already throws on a refusal under `require-result-or-throw-message`, and the helper only rewrote the text, so removing it keeps the same control flow: the sequence still stops at the refused leg, the later commands still go unsent, and `setError` still does not run. The mousemove matrix golden records `error: null` under every refusal and transport shape either way. The helper stays where the throw reaches a caller: dictation and agent history. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read the viewport outcome where the reader is `readTerminalUpdateViewportOutcome` had one caller, the reader that wraps it, so the name bought a second file to open and nothing else. Inline the two comparisons and keep the outcome type where it was. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record this branch's goldens after the adapter cleanup Recorded from a detached worktree atc6a7216984, this branch's rpc-recording/ and pilot-scenarios.json overlaid, per the README's migration-branch procedure. 266 recordings, all reproduced. adapterSha256 moved on all 56 goldens this branch owns, because the named throws and the deleted unreachable branches changed all five adapter modules. No other header field moved, and no body line in any golden changed: the wire, the effects and the state snapshots are identical, which is the claim the five review deletions rest on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): reach the narrow port type without naming the port module The previous commit named `UnvalidatedRpcRequestPort` by importing the port module, and that fails the boundary ratchet: it counts an import of `unvalidated-rpc-request-port` as reach, so all six unlisted files became offenders and the history panel went from 1 reference to 2. Main's `Pick<RpcClient, 'sendRequest'>` fails it for the same reason, by a different rule: a bare `'sendRequest'` string literal is counted too. That is why the migration widened these signatures in the first place, so the review finding's premise that it was done for no cause is wrong. Only the full `RpcClient` scored zero. Re-export the port type from `rpc-client` instead. An export declaration with no module specifier is not counted, the seven signatures still say they need one sender rather than a whole client, and the inventory does not move. Holding a client already carries the same reach, so nothing new is opened. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * Revert "refactor(mobile): reach the narrow port type without naming the port module" This reverts92e679f587and16c9c232fcas one commit, returning the seven client signatures to the full RpcClient the migration gave them. unvalidated-rpc-request-port-boundary.test.ts:192 pins an import of the port type as one reach by design: naming the type is exactly what the ratchet retires, so re-exporting it from rpc-client opened an uncounted path for every future file. Widening to the client is the design's intended end state. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): restore this branch's native substitutes and re-record at the pin Taking #20667's landed `native-mounting-substitutes.ts` verbatim dropped the substitutes this branch's domains need, and five scenarios stopped recording: `speech-audio-chunk-acknowledged`, `speech-dictation-session-transcript`, `speech-dictation-session-cancelled`, `terminal-viewport-refit-applied` and `terminal-viewport-refit-legacy-desktop` all failed with `Missing or completed request`, because the hook throws on the native member before it sends. Both branches created that file independently; neither is a version of the other. Main's structure is kept whole — `partialNativeModule`, the `__esModule` rule, the async-storage trap — and this branch's boundaries are added inside it: `buffer`, `AppState` and `useWindowDimensions` on `react-native`, `@orca/expo-two-way-audio`, `expo-keep-awake`, and `expo-secure-store` as a second unusable store. Each is inert; no listener is ever fired and no audio is produced. That is an engine edit, so every golden re-digests. Recorded at main's pinc6a7216984from a detached worktree with this recorder overlaid, and the product tree there was byte-identical to the pin. All 397 goldens moved on `recorderSha256` alone and nothing else: git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \ | grep -vE '^(\+\+\+|---)' \ | grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion|baseline|scenarioSha256|lockfileSha256)":' | wc -l 0 Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record at the pin after the #20685 merge The merged recorder is a third engine: main's post-tasks-2 files plus this branch's native-mounting-substitutes.ts, so recorderSha256 moves once and every golden re-digests. Recorded from the pinned baselinec6a7216984in a detached worktree with this tree's recorder laid over it, so the product source is still main's pre-migration tree. All 509 goldens move on recorderSha256 alone; no recorded wire byte changes. 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