Files
Jinwoo Hong 4b876758d3 refactor(mobile): checked reply readers for the session domain (step 7) (#21089)
* test(mobile): record main's session reply behaviour at every unrecorded read site

Step 7 for the session domain changes how 51 RPC readers read a *malformed*
reply. Eleven of the session read sites had no recording family, so main's
answer to a malformed reply at those sites was undocumented and the reader
change would have had nothing to move. This commit is the before picture, taken
from main's own tree with no product edit in it.

Ten new families, twelve scenarios, twenty-five goldens:

- `session.review-file-diff` / `session.review-branch-diff` — `git.diff` and
  `git.branchDiff` read through the review projection, which the Changes
  screen's verbatim readers do not cover.
- `session.review-git-mutations` — the single-file `git.stage` / `git.discard`
  and the bulk stage sweep's second `git.stage`.
- `session.review-send-sheet` — `session.tabs.list` read for the agent
  terminals the send sheet lists, the third reader on that method. Needs an
  `open-send-sheet` action on the review-action adapter, which re-digests that
  family's eight goldens on `adapterSha256` and nothing else.
- `session.browser-tab-create` — `browser.tabCreate`.
- `agentSession.structured-create` — `agentSession.create`, whose family base
  only ever covered the support probe.
- `session.tab-rename` / `session.tab-close-session` — `terminal.rename` and
  `session.tabs.close`.
- `settings.new-tab-local-agents` — `preflight.detectAgents`, the arm the
  new-tab loader takes for a workspace with no connection.

`baseline` is repinned to main's tip because two commits (#20659, #21004)
touched a fenced path after the pilot's pin, so `--record` refuses on main's own
tree until it moves. The repin is what rewrites `baseline` on all 705 existing
goldens; nothing else about them moves.

Decoded against origin/main through the value pool: 705 header-only (`baseline`
on every one, `adapterSha256` on the eight review-action goldens), 0 body-moved,
25 added, 0 deleted.

Not covered, with the reason: the chunked clipboard upload's
`appendImageUploadChunk`, `commitImageUpload` and `abortImageUpload` cannot be
matrixed, because `replyMatrixSites` takes every completion in the base scenario
and the chain's later params carry the `uploadId` the start reply named. Driving
`clipboard.startImageUpload#1` therefore makes main send an append whose params
no scripted step matches, and the recorder raises `Request params mismatch:
clipboard.appendImageUploadChunk#1` instead of recording. The two families were
written, probed and removed.

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

* refactor(mobile): checked reply readers for the session domain (step 7)

Fifty-one unchecked reply readers across nine 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.

Eight schema modules, one per reply family, each recording the consumer line
behind every requirement and the host handler that publishes it:

- `clipboard-image-reply-schema.ts` — the upload slot's `uploadId`, the commit
  and single-frame path strings, and the two legs whose body nothing reads.
- `github-pr-mutation-reply-schema.ts` — the `{ ok, error }` status envelope as
  two variants, and the bare-boolean confirmation.
- `github-pr-entity-reply-schema.ts` / `github-pr-read-reply-schema.ts` — the
  seven PR sidebar reads. Every identity requirement the hand parsers had is
  kept, so a payload that degraded to null still degrades to null; what changes
  is a payload that is not the declared container at all.
- `diff-review-reply-schema.ts` — the normalized branch compare, the review
  notes on the worktree record, the three file-diff arms, and the file-level git
  mutations.
- `review-terminal-reply-schema.ts`, `session-launch-reply-schema.ts`,
  `session-read-reply-schema.ts`, `session-write-reply-schema.ts` — the review
  send sheet, the launch paths, the session screen's reads and its writes.

Requirements are exactly the members a consumer reads unguarded, everything else
is a salvaged optional with main's own default applied in the transform, and no
schema is `.strict()`: a member a newer host adds passes through untouched.
Enum arm sets that a reader compares against pass through or degrade to the arm
the reader handles most conservatively; the two closed sets — the committed
change status and the diff kind — are closed because main *dropped* an arm it
did not know rather than passing it through, and degrading them would draw a row
or render a diff main never did. No member is coerced on the way back to the
host.

`github-pr-parsers.ts`, `github-pr-comment-parsers.ts` and
`github-pr-value-readers.ts` are gone; their suite is now the parity record for
the schemas that replaced them, with the four cases that refuse rather than
degrade marked as such. Twelve call-site casts are deleted, and three dead
"response was invalid" branches with them: the reader refuses those replies now,
so the error names its method.

The nine session files come off `unchecked-rpc-reader-inventory.ts` entirely
rather than being lowered. `git show --stat` on this commit touches nothing
under `mobile/rpc-foundation`.

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

* test(mobile): unit-pin every session reply schema's decision

Three kinds of case, one per kind of decision the schemas encode: a member a
consumer reads unguarded is required and its absence refuses, an arm set a
reader compares against degrades to the arm that reader handles most
conservatively, and a reply whose arms need different members is declared as
variants and each arm is read.

The last suite is the wire-compatibility claim: a member no reader knows passes
straight through, on the markdown document, the upload slot and the terminal
inventory alike, so a newer host is never refused for a field mobile does not
read.

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

* test(mobile): refresh the corpus for the session domain's checked readers

Repins `baseline` to the last commit touching a fenced path and re-records all
730 goldens, which is the disclosed behaviour change taken as an observation.

Decoded through the value pool against the pre-refactor tree on this branch:
688 header-only with `baseline` the only key that moved, 42 body-moved, 0 added,
0 deleted. The 42 are seven named scenarios and thirty-five matrix goldens, and
every moved checkpoint's own reply is malformed or refused. Three `normal`
partitions appear in the list and none of them reads a well-formed reply
differently: the review file-diff family's base scenario drives three legs and
its third is scripted `{ kind: 'unknown' }`, so that leg's checkpoint moves in
every variant, the varied leg included. The same append-only-history effect puts
`pr-read-upstream-error`'s `no-pr` checkpoint in the list for the malformed PR
recorded before it.

What the corpus now records, in one sentence: a property read on null, a V8
destructuring message shown to the user, and four hand-written "response was
invalid" strings are replaced by one message that names the method, and four
screens that published a malformed payload as ready state now show an error
instead.

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

* refactor(mobile): split the expanded check run out of the PR read schemas

`github-pr-read-reply-schema.ts` was 328 code lines against the 300-line cap.
The expanded check run and the annotations, jobs and steps listed under it are
one reply with no reader in common with the other six, so they move to
`github-pr-check-reply-schema.ts` whole. A move, not an edit: no schema changes
and no golden moves.

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

* test(mobile): repin the corpus to the branch's last fenced-path commit

The schema-module split touched `mobile/src`, so `--record` refuses on the pin
the previous refresh left behind. Repins to that commit and re-records. Decoded
against the previous corpus: 730 header-only with `baseline` the only key that
moved, 0 body-moved, 0 added, 0 deleted — the split is a move, and the corpus
says so.

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

* refactor(mobile): drop the worktree display-name cast's type import

The live-title read is typed by its schema now, so the cast it annotated is gone
and the import it needed with it. oxlint flags the leftover.

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

* test(mobile): repin the corpus to the branch tip

The unused-import removal touched a fenced path, so the pin moves with it.
Decoded against the previous corpus: 730 header-only on `baseline` alone,
0 body-moved, 0 added, 0 deleted.

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

* fix(mobile): contain a refused prChecks reply to the checks section

The checks read was the one phase-1 dependency that could take the whole PR
sidebar down. `loadPrSidebarData` routed `!checksOutcome.ok` through
`failureState`, so a host whose `github.prChecks` shape drifted cost the user
the title, body, comments, reviewers and merge controls — everything they
opened the sidebar for — over a section that renders a row of icons. Main
never noticed because its unchecked reader answered `[]` for the same reply;
this branch's reader refuses it, which is correct, and which is what makes the
containment necessary.

Contained the way phase 2 already is: a failed read keeps `kind: 'ready'`,
empties `checks`, and carries the message in a new `checksError` so the checks
section can say what happened. The sidebar can no longer reach `error` or
`blocked` on the checks read alone.

Also pins the enum departure this PR makes deliberately. The degrading arm
sets go through `salvagedOptional(name, z.enum(...))` rather than `openEnum`
because `openEnum` refuses a non-string where main mapped it to the
conservative arm; nothing held that, and all 2477 tests stayed green against
the swap. Six cases now hold both halves: a non-string degrades on the three
open sets, and an unknown arm drops the row on the closed ones.

Four deletions the reviewer found: a reaction-token alias with no importers,
the `errorType`/`fetchedAt` the branch-lookup reader fabricated to satisfy a
type whose only consumer reads neither, two bare schema aliases, and a
quick-commands pass-through with two callers.

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

* test(mobile): repin the corpus to the containment commit

`--record` refuses unless the product tree equals `baseline`, so the fix above
moves the pin. The corpus re-recorded in place against it: 730 goldens, every
one header-only on `baseline`, no observation moved.

No observation moved because no family reaches the code the fix changed. The
`github.pr-read` family calls the seven wrapper reads directly and records
their `{ ok, error }` outcomes; `loadPrSidebarData` sits a layer above that and
no scenario mounts it. The prChecks outcome is identical before and after —
what changed is what the sidebar does with it — so the unit suite is the only
oracle for the containment.

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

* test(mobile): record the PR sidebar's checks containment

The containment landed with no golden: no scenario mounted `loadPrSidebarData`,
so the row in the delta table rested on unit tests alone. `PrSidebarLoadDeps` is
five client-taking functions, so a new adapter drives phase 1 directly and
records the `PrSidebarState` it resolves to — no React host, and no edit to an
existing adapter, so no recorded golden moves.

Two scenarios: a normal load, and one whose checks leg answers a shape the
reader refuses. The matrix over the base then drives all eleven partitions at
`github.prChecks#1`, and every one of them records `ready` with a `checksError`
where main took the whole sidebar to `error`. `pr-sidebar-checks-failure-state`
is the mutant that routes the refusal back through `failureState`; it moves both
`pr-sidebar-checks-refused` and the prChecks matrix golden.

Also pins two closed-and-required enum decisions that were free to become
defaults — an unknown check-summary state drops the summary block, an unknown
reaction content drops the reaction — deletes four exported type aliases and
five enum constants with no reader outside their own file, makes
`PRChecksSection`'s `checksError` required so a second caller cannot silently
lose the message, and stops the header reading "No checks" when the checks were
unreadable rather than absent.

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

* test(mobile): repin the corpus to the pr-sidebar family commit

Six new goldens — two pilots and the four matrix sites the base scenario
scripts — and `baseline` on the 730 that already existed. No body moved and no
`adapterSha256`: the family is a new adapter module, so nothing recorded through
another one re-digests.

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

* test(mobile): re-record the corpus against the merged main

Repins `baseline` to the merge commit and re-records all 736 goldens in
place. Against `origin/main` the 705 shared goldens move only on
`baseline` (672 of them header-only), leaving the same 33 body moves and
the same partitions the branch carried before the merge, plus its 31
added goldens.

Every body also takes main's recorder shape from #21088: `sent` becomes
`ordinal` over one interleaved write counter, subscriptions record a
cleanup checkpoint, and a salvaging read now reports a `reply-salvage`
effect naming what it dropped.

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

* fix(mobile): keep an explicit null on the two tri-state PR flags

`autoMergeAllowed` and `mergeQueueRequired` carry three answers, not two:
`null` is GitHub saying auto-merge is not allowed, `undefined` is the host
not carrying the member at all. The readers coalesced the null away, so a
well-formed reply read differently from the parsers they replaced, which
preserved it explicitly. Both shared types already declare `boolean | null`.

No consumer separates the two today — `pull-request-auto-merge-availability`
compares with `=== true` and `!== false` — so this is parity, not a visible
fix, which is exactly why it needed a test.

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

* test(mobile): repin the corpus to the tri-state flag commit

All 736 goldens move on `baseline` alone: no scenario scripts an explicit
null on either flag, so preserving it changes no recorded screen.

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

* refactor(mobile): check the two session-write readers #21083 brought

Step 7 empties the session block of the unchecked-reader inventory, and
#21083 landed two readers into it after that: the New Tab create's member
read of `tab`, and the display-mode toggle's payload. Converting them is
what keeps the claim true — a session line reappearing would mean the
domain is not migrated.

`created-terminal-tab` requires `tab.id` and `tab.type === 'terminal'`,
because the strip keys the new tab on the id and spreads the rest into a
union whose arm `type` picks. `terminal`, `title` and `terminalTheme` stay
optional behind main's own guards, and unknown members pass through.
`terminal-display-mode-set` reads nothing, so it takes the same
`z.unknown()` the other five unread writes take.

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

* test(mobile): repin and re-record over #21083's corpus

All 736 goldens this branch already had move on `baseline` alone, and
#21083's 22 arrive beside them. One of the 22 moves against main's own
recording: `matrix-session.create-terminal-session.tabs.createterminal-1`,
where the New Tab create's five malformed partitions read
`Cannot read properties of undefined (reading 'tab')` and now read the
method's own message. Two of them also stop unsubscribing the terminal the
user was watching before the property read threw, so a create that never
happened no longer costs the live pane its subscription.

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

* docs(mobile): say what carries a refused create reply to the catch

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-16 21:24:31 -04:00
..

Orca Mobile

React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone.

Local development uses two processes:

  • Orca desktop/Electron from the repo root. This hosts the mobile WebSocket RPC server on port 6768.
  • Expo Metro from mobile/. This serves the React Native app on port 8081.

Unless a command says otherwise, run mobile app commands from the mobile/ directory.

Prerequisites

  • Node.js 24+
  • pnpm
  • Xcode and/or Android Studio tooling for simulator or device builds
  • Expo Go on your phone, or a development client build when native modules are needed
  • Phone and desktop on the same LAN when testing a physical phone

Start Desktop Orca

From the repository root:

pnpm install
pnpm dev

Confirm the mobile RPC server is listening:

lsof -nP -iTCP:6768 -sTCP:LISTEN

Restart pnpm dev after changing Electron main-process code. Metro hot reload only applies to the mobile JavaScript bundle.

Start The Mobile App

cd mobile
pnpm install
pnpm start

Scan the Expo QR code with your phone's camera on iOS, or Expo Go on Android.

For a native dev-client build:

pnpm exec expo run:android
pnpm exec expo run:ios
pnpm start --dev-client

Pair With Desktop Orca

  1. Open Orca desktop.
  2. Go to Settings > Mobile.
  3. Scan the pairing QR code from the mobile app.
  4. Confirm the mobile host endpoint is ws://<desktop-ip>:6768.

For the Android emulator, use ws://10.0.2.2:6768. For a physical phone, use the desktop LAN IP, for example ws://192.168.0.179:6768.

If the phone has a stale host entry, remove it from the app and pair again.

Development Paths

Android Phone

  1. Install Expo Go from Google Play
  2. Run pnpm start, scan QR with Expo Go
  3. For native modules: pnpm exec expo run:android
  4. Run with pnpm start --dev-client

iOS Simulator

  1. Install Xcode from the App Store
  2. Run pnpm start --ios to open in iOS Simulator

Physical Phone Debugging

The phone can be inspected through the connected device tooling:

orca snapshot --json
orca click --element @e3 --json
orca fill --element @e1 --value "ls" --json
orca screenshot --json

Use snapshot first to find the current element refs, then click/fill those refs. After mobile file edits, Metro usually hot reloads automatically, but navigating out of and back into the session screen can be useful because it re-runs terminal.subscribe.

Terminal Streaming Repro Without A Phone

Use this when terminal output does not render on device and you need to split server streaming bugs from WebView/UI bugs:

cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64>

You can pass a worktree selector as the third argument:

pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "path:/absolute/worktree/path"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "name:my-worktree"

The expected result includes:

streamSawMarker: true
readSawMarker: true

If this repro fails, debug the desktop runtime/PTY path before the mobile WebView. If it passes but the phone is blank, debug the session screen or TerminalWebView readiness/queueing path.

Terminal Color Repro Without A Phone

Use this when terminal colors disappear after switching tabs. Open a Claude Code terminal and at least one other terminal in the target worktree, then run:

cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/repro-terminal-colors.ts \
  <deviceToken> <serverPublicKeyB64> "id:<worktreeId>"

The script captures terminal.subscribe snapshots in an A → B → A sequence and writes raw snapshots to mobile/terminal-color-repro/. If the two A snapshots have different sgrColor counts, the desktop snapshot changed during the switch. If they match, the ANSI color data is still present and the bug is in mobile replay/rendering.

Validation

Run these checks before committing mobile terminal changes:

cd mobile
pnpm exec tsc --noEmit
pnpm 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_VERSION
  • mobile/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 — with MOCK_NATIVE_CHAT=1, present an OMP tab and four decoded transcript messages, including a tool call and result, instead of the default Claude scenario. It deliberately omits transcriptPath to exercise legacy-hook readability discovery; current OMP hooks may report a path.
  • MOCK_SERVER_KEY_FILE — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair.

Scenario control files

Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default.

  • MOCK_SEND_MODE_FILE (default orca-mock-send-mode in the system temporary directory) — accept (default) accepts the send, error fails it with mobile_input_floor_unavailable, anything else reports the send as rejected.
  • MOCK_TERMINAL_LIST_MODE_FILE (default orca-mock-terminal-list-mode in the system temporary directory) — omit returns an empty terminal list, other returns a list that omits the chat handle, anything else lists it.
  • MOCK_TERMINAL_STREAM_MODE_FILE (default orca-mock-terminal-stream-mode in the system temporary directory) — dead answers a subscribe with subscribed then end (a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.

Connecting to Real Orca

  1. Start Orca desktop with WebSocket transport enabled
  2. In Orca, go to Settings > Mobile and scan the QR code with this app
  3. The QR encodes the connection endpoint, device token, and TLS fingerprint

Project Structure

mobile/
├── app/                   # Expo Router screens (file-based routing)
│   ├── _layout.tsx        # Root layout with navigation stack
│   ├── index.tsx          # Home screen — paired hosts list
│   └── pair-scan.tsx      # QR code scanning screen
├── src/
│   ├── terminal/          # Terminal WebView and xterm bridge
│   └── transport/         # WebSocket RPC client
├── scripts/
│   ├── test-subscribe.ts  # Desktop streaming repro without a phone
│   └── mock-server.ts     # Standalone mock WebSocket server
└── assets/                # App icons and splash screen