Files
orca/mobile
Brennan Benson 34fa209fe1 fix(orchestration): native chats get each orchestration message whole instead of a check pointer (#28567)
* fix(orchestration): steer mail notices into a busy native chat's running turn

A native-chat coordinator's mail notice always queued as a card until the whole turn ended, so a
coordinator busy in a long command never saw its workers' reports. Agents that take a message
mid-turn (Claude, Codex, Pi) now get the notice at once; agents that would cancel their turn for
it (ACP) still queue it.

* fix(orchestration): drain mail that arrives while a notice is pending

* fix(orchestration): native chats get each orchestration message whole instead of a check pointer

Each mail message is sent to a native-chat agent as its own chat message,
with the sender, type, subject, body, payload and exact reply command, and
is marked read once the chat accepts it. Heartbeats are marked read, never
sent. Mail an unacknowledged check batch holds stays with that batch.

* fix(orchestration): keep the at-most-once gate's mail-lane assertions, exhaustive sender switch

* Preserve native mail compatibility and drain delivery backlogs

* Drain native mail with a finite ordered pass and scoped operation ownership

* Bound mail formatting and honor accepted queue handoffs

* Run the native mail tests that open SQLite in the Node runtime project

* Preserve live mailbox ownership and recover full reports by id

* Use a typed runtime client in inbox compatibility test

* Preserve historical mail evidence and scope the test clock

* Map historical structured-send evidence to the current test command

* fix(orchestration): settle mail handoffs per message and recover interrupted consumers

* Hold native chat mail until the chat is idle, as terminal mail waits

A mail send admitted into a running turn cancels that turn for agents that
cannot take a message mid-turn (ACP: Grok, OpenCode, OMP, Antigravity): the
second of two reports cancelled the first before the agent saw it, and the
first report cut off the turn carrying the person's answer. Mail now waits
while the chat is working or a prompt waits on the person, for every agent,
and goes out at the idle edge, one message per turn, oldest first.

* test: cover idle orchestration report turns

* fix: preserve independent mail authorization through grouped delivery

* fix: preserve routed mail identity and private inbox state

* test: bound mailbox query work on repeated delivery

* fix(orchestration): consolidate terminal mail imports

* Validate mailbox query rows and type delivery fixtures

* Keep host forwarders typed and inline single-use mail evidence reader

* Declare the mail handoff store method boundary

* Preserve mailbox contracts across typed boundaries

* Retain existing main cleanup row boundaries

* Fix mailbox handoff fixtures and helper mail transfer

* Name the mailbox fixture constructor type

* test: complete mailbox dialog pane identity

* Keep structured mail ordering and ownership current at claim

* Bound mailbox preparation and immutable cohort selection

* Consolidate structured mail contract fixtures

* Consolidate duplicate mailbox imports

* Fix mail handoff type contracts and deterministic test fixtures

* Repair journal forwarding and align mail contract fixtures

* Fix mailbox corpus message type inference

* Correct personal provenance in ACP backlog fixtures

* Correct ACP mail contract measurement and epoch wake fixtures

* Retain full ACP contract diagnostic arguments

* Restore reliability gate coverage and consolidate mail fixtures

* Preserve unknown alias release coverage

* Route native reports through the durable chat queue

* Preserve exact legacy mail holds in queue lineage proof

* Use ordinary queued messages for worker reports

* Use semantic queue and check completion for report delivery

* Record bounded queue recovery work and request cleanup proofs

* Preserve report creation evidence and bounded source wakes

* Preserve report source work and check ownership

* Use ordinary report acceptance and incremental original history

* Retire completed mail opportunities with their current owner

* Retire routed source debt and complete joined mail contracts

* Exercise Run typed peek through the command transport

* Require the queued report submission in creator coverage

* Restore database callback API and align queue contract fixtures

* Fix empty legacy tab selection in restart fixtures

* Keep relation heading assertions scoped to each report

* Native chats receive each orchestration message whole, through the chat's own queue

Each orchestration message to a chat session becomes one ordinary chat message carrying the whole
message, sent with the chat's queue like a person's message: it waits behind running work or a
question and the agent is handed one per turn. A per-message record keeps the send's operation id,
so a restart finds the card instead of sending twice; the message is marked read once the agent is
handed it, and a check that takes it first deletes the waiting card. A message too large for one
chat message keeps today's pointer to check. Terminals are unchanged.

* Test that chats get each message whole, queued like a person's message

* Repeat a report the agent may not have taken only after a later turn or /clear

A send the agent rejected or left unanswered stays unread for check and is handed over again only once
a later turn ran or a /clear replaced its context, so edges alone never restart an agent that fails on
every start. A send whose record a rewind dropped is sent again, and a send that throws is retried on
the session's next journal edge. Tests follow chats getting each message whole.

* Hand a chat orchestration mail only while it is still owed

A queued mail card now runs only while its message is unread in its mailbox and that mailbox still belongs to
the chat: the queue asks at handover and drops a stale card without a turn (abandoned or settled Dispatch,
Run taken over by another chat, mail a check or reset took). Cleanup after check, moves, run-use, abandon,
reset and worker release deletes those cards visibly and never holds the action up; mail still owed goes to
the mailbox's current owner, and Orca's own deletion is never read as the person's.

A send that may have reached the agent is never sent again; check keeps it. A send the agent proved it never
got is sent once more, under a new id, only after a later turn or /clear ordered after that failure. A send
recorded but never admitted is admitted under its own id. Each outstanding send is read by key, never by
scanning the chat's history.

A refusal no longer loses its retry when an edge arrives mid-send, and a coordinator refused for queue room
gets its mail when the queue frees room. A chat worker's follow-up now takes its short Task first and then
each moved message whole, with no check needed. The guide says a working worker gets mail when its turn ends.

* Let the chat's host answer what became of each mail send, and let the person's cards pass mail Orca cannot decide on

The chat's host now answers for each send by its id: the card first, then the send itself, then a gone card's handoff found by key, and only then the admission receipt, which alone can prove nothing was admitted. A receipt whose journal detail is gone is in doubt however old its id, so a renewed id can no longer send the same message twice. A rejected send is repeated once only after a different turn was actually handed over and accepted, or a /clear was made, after the rejection; the queue's admission cursor no longer stands in for that. No answer walks the conversation's history.

The mailbox reserves each send and stamps the message delivered in one write before the host call, so a crash after admission never leaves it selectable again. A refusal before admission releases that exact reservation; every retirement and release is checked against the current attempt, and a record an older build saved without a stamp is repaired as it retires. A person's Delete consumes an oversized pointer's mail too.

Who holds a mailbox is read from durable facts: a Run moved to a terminal, a released worker, another host or an ended chat makes a waiting card stale, so it is withdrawn without a turn and its mail released for the real owner. Only a fact that cannot be read holds a card; the person's own cards behind it go on, later mail stays behind it, and selection and the handover check use the same rule. When the owner can be read again the queue is woken. A Task or a person's edited card never opens the mail database. An explicit Send of such a card now says what happened.

Parked mail is indexed by chat, wakes only that chat's mail, ignores streamed tokens, and drops the chat's journal watch with its last mailbox. Canonical session identity is reused per session store revision instead of rebuilt from every record on each check. Main's account-switch worker test now reads "unread and still owed".

* Let the queue alone decide which card goes next, and wake a terminal given back its mail

The queue now owns one rule for what can be handed over next, installed once on each chat's queue and read by the drain, the final handover check, queue publication and scheduling alike. Mail whose owner cannot be read keeps its place; the first of the person's own cards behind it goes instead. Held cards stay held, a returned, paused or edited card behind it still stops the queue, and later mail, from any mailbox, stays behind it. The rule walks only the queue's live cards, never its history. When nothing can be handed over nothing is published as next, so the chat reads as idle and /clear works; when the owner becomes readable again the queue is published and looked at again. Choosing and publishing the next card only reads custody; settling a stale card stays with the handover.

Mail a chat gives back after its Run or worker moved goes through the runtime's own delivery, so a terminal owner is pointed at it. A chat's journal watch now lasts as long as it has mail parked, through its own redrives, so a publication that changes nothing the mail waits on starts no pass. Session identity is cached until a record joins, leaves or is cleared, not rebuilt after every admission. An explicit Send of mail whose owner cannot be read says Orca couldn't read the chat's saved state rather than that it restarted.

* Find the person's next card past undecidable mail by walking only as far as it must, and ask mail authority only once nothing else stops a send

* Say plainly when Orca can't confirm queued mail is still this chat's, and that the person can try again

* Let parked mail's own watch be the only journal subscription for mail, and stop saving a send fence nothing reads

* Count the queue's order reads with plain index getters instead of a reflected proxy

* Read saved mail sends column by column, and build test fixtures as the types they stand for

* Keep main's words for every other refused queued Send; only undecidable mail says its message wasn't sent

* Let replay read the queue's handoff links, keep the host's last mail answer where the type can see it, and drop the removed test from the send gate

* Keep the queue's possible next cards in order as each one changes, reuse the last pick until the queue moves, and look up a send by its id

* Ask whether one chat's tab closed instead of listing every open tab to find it

* Settle a mailbox's earlier mail only when something it waits on moved, republish a chat once per pass, clean up only the mail a check, abandon or move touched, and act on a chat's status only when it changes

* Date the scaling test's extra tabs by the coordinator's recorded lease instead of the wall clock

* Keep nothing in memory for a mailbox a cleanup finds with no owner and no send left, unless a pass on it is still running

* Ask about a report whose send threw before handing over newer mail, under the id it was reserved with
2026-11-02 12:08:01 -08: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.

The Node commands pnpm rpc:diff and pnpm rpc:record use a development dependency linked to ../src/packages/process-host. Mobile's install supplies the link, and tsx runs its source with the orca-source condition, so these command wrappers do not require a desktop install or package build. Keep that source directory in sparse checkouts. The recording suites still need root dependencies when they exercise desktop modules. The React Native app does not import this host package.

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 native ensure ios       # or: pnpm native ensure android [--device <name|udid|serial>]
pnpm start --dev-client

ensure computes the Expo fingerprint of the native inputs (native dependencies, config plugins, modules/, patches/, and the Expo config). On a HIT it installs the cached build on the booted simulator or the running emulator in a few seconds. On a MISS it regenerates ios/ or android/, runs expo run:<platform>, and caches the result. --rebuild forces a fresh build.

Builds are cached per fingerprint in the user cache directory (~/Library/Caches/orca-mobile/native-builds on macOS, $XDG_CACHE_HOME/orca-mobile/native-builds on Linux, %LOCALAPPDATA%\orca-mobile\native-builds on Windows, or ORCA_MOBILE_NATIVE_BUILD_CACHE_DIR), so every worktree reuses them. The newest three per platform are kept. app.config.js also registers the cache as Expo's buildCacheProvider, so a plain pnpm ios / pnpm android installs a cached build instead of compiling. Only ensure writes to the cache, because expo run reuses an existing ios/ or android/ directory even when it was generated from older inputs. Expo caches iOS Simulator builds only, not physical devices.

pnpm native fingerprint prints the current hashes. On pull requests, the Mobile native fingerprint job reports in its summary whether the PR changes them, which means dev clients need a native rebuild.

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.

Localization

UI copy goes through src/i18n, with the same call shape and languages as desktop:

  • In components and hooks: const t = useMobileTranslation(), then t('area.thing.label', 'English text'). The function changes with the language, so list it in memo and callback dependencies.
  • In helpers and imperative code (alerts, notifications): translate(key, fallback, options) from src/i18n/mobile-i18n, called when the text is shown, never at module scope.
  • Counts: count === 1 ? t('x.files_one', '{{count}} file', { count }) : t('x.files_other', '{{count}} files', { count }).
  • Dates and numbers: pass getUiIntlLocale() to Intl formatters instead of undefined.
  • Keep identity separate from display text: never compare or key on a translated string.

Run pnpm sync:mobile-localization from the repo root to add new keys to src/i18n/locales/en.json; other catalogs may lag and fall back to English. pnpm verify:mobile-localization and pnpm verify:mobile-localization-coverage run in CI. iOS permission prompts live in locales/<lang>.json.

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 the update that clears it. When mobile is too old it opens the newest release if the installed app's update check knows one, otherwise the App Store (iOS) or GitHub Releases (Android). When desktop is too old it opens GitHub Releases.

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