Files
orca/mobile
Jinwoo Hong 01a1b6b024 refactor(mobile): checked reply readers for the tasks item and list domain (step 7) (#21169)
* refactor(mobile): checked reply readers for the tasks item and list domain (step 7)

Thirty-eight unchecked reply readers across four tasks files become checked zod
readers, so a malformed host reply surfaces as one `RpcIncompatibleReplyError`
naming the method instead of a downstream `TypeError`, a rendered `undefined`,
or a sheet left ready over garbage. Deliberately a behaviour change on malformed
replies only; nothing on the wire moves.

  mobile-task-item-state-operations.ts    17
  mobile-task-item-detail-operations.ts    8
  mobile-task-item-comment-operations.ts   7
  mobile-task-list-operations.ts           6

Two rules decide every schema, and both are stated in
task-provider-entity-reply-schema.ts:

1. A member is required only where a tasks consumer reads it with no guard.
   Everything reached through `?.`, `??` or a `typeof` test stays optional,
   because a reply without it rendered the same fallback then and now.
2. No member is required that the site's own recorded `normal` reply lacks. The
   corpus is the only evidence of what a host really sends at each site, and
   requiring a member absent from that control would turn a good reply into an
   incompatible one.

Rule 2 holds two schemas at the container: `github.prFileContents`, whose
recorded reply is `{ oldContent, newContent, truncated }` where
`getPRFileContents` returns `{ original, modified, ... }`, and `gitlab.todos`,
whose recorded row is not a `GitLabTodo` and whose `normal` partition therefore
records main crashing in `actionName.replace`. Both still gain their container,
which is what names a reply that is not an object or not a list. Correcting
those two scenarios is the follow-up that unlocks narrowing the rows.

Nine writes share one envelope reader and five comment writes share another:
`ok === false` and `error` are one host convention across them, and no input
would make two of them want different answers. The acceptance, the name and the
recorded family stay per operation. Three readers are reused rather than
re-declared — the session domain's boolean confirmation for `setPRFileViewed`
and `resolveReviewThread`, and its salvaged-member combinators throughout.

Three call-site shape tests the reader now answers for are deleted: both
`Array.isArray(payload)` guards on the checks read and the
`typeof count === 'number'` fallback on the item count. `GitHubPRFileContents`
is widened to optional members, which is what the reader can promise, and
`buildGitHubPrFileDiffPreview` takes the widened sides — `splitContentLines`
already treated a falsy side as no content, so no runtime behaviour moves.

The tasks source-parity hashes are refreshed: hook, statement, declaration and
render-token counts are unchanged, the render-token hash does not move at all,
and `semantics` is a pure deletion of ten lines.

Inventory: 137 unchecked readers over 30 files becomes 99 over 26.

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

* test(mobile): repin the RPC recording corpus and re-record the tasks reply deltas

`baseline` moves to 9133c02c5b, the commit that made the tasks item and list
readers checked, and the whole corpus is re-recorded from it. The repin rewrites
the `baseline` header of every golden; the body moves are the disclosed
behaviour change and nothing else.

What moved, and why: a malformed reply at one of the thirty-eight migrated read
sites used to reach the consumer as the declared type and fail downstream — a
property read on `null`, `.map` on a string, a rendered `undefined`. It now
stops at the operation boundary as one `RpcIncompatibleReplyError` naming the
method, which each call site's existing `catch` shows where it showed the
`TypeError` before. Every move is confined to a malformed reply partition of a
`tasks.*` or `linear.issue-detail` family; no `normal` partition moves, and no
family outside the tasks domain moves at all.

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

* test(mobile): anchor the tasks reply readers' null-collapse mutant

One registered mutant for the class the session domain shipped twice before a
review caught it: the assignable-user row's explicit `avatarUrl: null` collapsed
into absence. `tk-item-detail-metadata` records that null in visible state, so
the pilot suite kills it; it also fails the unit pin beside the schema and both
of that family's matrix goldens, including their `normal` partitions.

Three by-hand experiments back the other two claims the corpus should hold.
Applied to the product source, run, reverted:

- Loosening a required member the consumer reads with no guard —
  `linearIssueSchema`'s `title` to a salvaged optional — fails the unit pin
  `one Linear issue: refuses a reply missing a member createLinearTask reads
  with no guard`, and `tsc` rejects it, because the loosened output is no longer
  a `LinearMobileIssue`. No golden moves: the reply matrix varies the envelope a
  host sends, never the shape of a row inside a result, so a row requirement is
  unreachable from the corpus and the unit pin is the only thing holding it.
- Loosening a *container* requirement is reachable: making
  `linearAccountStatusSchema` `.nullable()` fails one matrix golden,
  `tasks.provider-load: reply partitions at linear.status#1`, on the
  `result-null` partition, as well as its unit pin.
- Swapping one checked reader back to unchecked —
  `githubPullRequestChecksRead` to `rpcUncheckedPayloadReader` — fails two
  matrix goldens, `tasks.item-review-github` and
  `tasks.project-row-review-checks`, both at `github.prChecks#1`.

Nothing under `mutants/` is pinned by a golden header, so this moves no
recording.

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

* test(mobile): correct the gitlab.todos fixture to a real GitLabTodo row

The `tk-list-gitlab-todos` reply sent `[{ id, targetType, target: { … } }]`, a shape `listTodos`
never produces: the host returns `GitLabTodo[]`, whose row carries `actionName`, `targetTitle`,
`targetUrl`, `projectPath` and `updatedAt` flat. Main crashed on it —
`Cannot read properties of undefined (reading 'replace')` from `createGitLabTodoTask`, with the
list rendering as an empty inbox and a raw TypeError on screen.

That crash was being read as evidence: a reader could not narrow this row without "refusing the
site's only success control", when the control was never a success. The fixture is the defect, so
the fixture is what moves.

The row is now a real `GitLabTodo` (src/shared/gitlab-types.ts:219) and main renders it: one item
titled "A GitLab todo", subtitle `group/project #4`, status `review requested`.

Recorded from the pinned main tree, not from this branch, so the corrected fixture's main
projection exists as a golden before any reader touches it: a detached worktree at
`4b876758d3158a8eb6b798055d8db7c58d1cd4a9` with this branch's manifest laid over it and its
`baseline` set to that commit, per the recorder README's detached-pin recipe. Control: all 756
other goldens reproduced the base corpus byte for byte; only these two moved. Both keep
`baseline: 4b876758d3`, which is the tree that produced them.

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

* refactor(mobile): check the GitLab to-do row and drop its cast

With the fixture corrected, the row is readable, so the reader reads it. `gitlabTodoSchema`
requires the five members the screen reaches with no guard — `id`, `actionName` (read as
`actionName.replace`), `targetUrl` (what tapping the row opens, and the title's fallback),
`projectPath` (the subtitle and the repository badge's key and label) and `updatedAt` — and leaves
every guarded member optional: `targetTitle` behind `targetTitle || targetUrl`, `targetType` and
`targetIid` behind the two tests in `gitLabTodoTargetRef`, and `authorUsername` and `state`, which
this screen carries but never reads.

The list salvages: one unreadable to-do drops and the rest of the inbox still renders, which is
what the rest of this domain does with a row it cannot place. Nullish still reads as the empty
inbox the call site already read.

`GitLabTodo` in mobile-tasks-provider-detail-types.ts now says what the reader proves rather than
what the host declares, which is what lets the `as GitLabTodo[]` assertion at the call site go. It
was the last cast in this domain's consumers, and it was re-typing rows nothing had checked — the
phantom-field shape this series exists to remove.

Parity: the same three hashes the step-7 commit moved move once more, for the deleted cast and the
widened row type. Counts are unchanged and `semantics` does not move.

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

* test(mobile): repin the corpus after the to-do fixture and row check

Repins `baseline` to the commit that checked the GitLab to-do row and re-records all 758 goldens,
so the whole corpus is pinned to one tree again: the two `gitlab.todos` goldens were still pinned
to main's, which is the tree that produced their before-picture.

The disclosed move is the `normal` partition of `tasks.task-list-gitlab-todos`. That is the fixture
correction showing through, not a reader change: with a real `GitLabTodo` on the wire the list
renders one item where it used to render a TypeError. Every other body move in this refresh is a
malformed-reply partition, as before.

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

* test(mobile): correct the github.prFileContents fixture to the host's shape

The recorded `normal` reply at both `github.prFileContents` sites was
`{oldContent, newContent, truncated}`, which `getPRFileContents` never returns:
it answers `{original, modified, originalIsBinary, modifiedIsBinary,
originalTooLarge, modifiedTooLarge}` (src/main/github/pull-request-file-contents
.ts:121-128), with the two too-large flags set only where a side was skipped for
size (:54). Both sites' `normal` partitions therefore proved nothing about the
success path, and `githubPullRequestFileContentsSchema` cited that fiction as
the reason it could require nothing. Same defect class as the `gitlab.todos`
fixture corrected in 32bd65c134, found by round-1 review.

Before-picture recorded against main's own product tree, not this branch's: a
detached worktree at `4b876758d3`, this branch's `pilot-scenarios.json` copied
in with `baseline` set to that commit, per the recorder README's detached-pin
recipe. Control: 744 of the 758 goldens reproduce the base corpus byte for byte;
the 12 copied here are the two affected families, and the remaining two are the
already-corrected to-do pair, which reproduced 32bd65c134's bytes exactly.

The 12 carry `baseline: 4b876758d3`, the tree that produced them. The next
commit's reader change and the repin that follows it re-record them.

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

* test(mobile): record main's reaction rendering for both providers

No scenario in the corpus carried a comment reaction, so nothing in the 758
goldens could see what a reader does to one. Round-1 review found the
consequence: this branch closed `reactions[].content` to a mobile vocabulary
(`thumbs_up`) that no producer sends, and the corpus stayed green because the
member was never on the wire.

Two scenarios, one per provider, each a second scenario in an existing family so
no matrix base and no existing golden moves. The GitHub one carries
`GitHubReactionContent` as the host sends it — `'+1'` and `'heart'`
(src/shared/github/comment-types.ts:3-17, normalised from GraphQL at
src/main/github/comment-reactions.ts:19-27). The GitLab one carries
`GitLabReaction`, which is `{ name, count }` with no `content` at all
(src/shared/gitlab-types.ts:60-72).

Recorded against main's product tree at `4b876758d3` through the detached-pin
worktree, so the `normal` partition now pins what main renders rather than what
this branch renders. Control: all 758 existing goldens reproduce the previous
pin recording byte for byte; the only difference is these two added files.

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

* fix(mobile): forward a comment reaction instead of matching mobile's vocabulary

`DETAIL_REACTION_CONTENT` was `thumbs_up | thumbs_down | ...`, a vocabulary no
producer of this list sends. GitHub answers `github.workItemDetails` with
`PRComment[]` whose reactions are `GitHubReactionContent` — `'+1'`, `'-1'`,
`laugh`, ... (src/shared/github/comment-types.ts:3-17), normalised from GraphQL
at src/main/github/comment-reactions.ts:19-27 — and GitLab answers with
`GitLabReaction`, `{ name, count }` with no `content` member
(src/shared/gitlab-types.ts:60-72). The closed arm set dropped every real
reaction row on both providers, which is a good-reply path this PR must not
change.

`content` is forwarded now, salvaged the way every other guarded member here is;
`count` stays required, because the `count > 0` filter at
mobile-tasks-item-comments.tsx:145 is the one unguarded read. `DetailComment`'s
eight phantom arms go with it: mobile's declared type was written from memory,
not from the wire, and widening the type is the fix rather than narrowing what
the host may send.

`COMMENT_REACTION_EMOJI` was keyed by that same phantom vocabulary, so it
resolves no glyph for a real reaction and the chip renders without one. That is
a pre-existing defect and it stays exactly as it is: the map is typed
`Record<string, string>` and the lookup takes `?? ''`, which resolves to the
same `undefined` main resolved for both providers. The two scenarios recorded in
68a3db2a3a pin that rendering, so a future arm set cannot drop the rows unseen.

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

* docs(mobile): stop citing the file-contents fiction as the reason nothing is required

The schema comment and its unit pin both named `{ oldContent, newContent,
truncated }` as "the recorded reply", which d26aeecdb0 corrected. The rule that
keeps every member optional is unchanged and is now stated from the reads
instead: the call site files the payload under the file path and reads nothing
off it, the review panels reach each flag through `?.`, and `splitContentLines`
takes `string | undefined` behind a falsy guard. The pins move to the host's own
shape, plus the too-large pair a skipped side carries and the empty object that
shows no member is required.

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

* refactor(mobile): import MERGEABLE_STATE instead of redeclaring it

`task-item-detail-reply-schema.ts` declared its own copy of `['MERGEABLE',
'CONFLICTING', 'UNKNOWN']` while already importing four member helpers from
`../session/github-pr-entity-reply-schema`, which exports that arm set and uses
it in the identical expression three times. Two copies of one wire arm set is
one place to fix when a provider gains a fourth state.

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

* refactor(mobile): inline the alias-only bindings the deleted casts left behind

Each of the eight was `const result = x as { ok?: boolean; error?: string }`.
With the cast gone the line is a rename of a binding that already has a name,
and every one of them is followed immediately by the same `ok === false` check.
Reading `created.ok` / `updated.ok` / `written.ok` / `replyResult.ok` directly
leaves one name per value.

The parity constants move with it and with the reaction change before it. The
comment there names both: ten string literals leave `semantics` with the phantom
reaction vocabulary and one arrives with the `?? ''` fallback, and the alias
deletions move the hook and statement hashes. No `rpc:` or `jsx:` signature
moves, the render-token hash does not move, and the hook, statement and
declaration counts are unchanged at 350, 417 and 194.

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

* test(mobile): register the container-requirement mutant the matrix kills

`f1b695f161` proved by hand that making `linearAccountStatusSchema` `.nullable()`
fails the `result-null` partition of `tasks.provider-load`'s matrix, and left it
unregistered. Registering it in `pilot-mutants.test.ts` is not available: that
suite drives the manifest scenario as written, and `tk-provider-load` scripts a
fulfilled `linear.status`, which a nullable container accepts exactly as the
required one does. The mutation only has somewhere to diverge under a partition
the pilot never reaches.

`family-mutants.test.ts` drives one named variant of a family's matrix instead,
against that variant's own slice of the golden, and each entry names both the
partition that kills the mutation and one that cannot see it — so the entry
records where the coverage is rather than that some golden went red. The
comparison is the whole recorded variant, the way the family suite compares:
under a last-state projection this mutant survives, because the divergence is in
the settlement and both paths reach the same final screen state.

Nothing under `mutants/` is pinned by a golden header, so this moves no
recording.

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

* test(mobile): repin the corpus after the round-1 review fixes

`baseline` moves to 542c1c38ed, the last fenced commit, and the whole corpus is
re-recorded against it. The two fixture corrections and the two added reaction
scenarios were recorded at main's pin first, so this run is what carries them
onto the branch's own reader.

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

* refactor(mobile): keep the Linear create arm's own binding out of the inlining

The alias inlining swept one line further than the finding it answers: the
Linear arm's `const result = linearIssueCreate.interpret(reply)` is a
declaration with a name, not an alias for one, and renaming it put a
pre-existing `createLinearTask(...) as Extract<TaskItem, …>` assertion from
#17438 inside this branch's changed lines, where the changed-code casting gate
attributes it to this PR. Reverted to `result`; the eight bindings the review
listed stay inlined.

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

* refactor(mobile): let each module build its own Linear team reader

`linearTeamListReader` was exported from the detail-operations module only so the
list-operations module could import it, which adds an operations-to-operations
import edge that buys nothing: `rpcResultVariant` is a pure factory, so two calls
with the same schema produce two functionally identical readers. What keeps the
composer's picker and the saved-selection reconciler agreeing about a team row is
that both build from `linearTeamsSchema`, which is already exported.

Deleting the export also puts the composer-policy JSDoc back on
`linearComposerTeamListRead`. JSDoc binds to the next declaration, so the block
explaining why this method carries two operations with different acceptance
policies had drifted onto the reader, leaving the operation it is about
undocumented.

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

* test(mobile): repin the corpus to the last round-1 commit

`baseline` moves to 75c568c244 and all 760 goldens are re-recorded against it.
Nothing but the `baseline` header moves: the two product commits since the
previous repin — the Linear create arm's binding restored and the team reader
built per module — change no observation in any recording, which is what a pure
refactor should look like here.

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

* chore(mobile): repin the RPC recording corpus to the main merge

The merge of origin/main moved both lockfiles, which the recorder fences,
so --record refused until the pin named a commit whose fenced tree matches
this one. Repinned to the merge commit and re-recorded: 760 goldens, header
only, `baseline` and `lockfileSha256`. No observation moved.

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

* docs(mobile): point the reply-schema citations at the lines they name

The alias inlining and the detail-loader rewrite moved fifteen of the consumer
lines these comments cite, and the comments are the evidence for requiring a
member only where the consumer reads it unguarded. Every citation re-checked by
opening it; the host-side ones were already right.

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

* docs(mobile): stop repeating the file-contents fiction on the type

The corrected fixture carries four of the six members at both call sites, so
"the recorded reply carries none of these" is false. The reason is the one the
schema already gives: no reader reaches a member without `?.` or
splitContentLines' falsy guard.

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

* fix(mobile): forward a file's viewed state instead of closing it

No scenario reply carries a file row, so no golden can observe either of this
schema's two arm sets. `viewerViewedState` is read only by two `=== 'VIEWED'`
tests, so it is forwarded and an arm this build predates reaches them as itself.

`status` stays closed: its only consumer sends it straight back as a
`github.prFileContents` param, which the host validates against the same seven
arms (github-pull-request-params.ts:62). Forwarding could not reach the wire
without a cast, and the host would refuse the arm on its own params, so the
drop to absent that becomes `?? 'modified'` is the compatible read.

Parity: one declaration and three arm literals, no executable change.

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

* docs(mobile): say that a Linear state-update refusal is ignored, as on main

`require-result-or-throw-message` throws only on an outer refusal, and
`linear.updateIssue` refuses in band as `{ ok: false, error }` on a
successful envelope, so the refusal reaches no `catch`. Main read the
same payload unchecked and discarded it, so nothing here changes.

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

* test(mobile): repin the RPC recording corpus to the origin/main merge

Main's #20069 re-recorded ten session create-terminal goldens and moved
twenty-one files under src/shared, both inside the recorder's fence, so
`--record` refused until the pin caught up. The re-record moves one key,
`baseline`, in all 760 goldens and nothing else; the ten taken from main
reproduce byte-for-byte apart from that pin.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-17 02:17:25 -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.tsDESKTOP_PROTOCOL_VERSION, MIN_COMPATIBLE_MOBILE_VERSION
  • mobile/src/transport/protocol-version.tsMOBILE_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