* test(e2e): add a paired phone client helper and phone-mirror baseline specs Later phone-mirror slices need one oracle that drives a real mobile-scoped device with the phone's own capabilities against a serve host or a paired desktop. Pins #26022 across a serve restart and today's gap: a desktop's phone does not list the desktop's server workspaces. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): show SSH host health on the workspace row host badge Rows on an SSH host the desktop cannot reach looked healthy on the phone. The badge now adds the desktop sidebar's own word ("devbox · Disconnected"), derived by the same shared function from the connection status ssh.listTargetSummaries already sends; old desktops keep today's badge. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(e2e): compose the phone-mirror topology and check the cold snapshot Slices need one composer for host, desktop and phone with a single teardown, so specs stop copying setup. The serve-restart spec now reads the first tab-stream snapshot after the cold start, which is what the phone builds its tab strip from. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name an unhealthy SSH host even in a single-host list The desktop card shows SSH status on any host count, so the phone now reads target summaries whenever a repo is on SSH and badges an unhealthy host alone too. Labels and health share one target-to-host keying, and a hook-level test pins the reply-to-row wiring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(e2e): keep phone-mirror teardown errors from masking test failures Teardown failures become a test attachment instead of replacing the real failure, and the cold-snapshot poll reports stream errors together with the PTY list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): clear stale SSH host badges and announce them without the dot A catalog that no longer spans hosts or holds an SSH repo now clears the health and label maps, so a host that left cannot keep badging rows. The badge gets a screen-reader label ("devbox, Disconnected") instead of reading the middle dot. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(runtime): let a paired desktop register its phones as delegated mobile devices A desktop paired to this runtime can now call pairing.delegatedMobileDevice.sync to make each of its phones an ordinary mobile-scope device it owns, so the phone mirrors that host through the desktop. Children are never pending, inherit the parent's reach, and die with the parent's grant. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(runtime): move the mobile device revoke primitive below dispatch Dispatch binds the caller-bound delegated-device context, so the revoke path it needs now lives in a layer under it as a pure move; this drops the abstract method seam. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): keep a runtime grant until its delegated phones are revoked A failed child cleanup now leaves the parent in place so a retry can still find the children, and revokeRuntimeAccess lives beside the revoke it cascades to. Adds restart, rename and bounds coverage, shares credential minting, and drops the timing-dependent census. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(runtime): fail a delegated sync whose dropped phone cannot be revoked Matches the parent revoke: a child whose cleanup could not be saved is kept and the sync errors before minting anything, so the desktop retries instead of believing the server matches its list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): relay a paired phone's server-workspace requests through the desktop A phone mirrors its desktop, including workspaces on the desktop's configured servers. The desktop now forwards a request that names `executionHost: runtime:<env>` to that server over one socket per (phone, server), signed in as the phone's delegated device; replies pass through untouched except stream ids. Hidden until the phone client ships: the relay capability is not advertised yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the single-host health fixtures inline so the test tsc passes Fixtures hoisted into consts widened their health maps to string, which the now-required hostHealthById rejects under the tests-typecheck ratchet; passing them inline keeps the union. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): state the desktop relay contract in its entry module main dropped docs/reference, so the relay's contract lives beside the code. Also pins that a server whose delegated sync fails (e.g. delegated_device_revoke_failed) is unavailable until a later request syncs it again. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the relay's configured-server adapter The production hosts adapter was only typechecked; this pins that it resolves configured servers by exact id only (never a name or a removed server), fences by pairing revision and runtime identity, and stops reporting retirements once unsubscribed. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): re-record the repo-metadata goldens for SSH host health The settled metadata state now carries hostHealthById, and an SSH-only catalog now reads the target summaries so a lone unhealthy host can be badged. Scoped re-record via rpc-recording.mts. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): tighten the desktop relay after elegance review Reuses the shared E2EE socket opener, folds relay state into fewer fields, never sends a request whose link ended while opening, passes failure codes through, relays only this desktop's own phones, and drops the unused host-state reader until S2 needs one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): bound relay frames and stop sticky relay verdicts Restores the 8 MiB frame cap on every remote runtime socket, re-checks an update-needed server on each new link so an in-place upgrade relays, answers a phone the server just declined without syncing again, and ends a link whose mirrored capability update cannot be queued. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run targeted desktop-owned calls on the desktop The phone tags every call on a workspace screen with its execution host, so a paired-desktop method carrying `runtime:<env>` now runs locally instead of failing 'forbidden'. The Mac stays the only reader of the routing table. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): list the desktop's configured servers and their last workspace rows for the phone The phone needs the same host list the desktop shows, including servers that are offline or too old to relay to. mobileRelay.hosts.list/worktrees answer from the desktop's own status and its own worktree.ps fetches, cached per server and fenced by pairing, never from relayed replies. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): list server rows through one desktop fetch, with no side effect on listing The phone now always lists a server's rows through mobileRelay.hosts.worktrees, so the desktop's own fetch is both the live source and the cache, and stamping stays in main. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): fetch a server's full workspace list within a bound, and forget removed servers The desktop now asks for the phone's full-catalog limit, answers within 5s with the last rows when a server hangs, drops a server's rows when it is removed or re-paired, and checks the server's identity after the fetch so a reply from before a re-pair is never served. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): name a server workspace's host on the phone's calls A phone reaches a server's workspace through its desktop only if each call names the host; the direct, cloud-relay and shell-bridge transports now carry `executionHost`, streams keep it for replay and unsubscribe, and an older shell (no `executionHost` grant) refuses rather than strips it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): list managed servers as the desktop sidebar does, and hide rows mid-delete A server Orca manages on an SSH host now lists once under that host's name, or not at all while the host is reached over the SSH relay, using the sidebar's own folding. Rows the server is deleting are left out at reply time, as a phone asking the server itself would see. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a disconnected server's last rows, and never join a re-paired server's fetch Retirement also fires on a manual disconnect or managed-server update, which wrongly emptied the phone's list; rows are now pruned only once a server is no longer configured. Fetches are shared per server identity, so a poll after a re-pair asks the new server right away. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): list the paired desktop's server workspaces on the phone Fixes the phone missing a desktop's `orca serve` workspaces: rows come from the desktop's mobileRelay.hosts.* with each server's health ("Update needed", "Disconnected"), open through the relay, follow the desktop's hidden hosts, and the desktop now advertises the relay capability. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin that a desktop failing the hosts list leaves only its own rows The page asks a relaying desktop for its servers; a refusal such as method_not_found must leave today's list with no server rows and no catalog error. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): mirror the desktop's shown hosts exactly, one host included Jinwoo's call: the phone follows visibleWorkspaceHostIds as the desktop sidebar does, so a one-host list narrows the phone too instead of being read as a single-host focus. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): scope a server workspace at its route, never the desktop Every screen under a server workspace's route now reads one context and gets that server's client and a router that keeps pushes on it, refusing to fall back to the desktop; pins write to the row's owner like the Mac. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): never open an unreachable server workspace, and prove the relay in e2e A server workspace the phone can't reach now gets one explicit screen at its route and no row tap or action-sheet entry opens it; native routes read the route's host, a census pins every workspace route, and the e2e reads files and git on the server. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): gate a server workspace on that server's own features A targeted status.get now relays to the server, so a server workspace's route provides that server's status to every screen under it and its action sheet, while whether the phone can reach the server stays the desktop's answer. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(ci): run the remote-runtime WebSocket opener test on Node Under the bun vitest project, `ws` resolves to Bun's built-in client, which ignores maxPayload, so the frame-cap test hung. The opener only ships on Node/Electron, so run its test in the Node runtime project like its siblings. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run a server workspace's SSH calls on that server A phone in a server workspace asks for SSH state before a file mutation, but the Mac answered about its own targets, so server-owned SSH repos failed. The desktop asks the server, so the ssh.* family now routes by target; an e2e pins files, uploads, commits and review on the server. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep the server files/git e2e offline and baseline-git safe Review eligibility reached real gh/glab over the network and proved nothing beyond repoSlug; setup git now ignores the runner's config and avoids `init -b` (Git 2.25). The relay integration test is the routing oracle, so the census re-listing goes. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): sleep, wake and new workspaces on a desktop's servers from the phone The desktop renderer alone sleeps a server's workspace and holds its slept agents, so the phone now asks the desktop (two paired-desktop methods) instead of the headless server; the new-workspace sheet lists and creates on reachable servers the way the desktop's composer does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): sleep and wake only a server the desktop has configured A phone's sleep or wake names a server by host id; the desktop now refuses one that is not in its own configured servers, read from its settings rather than any fetched workspace list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep server workspaces' repos, picks and sleeps on their own host A hung server no longer holds back the desktop's repos, a repo id shared by two hosts no longer swaps the pick, and a server row's optimistic sleep waits for the server's own list to confirm it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): give the server-workspaces test rows every Worktree field The tests-typecheck ratchet checks mobile test files; the fixture row was missing required Worktree members, so the file no longer typechecked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): give the file preview the workspace router, back to 128 page assets The preview was the one workspace screen on the plain router, so the scoped router was reached by a route set of its own and esbuild split it into an extra chunk (129 assets); every workspace screen now uses it and it shares the route's chunk. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * revert(mobile): drop server sleep and wake from the phone for now The workspace-layout refactor deletes the notifier path these used; sleep and wake on a server come back later as server-side layout.sleep / layout.wake. A server row's Sleep behaves exactly as on feat/mobile-remote-workspaces. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-relay): derive the phone allowlist from the routing table Adding a phone method now takes one edit (its route). A server-targeted call on a legacy non-E2EE socket is refused instead of running locally, and only execution-host methods enter the relay. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile-relay): drop unread freshness fields from server workspace lists The phone reads only the rows, so stale, fetchedAt, totalCount and truncated were dead wire weight. The desktop still serves a server's last rows while the server is unreachable. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read only the rows of a server's workspace list The desktop no longer sends a freshness flag, so the phone's schema drops it. The relay capability list names the delegated-devices constant instead of repeating its literal, which also retires a now-circular type check. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): open server-workspace diffs and file taps on the phone's own screens A headless orca serve has no renderer, so files.openDiff / files.open answer renderer_unavailable and the Changes tap showed that raw code. On that refusal the phone now opens its review screen (git.diff) or file preview (files.read) instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): show a server workspace as disconnected while its server is down The phone read the desktop's connection state for a server workspace, so a stopped server kept a green dot and "Loading tabs" spun forever. The workspace route now narrows that state by the server's health from mobileRelay.hosts.list, reusing the existing disconnected session UI and refetching tabs when the server returns. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name run targets and the desktop's own row as the host list does The Run on picker showed a server's raw environment id behind "Remote ·" and always a monitor glyph; it now takes the host list's badge labels and icon, as the desktop's picker does. Server rows also made the desktop row read "This computer" because its platform was only read for a mixed desktop catalog. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): drop Open in Session and preview new notes in server workspaces A server workspace's host has no editor tabs, so the review screen no longer offers Open in Session there, and a new markdown note whose files.open answers renderer_unavailable opens in the file preview instead of a raw error toast. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): share one desktop server-list feed between the host list and workspaces A server workspace polled mobileRelay.hosts.list beside the host list's own read, so the phone could ask the desktop twice per tick and the row badge and the screen could disagree. One feed per desktop client now owns that read; both readers join it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(e2e): read a server's reachability from the hosts list, not a dropped stale flageff410fe30dropped `stale` from mobileRelay.hosts.worktrees, so the mirror spec's schema never parsed and its first poll timed out. It now waits on the server's relay/health from mobileRelay.hosts.list, the field the phone itself reads. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * build(mobile): re-measure the page's script sweep for server workspaces Main already sat on its 78-script ceiling (re-swept: 78, table: 74); the workspace route's shared modules split importer sets three ways more, reaching 81. Re-swept per route prefix on this head, as #24257 did for its feature; the spread is now 1 to 11. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): mock react-native where the server-list feed now loads it The merged server-list feed imports react-native's AppState, which vitest cannot parse unmocked. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): offer the desktop's servers in a new workspace opened before the list loads The sheet's run targets came from the host list's server rows, which never load while the sheet is open (the list deliberately pauses its refresh behind it). From Home's New Workspace that left only the desktop's repos. The sheet now reads the desktop's server-list feed itself while it is open. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): name the desktop's servers in a new workspace opened before the list loads The previous fix offered the servers but still named them from the host list's rows, so a sheet opened from Home showed a server's raw id under Run on. The sheet now takes both the servers and their names from the server-list feed while it is open. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): export the server-list feed's React reader beside the feed useRelayHosts moves from workspace-server.ts into relay-hosts-feed.ts, so every reader (workspace screens now, the new-workspace sheet next) subscribes through the feed's own module. No behaviour change. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the server run-target fixture's host id as a runtime id A plain string executionHostId is not an ExecutionHostId, so the mobile tests typecheck ratchet refused the file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(e2e): stop requiring the dropped stale flag in the files and git review speceff410fe30removed stale from mobileRelay.hosts.worktrees, so the schema never parsed and the row poll at :100 timed out. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): offer a server's SSH repos on the New Workspace sheet The desktop's composer offers a paired server's own SSH repos and connects them through that server. The phone left them out only because ssh.* was the Mac's own; it now reaches the server (#26519), so the drawer's existing SSH gate connects through the picked repo's server client. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): hide New Note where the host opens no editor tabs; drop stale pushes A server workspace's host has no desktop window, so New Note left an unopenable untitled.md each tap. It now hides under the same route rule as Open in Session, and the no-window fallbacks no longer push a screen after the user has moved on. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): let the new-workspace sheet read its servers where it knows it is open The sheet's controller already owns whether it is open, so it now reads the desktop's server list and names itself; the overlay's mirrored visibility state and the list-side hook go away. A server row no longer offers Sleep, which would sleep it on the desktop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): key the New Workspace SSH gate by the host holding the target The desktop and a server can each have an SSH target with the same id. The gate's state, Connect progress and detected agents were keyed by that id alone, so switching to the server's target showed the desktop's "connected" until the server answered, and let create through. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): name the editor-tab gate for what it measures useWorkspaceOnPairedHost says the workspace runs on the paired desktop itself; its doc states the rule (hide actions whose only result is an editor tab) and its known gap. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep polling the server list and read a server's reachability from relay A hosts-list read slower than the poll stopped the feed for good, since the next tick was scheduled when the read started; it is now scheduled from the finish. A server workspace now reads connected only when the desktop relays to it, so a server with no status yet is not shown up. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): list the desktop's server repos on the phone's Tasks screen The desktop's task page lists every host's repos and sends each repo's work-item calls to the host that owns it. The phone listed only the desktop's own repos; it now adds each reachable server's repos and runs every call naming one of them on that server through the desktop. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): offer only the repos the desktop's New Workspace offers The sheet now builds its lists from the desktop's own eligibility rule, so a server's per-workspace-environment SSH repo never appears or wins a server's row. A connect reply that arrives after the picked target changed no longer overwrites the newly shown target's state. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): show a reached server as connecting and a too-old one as disconnected A server the desktop has reached but whose status is not answered yet is moments from relaying, so it reads connecting; a server too old to relay to never will, so it reads disconnected. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): export the New Workspace SSH target key Names an SSH target by its holding host and id, so other server-aware screens can key their SSH state the same way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): let Tasks show the desktop's repos before a server answers The Tasks list waited for every server's repo list, so one slow server held up the desktop's own. The desktop list now settles alone and each server's joins as it answers, through the same hook the new-workspace sheet uses. Server repos name their host in the repo pickers, and agent detection takes the router's per-repo resolver. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the New Workspace sheet on a hidden repo's project A last-visited per-workspace-environment repo is hidden, so the sheet fell back to the first listed project. It now maps it to its project's eligible copy with the desktop's resolver, moved to src/shared. The phone's mirror of the seeding rule is gone, since the hook imports the shared one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run a server SSH repo's Tasks checks on that server F2 lists a server's SSH-backed repos, so Tasks now reads and connects their SSH targets through the repo's own client and keys the drawer's SSH state by (host, connectionId): the desktop and a server can each hold a target with the same id. Tests drive the real GitHub list loader. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep a Tasks repo filter and the listed items while servers join A saved selection naming a server's repo waits until every listed server has answered or failed, instead of widening to all repos. A server's repos joining reload the list quietly, so the desktop's items stay up, and a mixed list names the desktop's own host in the pickers too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): apply a Tasks repo filter early and keep it through server blips A saved filter applies once every saved id is listed or every server has answered, so a desktop-only filter never waits on servers, and a server that drops out briefly keeps its ids. One shared hook gives both repo pickers their server clients, keyed on the ready set, and an unchanged server answer no longer reloads the list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): derive the Tasks repo filter instead of pruning it The saved selection is now the user's choice, written only by them and never pruned; every reader narrows it to what is listed now, so a late, flapping or reconnecting server keeps its repos selected and a removed one reads as all. This deletes the reconcile hook, its refs and the server-settled tracking. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): edit the Tasks repo filter from what's shown Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): load the Tasks list once per server repo change Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(shared): move the account capabilities into their own group module Main's merge put protocol-version.ts at 301 lines, over the 300 cap. The account group moves out like the relay and skill groups did, same strings in the same order; the page's script sweep is re-measured because the split changed its chunking (81 to 79 at 16 routes). Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): read the Tasks mock's exports from a typed map Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): use type imports and full deps in the Tasks server-repo code Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): name a server workspace through the request envelope The server name now rides the generic request envelope from #27815 instead of its own executionHost option, bridge grant and per-transport plumbing. Server rows show only where the transport carries an envelope (native, or a shell that grants rpcEnvelope), so an older app keeps today's list. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the server-call wire and the no-envelope deep link A server workspace call still reaches the desktop as the same top-level executionHost frame, on the direct and relay wires, and a deep link into a server workspace under an app without rpcEnvelope shows the unreachable screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): read a relay stream's envelope slot in sorted key order Equal envelopes name one cleanup slot whatever their key order, the scoped view passes its client's envelope answer through as it does the generation, and the direct wire harness is shared instead of rebuilt. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a phone's new chat in a server workspace follows the desktop's chat setting A relayed agent.launch ran on the server, which picked chat vs terminal from its own setting. The desktop relay now stamps its own default as nativeChatDefault; new servers use it in place of their setting (feasibility still decides), older servers strip it. Outside the replay fingerprint. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(mobile): move the relay's delegated phone grant cache into its own module Keeps mobile-desktop-relay.ts under the line limit once #27749's page relay lands on it. Co-Authored-By: Claude <noreply@anthropic.com> * revert(mobile): stop offering a server's SSH repos on the phone Since #27609 a server cannot serve SSH repos at all (repo.addRemote throws and SSH-routed work on a server fails), so the phone should not offer them. This reverts #27728's own commits (c4c058ad15,4a5ccaa9a3,3b414b8842,a2bb1cf8a5,adb8ac5825) and the server-SSH parts ofce4b125b40: the New Workspace sheet and the Tasks screen again leave a server's SSH-backed repos out, and Tasks' SSH checks run on the desktop with its SSH state keyed by connection id only. Affected goldens re-recorded with `pnpm --dir mobile rpc:record`. Kept fromce4b125b40: useHostProtocolGates in host-protocol-gates-context (later commits import it there), the Tasks route requiring the gate, and the typed ListedRepo (the SSH filter reads it). Kept from #27728: nothing; the SSH filter now lives in the shared listServerRepos both screens use. The Tasks filter-edit test lists a third server repo so picking two stays a filter. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): show the early terminal tab from the relaying desktop's chat default The early-tab preflight read the server's own chat setting while the launch used the desktop's stamped nativeChatDefault, so on a windowed server with chats off a relayed chat launch flashed a terminal tab. Both now read the settings through readAgentLaunchModeSettings(runtime, nativeChatDefault). --------- Co-authored-by: Claude <noreply@anthropic.com>
Orca Mobile
React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone.
Local development uses two processes:
- Orca desktop/Electron from the repo root. This hosts the mobile WebSocket RPC server on port
6768. - Expo Metro from
mobile/. This serves the React Native app on port8081.
Unless a command says otherwise, run mobile app commands from the mobile/ directory.
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 exec expo run:android
pnpm exec expo run:ios
pnpm start --dev-client
Pair With Desktop Orca
- Open Orca desktop.
- Go to Settings > Mobile.
- Scan the pairing QR code from the mobile app.
- Confirm the mobile host endpoint is
ws://<desktop-ip>:6768.
For the Android emulator, use ws://10.0.2.2:6768. For a physical phone, use the desktop LAN IP, for example ws://192.168.0.179:6768.
If the phone has a stale host entry, remove it from the app and pair again.
Development Paths
Android Phone
- Install Expo Go from Google Play
- Run
pnpm start, scan QR with Expo Go - For native modules:
pnpm exec expo run:android - Run with
pnpm start --dev-client
iOS Simulator
- Install Xcode from the App Store
- Run
pnpm start --iosto open in iOS Simulator
Physical Phone Debugging
The phone can be inspected through the connected device tooling:
orca snapshot --json
orca click --element @e3 --json
orca fill --element @e1 --value "ls" --json
orca screenshot --json
Use snapshot first to find the current element refs, then click/fill those refs. After mobile file edits, Metro usually hot reloads automatically, but navigating out of and back into the session screen can be useful because it re-runs terminal.subscribe.
Terminal Streaming Repro Without A Phone
Use this when terminal output does not render on device and you need to split server streaming bugs from WebView/UI bugs:
cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64>
You can pass a worktree selector as the third argument:
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "path:/absolute/worktree/path"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "name:my-worktree"
The expected result includes:
streamSawMarker: true
readSawMarker: true
If this repro fails, debug the desktop runtime/PTY path before the mobile WebView. If it passes but the phone is blank, debug the session screen or TerminalWebView readiness/queueing path.
Terminal Color Repro Without A Phone
Use this when terminal colors disappear after switching tabs. Open a Claude Code terminal and at least one other terminal in the target worktree, then run:
cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/repro-terminal-colors.ts \
<deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
The script captures terminal.subscribe snapshots in an A → B → A sequence and writes raw snapshots to mobile/terminal-color-repro/. If the two A snapshots have different sgrColor counts, the desktop snapshot changed during the switch. If they match, the ANSI color data is still present and the bug is in mobile replay/rendering.
Validation
Run these checks before committing mobile terminal changes:
cd mobile
pnpm exec tsc --noEmit
pnpm 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.ts—DESKTOP_PROTOCOL_VERSION,MIN_COMPATIBLE_MOBILE_VERSIONmobile/src/transport/protocol-version.ts—MOBILE_PROTOCOL_VERSION,MIN_COMPATIBLE_DESKTOP_VERSION
Today all four are set so evaluateCompat always returns { kind: 'ok' } — nothing blocks. The wire format is in place to flip a switch when needed.
When to bump
Bump DESKTOP_PROTOCOL_VERSION (and the mobile mirror MOBILE_PROTOCOL_VERSION when relevant) for breaking changes:
- Removed RPC method or required parameter that mobile uses
- Changed meaning (units, nullability) of an existing field mobile reads
- Changed encryption, framing, or auth handshake
Do not bump for additive changes:
- New RPC methods
- New optional fields on existing methods
- New event types in
terminal.subscribe
Set MIN_COMPATIBLE_MOBILE_VERSION (kill-switch) when desktop ships a change that requires a minimum mobile version to function safely. Same for MIN_COMPATIBLE_DESKTOP_VERSION from the mobile side.
When a verdict is blocked, mobile/src/components/ProtocolBlockScreen.tsx renders a screen pointing the user at 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— withMOCK_NATIVE_CHAT=1, present an OMP tab and four decoded transcript messages, including a tool call and result, instead of the default Claude scenario. It deliberately omitstranscriptPathto exercise legacy-hook readability discovery; current OMP hooks may report a path.MOCK_SERVER_KEY_FILE— persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair.
Scenario control files
Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default.
MOCK_SEND_MODE_FILE(defaultorca-mock-send-modein the system temporary directory) —accept(default) accepts the send,errorfails it withmobile_input_floor_unavailable, anything else reports the send as rejected.MOCK_TERMINAL_LIST_MODE_FILE(defaultorca-mock-terminal-list-modein the system temporary directory) —omitreturns an empty terminal list,otherreturns a list that omits the chat handle, anything else lists it.MOCK_TERMINAL_STREAM_MODE_FILE(defaultorca-mock-terminal-stream-modein the system temporary directory) —deadanswers a subscribe withsubscribedthenend(a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.
Connecting to Real Orca
- Start Orca desktop with WebSocket transport enabled
- In Orca, go to Settings > Mobile and scan the QR code with this app
- The QR encodes the connection endpoint, device token, and TLS fingerprint
Project Structure
mobile/
├── app/ # Expo Router screens (file-based routing)
│ ├── _layout.tsx # Root layout with navigation stack
│ ├── index.tsx # Home screen — paired hosts list
│ └── pair-scan.tsx # QR code scanning screen
├── src/
│ ├── terminal/ # Terminal WebView and xterm bridge
│ └── transport/ # WebSocket RPC client
├── scripts/
│ ├── test-subscribe.ts # Desktop streaming repro without a phone
│ └── mock-server.ts # Standalone mock WebSocket server
└── assets/ # App icons and splash screen