* test(mobile): record main's file-preview and markdown-disk-fallback replies
Four of this branch's read sites had no malformed-reply coverage, so the reader
change would have had nothing to move at them. `familyGoldens` matrixes only the
first scenario of each family, and `files.preview-load`'s base is the grant-refresh
chain while `session.tab-documents`' is the served markdown tab — which left
`files.read` and `files.readPreview` on the worktree preview path, the artifact
image read, and the markdown tab's on-disk fallback recorded on their success path
only. This commit is the before picture, taken from main's own tree with no product
edit in it.
Three new families, five scenarios, ten goldens:
- `files.preview-worktree-text` / `files.preview-worktree-image` — `files.read` and
`files.readPreview` as the preview screen asks them for a worktree file.
- `files.preview-artifact-image` — `files.readTerminalArtifactPreview`.
- `session.markdown-disk-fallback` — the `files.read` leg a headless host's
`renderer_unavailable` sends the markdown tab down. It carries a second scenario
that serves `markdown.readTab`, because a matrix site needs a fulfilled reply
recorded somewhere in its own family to replay as the `normal` partition.
No existing scenario moved to a new family and no adapter changed, so every
pre-existing golden keeps its `adapterSha256` and `scenarioSha256`. Recorded in a
detached worktree at the manifest's pin (`4b876758d3`) with this manifest copied in;
the control is that all 748 pre-existing goldens came back byte-identical to
origin/main's, which `git diff c2962a765a -- mobile/rpc-foundation/goldens` confirms
as empty.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): checked reply readers for files, dictation, host-screen and agent-history
Thirty-five unchecked reply readers across seven files become checked zod readers,
so a malformed host reply surfaces as one readable error at the operation boundary
instead of a downstream TypeError, a rendered `undefined`, or a screen left ready
over garbage. Deliberately a behaviour change on malformed replies only: every good
reply decodes to the same value it did, which the `normal` partition of every
matrix golden holds byte for byte. Nothing on the wire moves — no method, params,
options, timeout or acceptance policy changes at any site.
The inventory drops from 137 readers over 31 files to 102 over 24.
What each domain checks, and what it deliberately does not:
- files/preview — one schema for `files.read` and `files.readTerminalArtifact`, one
for the two preview methods. `content` is required on the text pair because the
markdown disk fallback publishes it into the tab with no guard; the image pair
requires nothing, because normalizeImagePreviewResult guards all four members and
the host's own "binary I cannot preview" and "not actually an image" arms are good
replies the screen renders today.
- files/tab-doc — stricter than the preview screen on the same two methods, because
a tab publishes what it read into a typed ready document with no guard. `git.diff`
reads as two variants, and an arm this build has not heard of takes the binary one
rather than refusing the reply.
- files/explorer — the directory listing is an array and a row needs the name and the
directory flag the tree projection turns on; the legacy capped list needs its rows'
paths and the truncation flag its note draws.
- files/ownership — the two members that decide *where a write lands* are fatal on a
wrong type rather than salvaged, because absence reads as `local` downstream and a
salvage would send a mutation to the wrong host. `hostId`'s absent/null/string
states stay distinct, and the SSH connection generation passes through at its own
type because the mutation echoes it back to the host.
- dictation — the setup the sheet renders is checked; the model rows need the `id`
the sheet keys and sends back. The five sends whose reply body no call site reads
keep an unknown payload, and so does `speech.dictation.finish`, whose transcript is
read past a staleness guard that a reader throw would move the failure across.
- host-screen — the repo catalog, the SSH labels and the host platform. The four
writes read no reply body; `worktree.activate` stays opaque because the session
route's second report site awaits it outside any catch.
- agent-history — the capability gate and both scan containers. The session rows stay
unknown on purpose: `agent` is a vocabulary that grows with every agent CLI Orca
learns to scan and that this client echoes back on resume, so narrowing it would
refuse a newer host's reply or drop the very sessions it added.
Two shared readers were widened to take the strings the reply readers hand them —
`getRepoExecutionHostId` and `buildRepoHostIdByRepoId` — because both already answer
`local` for a host-id spelling they cannot parse, and closing that spelling in a
reply schema would refuse a newer host's own catalog.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the corpus and re-record the checked reply readers
`baseline` moves to this branch's last fenced commit, which is what `--record`
refuses without: main's fenced tree drifted past the session domain's pin when
#21114 and the dependency bump landed, and the product edit in the commit before
this one moves it again.
Every body move is confined to a malformed partition of a family this branch
touched. No `normal` partition moved, which is the byte-for-byte control on good
replies, and no golden outside the seven files' families moved at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop the dictation reader requiring a mode main rendered without
The setup sheet's `normal` partition refused after the reader landed, which is the
success control saying the schema was wrong rather than the fixture: `dictationMode`
was declared required because the one unguarded consumer pushes it into a
`useState<'toggle' | 'hold'>` and cannot invent a value, but main rendered a sheet
whose reply omitted it, and requiring a member no consumer crashes on is exactly the
version claim Rule 1 of the remote-wire contract warns about.
The member is salvaged now and keeps its open arm set, so an unknown mode still
degrades to `toggle` rather than to one that matches no segment. The native-chat
refresh spells that same `toggle` for an absent mode, which is the value its state
already started at, and the route parity pins are refreshed for the one literal and
the two callback bodies that moved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin past the dictation fix and re-record
Second repin of the branch: the fix to the setup reader is a fenced-tree change, so
`--record` refuses until `baseline` names it. The speech family's `normal` partition
is back to main's projection, which is what said the first reader was wrong.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): mutant evidence for the checked reply readers
Three mutations applied by hand, run, and reverted, recorded beside the adapter
family mutations in the same shape. They are kept in their own file because a reader
mutation is not killed by a pilot scenario: a pilot serves a good reply, and a schema
that has stopped checking a member reads a good reply exactly as before. What kills
them is a matrix golden's malformed partition, the schema's unit pin, or a consumer
pin, and each is named against its mutation.
Two survived their first run, and both survivals were defects in the gates:
- Loosening the file tab's `content` was invisible, because the pin dropped members
only in pairs and each pair is refused by the sibling. The pin now drops exactly
one member per iteration, and the preview text schema and the legacy file list got
the same treatment.
- Collapsing the hostId tri-state was invisible, because no golden serves an explicit
null host — the local ownership scenario omits the member. The ownership test now
captures all three states end to end, which is where a tri-state belongs.
`repo-metadata-platform` is re-anchored where this branch moved the read it mutates:
the hand-rolled `readHostPlatform` became the reply schema's own projection. The
defect it injects is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record main's repo-icon and speech-vocabulary replies
The closed enums this branch introduced had no fixture behind them. `provider`,
`dictationMode` and `repoIcon` were carried by no scenario at all — the fulfilled
repo-metadata golden records `repoIconsByName: []` — so the corpus could not have
moved whatever arm set the schemas declared, which is how a reader can pin a
vocabulary the host does not speak and still decode to a zero-move delta.
Two scenarios, both appended to an existing family so `familyGoldens` adds no
matrix golden, recorded from main's own tree at the pin with no product edit in it:
- `settings-repo-metadata-icons` — all three `RepoIcon` arms, a github-sourced
image with a label, an explicit `badgeColor`, and a mixed-host catalog so the
ssh/settings/platform wave runs too.
- `speech-setup-sheet-model-vocabulary` — `provider` on both arms, `status` on two,
`dictationMode: "hold"`, and null and numeric `sizeBytes`/`progress`.
Control: re-recording the whole corpus at the pin reproduces every committed
golden body, including this branch's five earlier before-pictures; only `baseline`
and the masked `lockfileSha256` move.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop the repo icon narrowing a member no consumer reads
The image arm of `repoIcon` declared `source` as the four values
`RepoIconImageSource` spells today (src/shared/repo-icon.ts:3). MobileRepoIcon
reads `type`, `src`, `label`, `emoji` and `name`, and never `source`, so the only
thing that enum could do was fail the union arm for a source a later host adds —
dropping the whole icon and drawing the Folder default where main drew the image.
That is the one arm set on this branch whose degrade was not already main's own
behaviour for an unknown value.
Dropping the declaration keeps the member: `looseObject` passes it through
verbatim, so the decoded object is byte-identical to the one main published, which
`settings-repo-metadata-icons` now records.
The two type sites that hold an icon move to the decoded type. A host `RepoIcon`
still satisfies the rendered union, so the worktree rows that carry one are
unaffected.
Every other closed enum on this branch was checked against the host's own shared
type and left alone: speech `provider`/`status`/`dictationMode`
(runtime-worktree-contracts.ts:83/85/86), `groupBy`/`sortBy`
(persisted-ui-state-types.ts:41-42), `platform` (Node's own domain; the handler
answers `process.platform`). For each, a salvaged member lands on the same branch
main's unknown value did: `=== 'openai'` and `=== 'ready'` stay false, a missing
`groupBy` and an unmapped one both answer null, and an unknown platform and a null
one both label the host "This computer".
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin past the repo-icon fix and re-record
Header-only: all 770 goldens move on `baseline` alone, including the two recorded
from main's tree two commits back. The icon fix and the two new fixtures decode to
the bytes main published.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep the repo-metadata readers total the way main's were
readSshTargets and readHostPlatform answered [] and null for any payload at
all. The checked schemas threw for a non-object, and because the label write
runs first in the same sequence that throw also skipped the platform write, so
a malformed reply left both decorative labels at their previous values instead
of degrading. A .catch on each restores main's answer without giving up the
row filter or the checked reader.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): forward the dictation mode instead of substituting a default
The reader closed the mode to two arms and the native-chat refresh spelled
`?? 'toggle'`, which is a good-reply change no golden covers: main left the
state undefined for a reply that omits the mode, and undefined binds no press
handler on the terminal input mic. Head gave that mic a working toggle. The
member is forwarded as the string the host sent and the refresh is main's line
again, so an absent or unknown mode leaves the mic exactly as inert as main's.
The route-parity runtime-string pin is main's own sha again.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin past the review fixes and re-record
The repo-metadata readers are total again, so both families' `result-absent`
and `result-null` checkpoints decode to main's bytes instead of the caught
throw, and the two delta rows they cost go away. The dictation mode forwards
verbatim, which no recorded reply exercises differently.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin onto the merge and re-record
Pins the corpus to the merge commit so main's ten create-terminal goldens and
this branch's own are recorded from one tree.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct three reader comments round 2 caught
The ownership schema said an explicit null hostId means the host said local;
the code refuses it, which is the whole reason mutant (c) exists. The AiVault
sessions cast cited a golden whose fixture row carries three members, not the
sixteen the cast claims — the full row is in aivault-history-screen-listed —
and both the issues cast and the schema doc said the rows are rendered when
the only read anywhere is issues.length.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct stale file:line citations in the batch-A reply schemas
Resolved every citation in the seven reply-schema modules and the SAFETY
notes against the tree and diffed each target line against the claim beside
it. Twelve were wrong, two of them past the end of a file that had shrunk,
so they read as evidence while pointing at a closing brace.
- file-explorer: the entries put is :157 not :160, the relativePath split is
file-list-fallback.ts:48 not :42, and the truncated publish is :136 not
:141. buildFileExplorerRows is no symbol at all; the sort-and-walk is
flattenDirectoryCache (file-tree.ts:58).
- file-ownership: the !summary throw is :68 not :64.
- file-preview: the markdown disk fallback reads content at :60 not :65.
- file-tab-doc: the html body render is :68 not :81 and the file arm is
:73-75 not :86-88 (the file has 78 lines); the isImage guard is :58 not
:66; the kind !== 'text' branch is :41 not :44; mobileDiffImageDataUri
spans :22-33 not :20-31; the unguarded content.length is
mobile-diff-lines.ts:35, the function that does it rather than :34.
- agent-history: both members land at :133-135; :135 alone is issues.
- dictation: the parenthetical read as citing the staleness guard when it
named the rpcPayloadMember read. Both are cited now, :237 and :225.
Comments only. No schema, type, or runtime behaviour changes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the unguarded activation report site that pins the opaque schema
Handler audit over all 33 interpret sites in the four domains found one site
that is structurally unguarded: use-mobile-session-startup.ts:170 reports the
activation verdict from inside a fire-and-forget `void (async …)()` whose only
`.catch` sits on the request, not on the chain. A throw there would be an
unhandled rejection and would also skip the terminal fetch below it.
Nothing throws there today, because `worktree.activate` reads
hostScreenUnreadReplySchema, which is `z.unknown()`. That totality is load
bearing rather than incidental, so the doc now names the line it protects and
contrasts it with the first report site at :141, which is chained
`.then(…).catch(…)` and would survive a throw.
Comments only.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin that a bound descriptor's interpret survives being detached
bindDeferredRpcOperation builds interpret as a shorthand method closing over the
captured operation, never `this`, which is what lets eleven call sites pass it as
a bare function reference. Nothing named that invariant.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* chore(mobile): repin the RPC recording baseline to the main merge
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): pin the closed reply enums to the host unions where tsc looks
pullfrog: the PR body promised a Record<HostUnion, true> pin for every
closed enum in this batch and the code had none. Adding them in the
schema tests would have changed nothing: mobile/tsconfig.json excludes
*.test.ts, so a coverage record there is never typechecked (a mutation
that dropped a key stayed green).
hostUnionArms(coverage) in zod-salvage spells the arm list as a
Readonly<Record<U, true>> in the schema module itself, called with the
host union as the explicit type argument: an arm the host adds is a
missing property, one it drops is an excess property. Used for the speech
provider and status (RuntimeSpeechModelSummary), the workspace groupBy and
sortBy (PersistedUIState) and Node's platform list, which host-screen now
imports from mobile-runtime-host-platform instead of duplicating. The repo
icon branches satisfy Readonly<Record<RepoIcon['type'], z.ZodType>>.
Three mutations (drop `manual`, add `bogus`, drop the image branch) each
fail tsc. The tests iterate the exported lists; the platform mutant is
re-anchored to the renamed constant.
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