Files
Jinwoo Hong 5d13a70ea3 fix(mobile): keep an in-page hop local only when the session's grants cover it (OTA phase C, C2.9) (#21723)
* feat(mobile): carry what each page route declared in init (OTA phase C, C2.9)

The page decides an in-page hop from `init.pageRoutes`, which says which patterns
this shell would render and nothing about what each one costs. So a push kept
local on the strength of the pattern alone runs the target under the opener's
grants — which is how the tasks page is reached from the wide-layout sidebar
without `native.clipboard.write`, and why its copy actions refuse silently.

`init` now also carries `pageRouteGrants`, the manifest's own route/grant pairs,
from the manifest the shell already holds. Optional in both directions: an older
shell omits it and an older page ignores it, and a page that receives none keeps
today's rule. No new frame kind, no cap change, no protocol bump.

The grammar is the manifest's, imported rather than restated
(`MobileWebBundleGrantNameSchema`, now exported for this), so a grant name the
bundle could not have declared cannot reach the page through this field either.
The host validates the pairs before it builds the frame and refuses the session
when they fail, for the reason it already refuses a malformed route: an `init`
the page would reject whole is worse than no session at all.

Two files were at their line ceiling and are split rather than bumped. The pairs
schema moves to `bridge-page-route-grants.ts`, which is read by both the envelope
and the host, so it belonged in one place anyway. In the session reducer the
three sites that each spelled out "patterns, their grants, this route's grants"
become one `routeViewOf`; that is a net reduction and removes the fourth spelling
before it is written.

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

* fix(mobile): keep a hop local only when the session's grants cover it

The rule the page was using is "the shell would render this pattern", and that is
not the question. Grants are resolved once, from the route the shell opened, so a
push kept local runs the target under the opener's list. On a wide layout the
sidebar renders beside every `/h` route and pushes `/h/<id>/tasks` through this
seam, so from the worktree list, agent history or the files pages the tasks page
ran without `native.clipboard.write` and its copy actions refused with nothing on
screen to say why.

`servedHere` now means served here *and* covered: the target's declared grants
must be a subset of this session's. An uncovered page route is handed to the
shell exactly like a non-page route, and the shell opens it as its own session
with its own grants — which is the mechanism that already exists, rather than a
new one.

Three answers, not two, because an absent field is not an empty one. A shell that
sent no pairs keeps the old behaviour: `null` is "nobody told me", and an older
shell has to keep working. A target the shell lists but names no entry for is
*not* covered — the page cannot justify that hop, so it hands it over rather than
guessing in the direction that loses grants.

This is C3.1's explorer ⊇ preview finding without its pairwise pin: that hop is
covered by this rule and stays local, and the rule scales to the sidebar, which
reaches every route and which no pairwise list can keep up with.

Red first on the two cases only the new rule answers; the other four are the
regression guards and passed before and after. Two whole-session assertions
gained `pageRouteGrants: null`, which is what the reader now returns.

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

* test(mobile): prove the sidebar hop in a browser, under the session's own grants

The unit tests pin the decision; only a browser shows the control exists, is
reachable at the viewport where the sidebar renders, and that the document does
not move when the hop is handed over.

Four cases on the shared harness, which now forwards `pageRouteGrants` (omitted
when a caller names none, because an absent field is not an empty one and the
page reads the difference).

- Wide, session without `native.clipboard.write`: tapping Tasks posts exactly one
  `navigate` notify, the document stays on the worktree list, and **no new chunk
  is fetched** — which is what says the page did not quietly render tasks under
  the wrong grants.
- Wide, same tap with the grant added: no notify, the document moves to `/tasks`.
  Without this the first case would pass on a page that simply never navigates.
- Wide, shell sending no pairs at all: the old behaviour, local. An older shell
  must not start handing every hop over on a field nobody sent.
- Narrow: asserts the absence rather than a tap. `app/h/_layout.tsx` renders the
  sidebar only on a wide layout, and only that header branch labels its Accounts
  and Tasks controls; the narrow header's are unlabelled pressables. So the hop
  does not exist at that viewport, and `getByLabel('Tasks')` finding nothing is
  the honest assertion. That unlabelled narrow header is a real accessibility gap
  and is not this lane's to fix.

Registered in `pr.yml`'s `mobile_web_app` job beside the other render checks.

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

* test(mobile): census the in-page hops a session's grants cannot cover

The rule landed in the commit before this one decides each hop; this says which
hops those are, so a route's grants growing — or a new push between two page
routes — shows up here rather than as a verb that silently refuses on a device.

Openers are every page route, not the one that happens to push. On a wide layout
`app/h/_layout.tsx` renders the worktree-list sidebar beside every `/h` route and
its header pushes tasks, which is exactly why a pairwise pin is the wrong shape:
the sidebar reaches everything, so the census has to be the cross product of what
the manifest declares against what the source actually builds.

Targets come from the hrefs the app builds, read out of `mobile/src` and
`mobile/app` and reduced to route patterns, so a hop nobody writes is not pinned
and a hop someone adds is. A presence case asserts the sidebar's tasks push is
among them, because a census that stopped finding hops would go quietly green.

Two hops are pinned as handed off today, both into tasks, which is the only route
declaring more than `navigate` and `storage`. A third case asserts the other half
of the rule on the manifest: a target asking for no more than its opener stays in
the document.

Checked that it discriminates rather than assuming: widening the worktree list's
grants to cover tasks fails the pin, and restoring them passes it.

**No pin was deleted.** The brief expected C3.1's pairwise explorer/preview pin to
be replaced here, but C3.1 is not on this base — `MOBILE_WEB_PAGE_ROUTES` has
three routes and no `files` entry, so there is nothing to remove. When C3.1 lands,
its pin is this census's to subsume.

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

* fix(mobile): drop an unused import from the hop census

`statSync` was imported and never used; `oxlint` fails it. My error: I committed
the census on a green test run without waiting for lint, the same order mistake I
made earlier in this lane. Fixed forward rather than amended, because the lane
forbids rewriting a commit that exists.

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

* test(mobile): fold C3.1's pairwise grant pin into the hop census

C3.1 landed while this branch was open, and it brought the case this lane
generalises: the explorer pushes to its own preview, that push stays in the
document, so the preview runs under the explorer's grants. Its pin asserted that
one pair by name.

The census now covers it as a consequence rather than a rule. With the files
routes in the manifest the cross product finds six more hops the session cannot
cover — the sidebar into files from the worktree list and from agent history, and
both files routes into tasks — and it does **not** find explorer → preview,
because the preview declares no more than the explorer. That absence is the
pairwise pin, derived.

So the pairwise block is deleted, with its import. The rest of that file stays:
its external-link seam checks and its clipboard-absence control are about what
the files closure contains, which this census says nothing about.

Checked the extended census still discriminates: granting the explorer
`native.clipboard.write` fails the pin, restoring it passes.

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

* test(mobile): prove the sidebar hop from a files route, not only the worktree list

The defect is not "the worktree list pushes tasks". On a wide layout the sidebar
renders beside every `/h` route, so the same hop exists from the files explorer,
whose session carries `externalLink` but not `native.clipboard.write`. One opener
proving the rule would have left the general case to inference, which is the
inference C3.1's pairwise pin already made once.

Opened on `/h/<id>/files/<wt>` with the files route's own grants, the sidebar's
Tasks control posts exactly one `navigate` notify, the document stays on the
files route, and no new chunk is fetched.

The harness helper now takes the route and the text to wait for, so a case can
open on something other than the worktree list without a second copy of it.

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

* fix(mobile): make the render helper wait on the text its caller named

The `awaitText` parameter I added in the commit before this one was never wired
into the wait, so it was dead and `oxlint` failed it. The case still passed,
because the files route renders the host name in its sidebar and that is what the
helper was still waiting on — which is exactly the kind of accident a dead
parameter hides.

Third time in this lane I have committed on a green test run before lint
finished. Fixed forward, not amended.

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

* fix(mobile): carry route grants through the download path

`onManifestRead`'s download branch set `pageRoutes` and `routeGrants` from the
new manifest and dropped `pageRouteGrants`; nothing downstream recomputes it, so
every first install and every OTA update reached `ready` with the default or the
previous generation's pairs. The page then read each target as listed-with-no-
entry and handed off every in-page hop.

`routeViewOf` moves to `page-route-policy.ts`, beside the two functions it calls,
to keep the reducer under its line cap without a bump; its stale neighbouring
comment, which described a filter that moved into it, goes.

Red first: the cold-cache and generation-change cases failed, the cached-hit case
already passed.

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

* test(mobile): derive census targets from navigation call sites

The reachability filter was inert. Harvesting every `/h/${…}` template caught the
five screens that declare their own mount pathname, two `pathname ===`
comparisons and the route template types, so every declared route was reachable
through its own mount: the pinned table was the all-pairs one, eight hops with
the filter and eight without.

Targets now come from the arguments of `router`/`navigation` `push`, `replace`
and `navigate`, and of `navigateFromHostList`; mounts, comparisons and types are
excluded by construction because they are not navigation arguments. Two real
hops are not written as a literal, so a local binding or a call is followed one
step to the function that returns the pathname: the files explorer is pushed as
`{ pathname: descriptor.pathname }` and the preview as
`push(createMobileFilePreviewHref(...))`. A call site whose target cannot be read
is returned rather than dropped.

Derived patterns go from 11 to 10; the pinned table stays at eight because all
five page routes are genuinely pushed to. What changes is that the filter now
discriminates: deleting the header's two tasks pushes reds the presence case and
drops the four `-> tasks` rows from the pin, where the old derivation stayed
green on the same deletion because `app/h/[hostId]/tasks.tsx` still declared the
pathname. A push added at a real call site appears in the set.

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

* test(mobile): restore the preview-declares-something guard

The pairwise pin this case replaced asserted the preview declares at least one
grant before asserting the explorer covers them all; without it two empty lists
satisfy the subset check and a route that lost its grants passes.

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

* docs(mobile): describe the route list under the handoff rule

Two passages described the world before this PR: the explorer's note said the
census pins its pair with the preview, and a closing paragraph left the sidebar's
tasks hop open for a later PR. This is that PR. Covering the preview now buys the
in-document hop rather than making it correct, an uncovered target is handed to
the shell and reopened under its own grants, and the census reads the explorer to
preview relation off this list rather than pinning it by name.

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

* test(mobile): mirror the manifest's tasks grants in both fixtures

CodeRabbit on #21723: both fixtures declared the tasks route as `navigate`,
`storage`, `native.clipboard.write` while the manifest also declares
`externalLink`, so no covered-session case ever required it.

Both now mirror the manifest's four, and the covered sessions hold them. That
alone does not make an `externalLink`-blind rule fail, since those sessions hold
every grant either way, so the unit suite gains the case that does: a session
holding the clipboard but not `externalLink` must still hand the hop off.
Mutating the rule to treat `externalLink` as always held reds that one case and
no other.

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

* test(mobile): make a stalled hop name its own cause

Both waits for the hop to land read as a bare 30 s timeout when it does not. The
CI failure that sent this file back was a `TypeError` inside React Navigation
that blanked the document, and it was invisible here because the error
assertions run after a wait that never returns.

The wait now throws with the page's own account: the pathname it stayed on, the
collected page and console errors, the `navigate` notifies posted, the first 300
characters of the body, and every `.js` response since the click with its status.
The response listener records every script answer rather than only the 200s, so a
chunk the navigation waits on can be seen failing; the 200-only list the
no-new-chunk assertions read is unchanged, as is everything the five cases
assert. Kept in this file because no other render file waits on the pathname
moving.

Proved by mutating the rule to hand every hop off: the covered case fails naming
the pathname it stayed on, an empty error list, the notify it posted and no
scripts since the click — which is the handoff signature, distinct from the
crash signature CI saw.

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

* test(mobile): aim the narrow hop at the control C2.10 named

The narrow case asserted the absence of a labelled Tasks control, which
was true only because the narrow toolbar carried no accessibility props.
C2.10 gave it the wide sibling's role and label, so the assertion was
red on the merge and, worse, the rule this file is about went unproven
on the branch the phone actually presses.

It taps that control now: at 390 px there is exactly one, and the tap
posts exactly one navigate notify for the tasks route while the document
stays on the worktree list and fetches no new chunk. Red first against
the merged header (count 1, expected 0); with the session given
native.clipboard.write the hop goes local and the case reds, which is
what says the assertions discriminate.

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

* docs(mobile): drop the handoff predicate's contradicted one-liner

The pre-C2.9 summary said the answer is whether this document renders
the target, which is exactly the claim the block comment below it
replaced: the predicate now also requires the target's grants to be
covered. Two doc comments on one declaration, the first of them wrong.

Comment only; the 35 handoff cases are unchanged and green.

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

* test(mobile): assert which field a route refusal blames

The host builds `pageRouteGrants: <issue>` so a refusal says which of the
two checked inputs failed, and nothing read it: the case counted
refusals, so a host that reported the route's own verdict for a malformed
pair would have stayed green while sending whoever reads the refusal to a
pathname that was never the problem.

The case pins the prefix, a non-empty issue behind it, and that the
diagnostic and the callback carry the same string. The control is an
opener that fails the other way: a malformed route reports its own issue
and does not take this prefix, without which the pin would hold on any
reason at all.

Red first with the field branch dropped from the reason: the prefix
assertion fails and the control stays green.

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

* refactor(mobile): stop exporting the route filter the reducer stopped calling

`implementedPageRouteEntries` and `implementedPageRoutes` were the
reducer's two ways in before it moved to `routeViewOf`. The entries form
had no caller anywhere afterwards and the patterns form had only this
test, so the module's public surface advertised two functions no product
code reaches. Both are module-local now; the surface is
`matchesRoutePattern`, `pageRendersRoute`, `grantsForRoute`,
`routeViewOf` and the grant list.

The test reads the same list through `routeViewOf(...).pageRoutes`, which
is the reducer's own view of it, so no assertion changed and no export is
kept for a test.

Red first: with both un-exported and the test untouched, seven cases fail
with `implementedPageRoutes is not a function`; routed through the view
all nineteen pass. Still discriminating, as a control: with the grant
filter dropped from the entries helper, four of them fail.

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

* test(mobile): route the merged haptics cases through the policy view

PR E's two haptics cases arrived with the merge calling
`implementedPageRoutes`, which this branch had already made module-local,
so the merged file was red with `implementedPageRoutes is not defined`
on both of them. They read the same list through `pageRoutesOf`, the view
the rest of the file already uses, so neither assertion changes.

PR E's paragraph named that function for the filter it describes; the
filter now sits in the entries helper the view is built on, so the
sentence says that instead of naming a function the reader cannot see.

Red: the two cases above on the merge. Green: all 21, PR E's two included.

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

* test(mobile): mirror the haptics token in every handoff fixture

PR E put `haptics` on all five manifest routes, and these fixtures still
carried the pre-E grant lists: tasks with four grants where the manifest
now declares five. A fixture that is short the same token on both sides
of the subset check agrees with the rule by accident, and would have gone
on agreeing after the token stopped being universal.

The pairs mirror the manifest now, and each session carries what its
opener route would actually be granted, since the host narrows a route's
declared grants to what the shell implements and the shell implements the
token.

Red first, with the token added to the pairs alone: the two covered-hop
cases flip to handed-off, `stays in this document when the session
already covers the target` and `keeps the hop in the document when the
session covers tasks`. Green once the sessions carry it, 35 and 5.

The hop census needed nothing: it reads `MOBILE_WEB_PAGE_ROUTES` itself.
Measured there, all 5 routes declare the token and it is the missing
grant in 0 of the 8 uncovered pairs, so it cannot decide a hop and the
rule still reads only `pageRouteGrants`.

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

* test(config): count C2.9's two bridge modules in the session route closure

#21908 recorded this pin at 4,324 for the haptics notify module. C2.9
adds two more that the same closure reaches: the page-route-grants schema
and the manifest contract whose grant grammar it imports rather than
restates, both pulled in by `bridge-envelope.ts`, which the page reads to
parse `init`.

Named in the docstring beside #21908's sentence rather than folded into
its number, because the three modules arrived from two PRs and a single
count with one reason invites the next author to assume the rest.

Red first against 4,324: expected 4,326. Measured on this head, not
inferred -- a control worktree at pristine main gives 4,324, so the two
are this branch's.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-21 01:29:02 -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 run check:tests-typecheck
pnpm lint
cd ..
pnpm typecheck:node

tsc --noEmit reads tsconfig.json, which excludes test files so Metro never bundles them. tsconfig.test.json puts them back, and pnpm run typecheck:tests shows their errors in full. check:tests-typecheck is the gate over it: a ratchet against tests-typecheck-baseline.txt, the 127 test files that do not typecheck yet. It fails when a file that checks today stops checking, and when a baseline entry starts checking (prune it with node scripts/check-tests-typecheck-ratchet.mjs --prune). The list may only shrink.

The same gate censuses the program first: every *.test.ts(x) on disk must be in it, or named in the script's TESTS_OUTSIDE_PROGRAM with a reason. Without that, a test excluded from tsconfig.test.json — or a Foo.test.tsx shadowed by a Foo.test.ts beside it, which a wildcard include drops for the higher-priority extension — would leave the ratchet silently.

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