Skip to content

feat(agent-launch): keep long prompts off the launch line and paste them after readiness (step 1 of 7) - #24257

Merged
brennanb2025 merged 26 commits into
mainfrom
brennanb2025/agent-launch-host-prompt-delivery
Oct 5, 2026
Merged

brennanb2025 merged 26 commits into
mainfrom
brennanb2025/agent-launch-host-prompt-delivery

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 44 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3234 $\color{#cf222e}{\Huge{\mathbf{−}}}$​69 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​3165
Prod 58 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1533 $\color{#cf222e}{\Huge{\mathbf{−}}}$​302 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1231

ELI5

The problem. The host's agent.launch typed a launch prompt into the shell as part of the command that starts the agent, whatever its size or shape. A multi-line prompt typed into a shell runs line by line, a long line can wedge an SSH shell that is still starting, and Windows cmd caps a line at 8191 characters.

What this changes. agent.launch keeps a prompt on the command line wherever Orca has measured that the typed line arrives whole, so the agent starts with it as on main. That covers a short one-line prompt in any shell, and, on a local host whose shell is zsh (the macOS default), a multi-line prompt whose lines are each 512 bytes or shorter, up to 8 KB. Any other prompt stays off the command line. The agent starts clean, and the host pastes the prompt into it once the agent shows it is ready, with the same signal, bytes and Enter timing the desktop's own paste uses. The paste needs positive proof that the launched agent, not the shell, holds the terminal: one read of that pane's own terminal, a few milliseconds. If the agent never shows readiness, exits at startup, or the host cannot tell what is in front, nothing is typed anywhere and the launch reports the prompt as not delivered. On Windows, where no read can tell an agent from its shell, the prompt rides the launch line at any size, as on main.

The plan, and where this PR fits. This PR is step 1 of 7:

  1. feat(agent-launch): keep long prompts off the launch line and paste them after readiness (step 1 of 7) #24257 (this PR): the host owns prompt delivery.
  2. Stage long agent launch lines where they are typed, so they arrive whole (step 2 of 7) #23962: long launch lines arrive whole.
  3. Carry an agent's first prompt on its command line instead of pasting it (step 3 of 7) #24080: the first prompt rides the agent's command line.
  4. Start orchestration workers with their brief on the launch command line (step 4 of 7) #24081: orchestration workers get their brief on the launch line.
  5. Launch identity and a two-write launch record.
  6. Placement and the instant tab.
  7. feat(source-control): start the AI buttons' agents through agent.launch (step 2 of 2) #23748, reworked: the desktop AI buttons (Fix checks, Resolve conflicts, commit/push-failure recovery, Explain commit) go through agent.launch.

What changes for existing callers

Caller Before After
RPC agent.launch with a prompt, terminal agent (the phone and other RPC clients; no orca CLI command calls it) Prompt folded into the typed launch line at any size, line breaks included Rides the line as on main when it is one control-free line of at most 512 bytes, or, on a local zsh host, multi-line with every line at most 512 bytes and the whole line at most 8 KB. Otherwise pasted after readiness, or reported not delivered
Phone AI buttons and review notes, against a host on this version Prompt passed as one quoted argument on the launch line (released desktops v1.4.208 and later) On a local zsh host (the macOS default), review notes ride the launch line as on main, and the RPC reply comes back right after the spawn as before (re-run at e463aea: same launch line and timing as main). The commit-failure Fix still pastes after readiness: its prompt carries the hook's output as one 2,163-byte line, over the 512-byte per-line size measured safe. Intended, per that measurement; #24080 removes the delay. On bash, fish or an SSH host to macOS or Linux, the prompt is pasted after readiness. On Windows (cmd, PowerShell, Git Bash, WSL) and on an SSH host running Windows, it rides the launch line as on main. The phone's own code only moves its launch-refusal rules into the shared classifier, unchanged
Orchestration worker start tui-idle; ZCode, OpenCode and OpenCode2 wait for the composer marker their captured startups prove (main's lane table, #24601) The same lanes. On tui-idle, a bare agent-name title now counts only once output goes quiet, for local and paired-server (federation) worker starts alike, since both go through this PR's worker-start wait. A worker started in an existing terminal keeps plain tui-idle. Grok, DSH and mimo-code, whose only rest signal is that bare name, also answer on their composer marker, whichever comes first. Before the brief is typed, the worker host must find the launched agent in front (on Windows, which cannot, only a shell found in front stops it); otherwise the start fails at dispatch_input and nothing is typed
Desktop draft paste (new-workspace dialog, and the source-control buttons until step 7) Unchanged path Unchanged bytes and timing; its paste frame and 50 ms Enter constant now live in src/shared so the host reuses them. The second Enter Qwen Code now gets does not apply here: the dialog pastes its draft without Enter on purpose. It applies to the source-control buttons' paste and to the host's own prompt write

Measured in the final live QA (2026-10-02, M4Air and Windows stubs, real Claude on macOS): an agent that exits at startup gets nothing typed into its shell in zsh, fish, Git Bash and WSL, and the launch reports not delivered (9 of 9 zsh and fish runs with shell hooks logging every executed command and the command-line buffer). Each read of what is in front of a terminal takes 3–59 ms, where it took 3.4–10 s before. A hooks-off orchestration worker is reported ready in 0.67–1.38 s against main's 8.5–9.4 s (4 interleaved pairs, load 9–26). A worker brief with real Claude is submitted at a median 8.51 s against main's 8.57 s. A short one-line prompt is as fast as main on every shell. A 2 KB prompt on the paste path reaches the agent about 1.5 s after the agent is up, the ready signal's quiet window, which is later than main, where it rode the command line; #24080 removes that for Claude and Codex. A Windows Gemini worker's brief is slower than main (see Deviations).

Measured earlier on a combined build of this PR plus #23748 (step 7, the desktop buttons, the same host code): worker brief submitted 8.37–8.38 s after worker-start against main's 8.38–8.45 s; Enter 52–55 ms after the paste against main's 52–54 ms; Windows Qwen Code submitted once (2 of 2).

How it works

  • The terminal that builds the launch command measures the actual typed line. A one-line line with no control characters rides at up to 512 bytes, half of macOS's limit for a line typed into a shell that is still starting. On a local macOS or Linux host the host names the shell the line is typed into, the same way the spawn picks it (the request's shell, the default-shell setting, then SHELL). For zsh, a multi-line line also rides, but only when every line is at most 512 bytes and the whole line at most 8 KB. The terminal reports whether the prompt rode, and the launch pastes only when it did not.
  • The limits come from typing lines into real zsh, bash and fish through Orca's own ready barrier and startup write on macOS, including with a user config that outlasts the barrier's 1.5 s cap, so the line lands while the terminal is still line-buffered:
    • zsh read multi-line lines of up to 8 KB whole with short lines, and also took them when the write landed early.
    • When the write landed early, a single line over 1 KB was lost in zsh, bash and fish. In zsh, so was a multi-line line containing a line over 1 KB.
    • macOS bash 3.2 lost every multi-line line, on main's path too, because it has no bracketed paste.
    • fish lost every multi-line line when the write landed early.
    • startup-line-typed-length.live-shell.test.ts pins the zsh case, both normal and early.
  • Readiness is the desktop paste's signal: bracketed paste turned on, then the agent's composer marker or 1.5 s of quiet, within the agent's budget (8 s by default). A startup dialog or Codex's "model: loading" screen never counts. After the budget, the host checks tui-idle for the rest of a 60 s overall wait, re-checking a dialog every second; with no readiness there either, nothing is pasted. Agents whose composer marker a captured startup proves (main's lane table: ZCode, OpenCode and OpenCode2) wait only for that marker, for the whole 60 s. Every other agent, DSH and Grok included, takes the composer signal above and then tui-idle.
  • Only output after the shell's last bracketed-paste-off counts. That alone cannot tell an agent from its shell: an agent that exits at startup leaves its shell at a prompt that turns bracketed paste on and goes quiet, which is exactly the ready signal. So the signal is never proof by itself. Before the paste, before Enter, and before the second Enter, one fresh read must find the launched agent holding the terminal (src/main/runtime/launched-agent-foreground.ts). On a local macOS or Linux host it is one ps limited to the pane's own terminal (terminal-foreground-group.ts, about 3 ms): the launched agent or any non-shell process in the terminal's foreground process group is the agent, which also finds an agent behind a wrapper that did not exec it; a group of shells alone is the shell. On an SSH host it is the relay's process-group observation, then the relay's own read of the terminal. Only that answer lets a write through; a shell, or a read that cannot tell, refuses it and the prompt is reported not delivered. The same read finding the shell also drops the ready signal and keeps waiting. A reused terminal goes through the same check.
  • Windows has no such read: its scan names the pane's shell for an agent it cannot recognize, and Git Bash and WSL keep other processes in the shell's job. So a Windows host never pastes a launch prompt. The launch line carries it there at any size, as on main, decided before the spawn (launchHostProvesAgentInFront).
  • The paste uses the desktop's frame (wrapTerminalBracketedPasteText, moved to src/shared/terminal-bracketed-paste-text.ts), then Enter 50 ms later, plus a second Enter for Codex and Qwen Code. One ownership read before the paste also covers the Enter unless the terminal shows a shell coming back.
  • A replay-safe launch admits and claims its ledger row in one durable write. The desktop and phone share one rule for what a launch refusal means (src/shared/agent-launch-replay-refusal.ts). Hosts advertise agent.launch.prompt-carry.v1. One addition exists only for step 7: an explain_commit launch source for telemetry. agent.launch gains no new request field: how a launched agent's tab is placed and shown is the host's call, and step 6 brings any placement intent under its own placement object rather than as a top-level field. A prompted agent.launch is a long poll, so the local socket stays open while it waits for the agent.

Other changes outside the launch path.

  • A bare gemini window title (a shell's auto-title) no longer gets Gemini's idle mark. That normalization feeds tab and sidebar titles and every idle wait, not only launches. The readiness census records it: name-only Gemini rows settle as weak instead of strong, and Gemini's own fresh working or blocked status is no longer overridden by that title.
  • Paired callers (the phone) no longer receive the host's "could not reveal the tab" warning, since they place the tab themselves.

Not covered by this PR. orca worktree create --prompt calls worktree.create, not agent.launch. It has no paste of its own, so it keeps main's fold of the prompt into the typed launch line at any size, unchanged. The same holds for automations. Both are out of scope here.

Why this design. One host rule protects every caller of agent.launch, instead of a per-caller flag an older host would ignore. Reusing the desktop's signal and paste frame keeps timing and bytes equal to what users get today. Writing only on positive proof that the launched agent holds the terminal means a crash, a wrapper, or a read that cannot answer can never put the prompt into a shell, and reading only that pane's terminal keeps the check to milliseconds instead of a whole-machine process scan. Windows has no such proof, so there the prompt rides the launch line as it always did.

Deviations from main

  • Intended: a prompt Orca cannot prove arrives whole on the typed line is pasted after readiness instead of typed. The phone's commit-failure Fix is one: its hook-output line is 2,163 bytes, over the measured 512-byte per-line size, so it still pastes, and Carry an agent's first prompt on its command line instead of pasting it (step 3 of 7) #24080 removes that delay. That is a line over 512 bytes, or a multi-line prompt into bash, fish, an SSH host or Windows, or past 8 KB. On main these were typed and could be lost: bash 3.2 lost every multi-line line, and a line over 1 KB written while a slow shell was still starting was lost. Main was faster where they did arrive whole, for example a long single line or a Linux bash 5 host with a fast config. Stage long agent launch lines where they are typed, so they arrive whole (step 2 of 7) #23962 and Carry an agent's first prompt on its command line instead of pasting it (step 3 of 7) #24080, per their description, carry exactly these prompts for Claude and Codex by staging the long line or putting the prompt in a launch file. Other agents and paired hosts keep the paste there. Also Intended for agents whose CLI takes no prompt argument, and for worker starts.
  • Intended: no blind paste. Where the desktop pasted once its budget ran out, the host asks tui-idle and otherwise reports the prompt as not delivered.
  • Intended: a write needs a fresh read that finds the agent in front. A read that cannot tell refuses the write and reports the prompt not delivered. That is not a claim that anything exited (docs/reference/ssh-execution-boundary.md); it only declines to type into a terminal Orca cannot vouch for.
  • Intended: on Windows hosts, launch prompts ride the launch line at any size, as on main, including main's limits there (cmd's 8,191-character line). No Windows read can prove the agent holds the terminal, so a paste there is never safe.
  • Intended, narrow: an agent Orca cannot recognize by name, started through a wrapper that does not exec it, reads as a shell, so its pasted prompt is reported not delivered (never typed). A recognized agent behind such a wrapper (a script, or the /bin/sh a tcsh or nu launch line runs) is found as a member of the terminal's foreground process group.
  • Intended, narrow: an agent that exits at startup holds the launch reply for the full 60 s readiness budget before it reports not delivered. A prompted agent.launch is a long poll, so the local socket stays open for that wait. The reason (agent_not_in_foreground or the readiness timeout) is in the host log only: the not-delivered receipt has no reason field, and adding one is a public-contract decision for the agent.launch plugin API, not this fix.
  • Intended: a Windows Gemini orchestration worker's brief is submitted at 4.66–4.69 s against main's 0.96–1.07 s (3 runs each). Main pastes 23–31 ms after the agent process starts, before Gemini has drawn its input box, so main's time depends on Gemini reading input that early; this PR waits for the agent to show it is ready.
  • Intended: a worker start whose agent exits at startup fails at dispatch_input with nothing typed. Before, its brief could land in the shell: the live re-check of this PR found a paired-server (federation) worker start type the 5 KB brief into the server's zsh, which ran it, 3 of 3 runs, the same as main. The brief now takes the check a launch prompt takes. On a Windows host, which cannot find the agent in front, the brief is still written unless a shell is found in front, as main does.
  • Intended: a Grok, DSH or mimo-code worker start answers on the agent's composer marker as well as on its title. Holding a bare-name title to quiet output made a Grok worker's brief arrive at 14.1–14.3 s against main's 2.2–2.8 s, because Grok animates its logo for ten seconds after drawing its composer. mimo-code's marker is OpenCode's by parity; no mimo-code startup has been recorded.
  • Temporary, narrow: the Enter reuses the paste's ownership read unless the shell shows a prompt mark, so a shell that writes no mark, whose agent exits in those 50 ms, is not caught. Main checked neither. Follow-up not filed yet.

Open, not yet explained live: on the M4Air at load 47-50, a tcsh launch whose agent ran behind /bin/sh (with the 1 s bound of 3054018) was refused 4 of 4 at 60 s. The 60 s means the fresh scan named a shell, which it did not do on the same process tree at load 10 here (3 of 3 found the agent). No ps -t sample of a failing run was taken. #23962 no longer routes plain tcsh and nu lines through /bin/sh (4 of 4 delivered at load 40-74), so this now affects only a user's own wrapper that does not exec its agent, under heavy load. The outcome is safe: not delivered, nothing typed. Follow-up not filed yet.

Open, not yet explained live: in the final QA, one fish crash run (1 of 3, under load) on an earlier head returned handed-to-terminal after 3.9 s. Fish executed nothing, but the screen read came back empty, so text left unexecuted on the command line cannot be ruled out. Not reproduced in 8 later runs, nor in 9 hook-logged runs on 4523a4b. Follow-up not filed yet.

Known issues, the same as main

  • A short one-line prompt in a Windows cmd pane gets stray ^ from the shared cmd quoting.

Linked Issue

N/A (internal agent-launch unification series).

Visual Proof

N/A: no window changes in this step. The live screenshots of this host path are on #23748 (step 7), which drives it from the desktop buttons.

Testing

  • I manually tested these changes. The host code was live-tested through feat(source-control): start the AI buttons' agents through agent.launch (step 2 of 2) #23748's buttons. The phone and raw agent.launch callers were tested on d268089 and re-run on e463aea: phone review notes and raw agent.launch now match main, and the phone's commit-failure Fix pastes as described above. Later commits merge main and move code without changing what this PR does; they were not re-run live.

  • Automated tests added/updated

  • At f9bbc16, the two findings of the live re-check after the main merge (paired-server crash brief, Grok worker latency). Both worker-start paths check the brief's write. New tests: federation-worker-brief-agent-in-front.test.ts and worker-brief-agent-in-front.test.ts (a crashed agent's shell types nothing; a live agent is written once; on Windows the brief is written unless a shell is found in front), plus launched-agent-worker-start-grok.test.ts, which replays Grok's recorded startup through the runtime: ready within a second of the composer glyph, where it was 14.0 s. With the check removed, the crash tests fail. Not traced: why the paired server's wait settles on the shell about 4.4 s after the crash while a local one times out. Replaying the same crash through the runtime's wait times out for every cached foreground name tried, so the cause is pane state the unit harness does not model. The write check refuses the brief either way. Gates: node, web and cli typechecks, full oxlint, the changed-code quality gate, 193 test files (3,465 tests) and the cross-version wire tests pass.

  • At 7d4aede, after merging main again (40f9e7a, which fixes main's own locale tests): no conflicts; the same gates pass.

  • At 0811b34, after merging main (e02adc5): main's Wait for OpenCode worker input before the first dispatch #24601 had added the same readiness module with its own lane table, which agents wait only for their composer marker, read from each agent's cited startup captures. That table is now the one source of truth: OpenCode and OpenCode2 became marker-only, and DSH and Grok, which this PR had made marker-only, take the composer signal and then tui-idle as main does. This PR keeps its dialog and "model: loading" hold, its proof that the agent is in front, and its launch-readiness title rule on top. Main had also moved the terminal and worktree startup builders into their own modules; the prompt-carry decision and its report are carried into both. Gates: node, web and cli typechecks, oxlint, the changed-code quality gate, the reliability-gates manifest and RPC params catalog pass; 122 named test files pass (2,845 tests; the one file pinning the old lane table was updated and re-run), as do 17 cross-version wire files and 4 mobile files.

  • At 6c16914: the mobile web bundle check failed at 124 assets against a ceiling of 123. Main already sat exactly on that ceiling (its sweep table said 69 scripts at 16 routes; the tree builds 73, the whole margin). Moving the agent.launch capabilities out of protocol-version keeps that module off the root layout and four other routes, which left main's new Qoder capability module with importers no other module shares, so it got a chunk of its own. The script sweep the ceiling is derived from was re-measured on this head (74 scripts), as the check documents, instead of raising the ceiling; the worst route now adds 10 scripts, which moves the phone's 256-asset crossing from 32 to 30 routes, the same as main re-measured on its own.

  • At 3054018: two follow-ups from the same QA. (1) c9b4dc0: a prompted agent.launch and agent.launchReplay are long polls, as orchestration.workerStart already is. The local runtime socket closes a connection idle for 30 s, so a launch whose agent exited (now a 60 s wait) answered runtime_unavailable instead of not delivered (M4Air bash 3.2 and every crash row at 74fa3f8). (2) 3054018, bounded by 29e38f6: the read also takes the host's process-group observation as positive proof when it names the launched agent among the terminal's foreground group, from a capture begun after the read was asked for (less the 500 ms a shared capture is reused across). The first version bounded the observation's age at 1 s, which counted the whole ps run and dropped good proof on a loaded Mac. The host still gives up on a capture after 1.2 s, so on a slower host the fresh read decides. A tcsh or nu launch line runs the agent from /bin/sh '<script>', which leads the group, and the fresh read named sh (M4Air tcsh: 2 of 4 not delivered, 2 pasted about 9 s late). The observation never proves a shell. Each fails with its change removed (the 1 s bound fails the slow-capture test). Gates: tc:node, oxlint, the changed-code quality gate against main; 93 test files including the readiness census: all pass (2,585 tests, 2 skipped).

  • At 4523a4b: final QA measured the foreground read at 3.4-5.0 s per read on the M4Air even at load 5-9, and worker starts with hooks off at 17.6-32 s against main's 9-12 s at load 25-84. Cause: the read ran the daemon's inspectProcess capture and then a fresh scan, each a whole-machine ps, and the fresh scan first waits for any capture already running. A local read now takes the pane's root pid from the provider's in-memory session inventory and runs one ps limited to that pane's terminal. Measured on this laptop at load 5 on the same login-wrapped zsh pane, both reads answering shell: old median 1,225 ms (889-1,354), new median 2.7 ms (2.3-7.7). A new test pins that the local read answers while every whole-machine read never settles; with the old read it fails, as do the two row-wiring tests. Gates: tc:node, tc:web, tc:cli, oxlint, the changed-code quality gate against main; 95 test files including the readiness census: all pass (2,589 tests, 2 skipped).

  • At 2603d2c (crash-guard fix, d96b1e7): round-4 QA of Stage long agent launch lines where they are typed, so they arrive whole (step 2 of 7) #23962 found that when the launched agent exited at startup, feat(agent-launch): keep long prompts off the launch line and paste them after readiness (step 1 of 7) #24257 pasted its prompt into the shell, which ran it (macOS zsh daemon pane, Windows Git Bash and WSL; main is clean because the prompt rides argv). Cause: the guard refused a write only when a shell was proven in front, and several reads never proved one. (1) macOS: a pane runs its shell under login, so the process-group fence's root is login, never zsh's group; measured on a real login-wrapped zsh, the fence reads root 60404 against foreground group 60406 with zsh at its prompt. The cached foreground name can also still name the exited process. (2) Windows Git Bash and WSL: the shell-alone-in-its-job check never answers. The rule is unchanged since d268089, the first commit, so neither later commits nor the refactor(runtime): read Codex, Claude, OpenCode, Pi, OMP and Gemini readiness from rule files #24375 merge introduced it. Fix: a write needs a fresh read that finds the agent in front, and Windows hosts carry the prompt on the launch line as main does. New tests: launched-agent-crash-guard.live-shell.test.ts (real zsh spawned under login as a macOS pane is, the daemon's own foreground tracker, a stub that crashes and one that stays up) and launched-agent-exit-delivery.test.ts (Windows Git Bash with claude and with grok, WSL, macOS zsh with a stale cached name, driven through the real readiness wait, foreground read and write guard). With the old rule put back, all five crash cases type the prompt and fail; the live-agent controls pass both ways. With the Windows line rule removed, its two tests fail. Gates: tc:node, tc:web, tc:cli, oxlint, the changed-code quality gate against main; 95 test files including every feat(agent-launch): keep long prompts off the launch line and paste them after readiness (step 1 of 7) #24257 test and the readiness census: all pass (2,556 tests, 2 skipped).

  • At 39419d2, after merging main again (24b97e8, which carries fix(build): import the Electron remote capability list from its own module #24494 and fix(native-chat): move the chat-tab surface out of the session host so main passes lint #24496, the fixes for main's own typecheck and lint failures): main's refactor(runtime): read Codex, Claude, OpenCode, Pi, OMP and Gemini readiness from rule files #24375 moved agent readiness checks into per-agent rule files, which needed three changes here. (1) Codex's provisional-header check was deleted from codex-terminal-readiness.ts and now lives in codex.json's provisional_startup hold anchor, so the launch readiness hold asks showsHoldAnchor, as main's own settled check does. Codex's is the only hold anchor in any rule file, so the result is the same. (2) A name-only title is now answered by each agent's rule file before the sustained-title lane runs, so gemini.json's name-title rule settled a launch wait on the shell's auto-title while Gemini was still booting. A launch now asks for a quiet stream on every weak idle verdict, as that lane did. tui-idle-launch-readiness-shell-title.test.ts failed without this and passes with it. (3) Main's readiness census needs a recorder for every runtime fixture, and this PR's zsh-prompt-runs-command recording now has one as a non-agent control. Gemini's synthetic census baseline is regenerated for this PR's stated change. Only the 24 title=name-only rows change: a bare gemini title now settles as weak rather than strong, and a fresh working or blocked status from Gemini itself is no longer overridden by that title. No other recording's verdict changed (every other regenerated baseline was compared as parsed JSON and was identical, so those were reverted). Gates: tc:node, tc:web, oxlint, the changed-code quality gate against main and the RPC params catalog pass. 93 named test files pass (2,542 tests, 2 skipped), including the full readiness census and every tui-idle, terminal-wait and rule-engine test.

  • At 2847488: removed the unshipped top-level viewMode request field and the trusted local caller id (aa5db2d). agent.launch will become a public plugin API, so a top-level viewMode would read as "choose terminal or chat", which the host decides. Nothing in this PR used either one (no desktop code calls agent.launch until step 7). Step 6 adds placement and view intent under a placement object. Then merged main (7577366). Its new structured-agent-session-admission.test.ts imported AGENT_LAUNCH_RUNTIME_CAPABILITY from protocol-version, which this PR moved, so that import now points at agent-launch-runtime-capability. Gates: tc:web, oxlint, the changed-code quality gate against main and the RPC params catalog pass. 73 named test files pass (2,091 tests, 2 skipped). tc:node fails only in four files that are identical to main, so main itself fails there: fix(native-chat): a paired server admits structured chat by client capability, not its own chat setting #24203 and feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) #24463 landed without seeing each other, leaving ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES imported from protocol-version (fix(build): import the Electron remote capability list from its own module #24494 fixes it), and main's structured-agent-session-held-signal.test.ts is missing the required logger.

  • At 8dc5516, after merging main again (bd90da7, which carries main's own lint fix refactor(native-chat): move the provider-exit proof out of structured-agent-session-adapter.ts so main's lint passes #24323): node, web, cli and mobile typechecks, the mobile tests-typecheck ratchet, oxlint and the changed-code quality gate pass. The same 35 test files (2,056 tests) pass, and the phone's recording, tasks and session suites (283 files, 4,315 tests) pass.

  • At c798d08, after merging main: the conflict in tui-idle-evidence.ts keeps main's screen-ruled readiness check (fix(runtime): read Antigravity, Cline and Prime Agent readiness from the live screen #24222) ahead of this PR's launch-readiness title rule. Main's protocol-version.ts sat at the 300-line cap, so the four agent.launch capabilities move to src/shared/agent-launch-runtime-capability.ts, and protocol-version.ts spreads their list into the advertised capabilities. The advertised capability lists were compared before and after: identical. Their importers, 14 files across desktop and phone, now import from the new module. A re-export via export * left the names undefined under the phone's recording loader and failed 37 recordings, so no re-export remains. The real-zsh test's zsh lookup became a function, because the Windows-lane registration scan read its multi-line ternary as a Windows-only gate. Gates: node and web typechecks, oxlint, the changed-code quality gate against main, and 33 test files (2,016 tests), including fix(runtime): read Antigravity, Cline and Prime Agent readiness from the live screen #24222's readiness tests and win32-test-lane-registration.test.mjs.

  • At e463aea (the zsh multi-line carry): node and web typechecks, oxlint and the changed-code quality gate against main pass. This PR's changed test files plus orca-runtime.test.ts pass with SHELL set to zsh and to bash: 25 files, 1,616 tests. The new rule's tests fail with the zsh carry or the per-line cap removed, and the remote-host test fails with the remote check removed. The real-zsh test fails on the early write when its lines are 1,100 bytes.

  • Gates at d268089: node, web, cli and mobile typechecks, the mobile tests-typecheck ratchet, full oxlint, the changed-code quality gate against its base, the RPC params catalog and the max-lines, ts-nocheck and reliability ratchets.

  • 49 named test files (2,145 tests) and the two mobile test files (35 tests) pass. A separate review pass re-ran the 22 test files this PR changes (263 tests): all pass. Each new rule has a test that fails without it, using captured zsh, Claude and Codex transcripts, real ps rows and the desktop's paste frame.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • SSH: prompt text reaches the host only as terminal input, never in a remote command line, environment variable or file. Mixed versions: a new capability only, which an older host ignores. No request field is added. Folder workspaces work as worktrees do.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

The host's agent.launch typed any launch prompt into the shell as part of the
launch command. A long or multi-line prompt then ran line by line in the
shell, and an agent that never showed readiness or crashed at startup had
nothing guarding where its text went.

agent.launch now carries a prompt on the typed line only when the line stays
one line, control-free and at most 512 bytes; otherwise the agent starts
clean and the host pastes the prompt once the agent's own ready signal fires
(bracketed paste plus its composer marker or a quiet render, read only after
the shell's last hand-off, never while the pane's own shell is proven in
front), with main's draft-paste bytes and an Enter 50 ms later. Orchestration
worker starts wait on tui-idle as before. A replay-safe launch admits and
claims its ledger row in one write, Qwen Code gets a second Enter, the
desktop and phone share one launch-refusal classifier, and hosts advertise
agent.launch.prompt-carry.v1.

Split out of #23748, which moves the desktop source-control buttons onto
this path.
brennanb2025 added a commit that referenced this pull request Oct 1, 2026
The desktop's source-control AI buttons (Fix checks, Resolve conflicts,
commit/push-failure recovery, Explain commit) opened a tab themselves and
pasted their generated prompt. They now ask the host's agent.launch, which
step 1 (#24257) taught to keep a long prompt off the launch line and paste it
after readiness. The buttons record where their tab goes under every id the
tab can arrive as, so the host's terminal reveal and the session-tabs mirror
put the terminal or chat in the split the button was used from; an empty
target group is kept until its tab arrives; a lost reply is replayed under
the same operation id; and a prompt the host could not deliver shows a
notice with Copy prompt.

Rebuilt on step 1; the combined tree is identical to f564d7a
(backup/23748-pre-split).
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status (one pass after the split, 2026-09-30)

No blockers. This PR works on its own for its current callers: RPC agent.launch (the phone's AI buttons and review notes), orchestration worker start, and the desktop draft paste. Nothing in step 2 is needed for them. The 22 test files this PR changes pass (263 tests). No code changes; the body now covers:

  • the Gemini bare-title change, which affects titles app-wide
  • the phone no longer receiving the reveal warning
  • the ZCode/DSH/Grok wait, with its 60 s budget
  • Qwen's second Enter on every platform
  • the step-2-only additions (trusted local caller id, explain_commit)
  • that orca worktree create --prompt and automations still fold the prompt at any size
  • "follow-up not filed yet" on the two Temporary items

Open, not fixed (design calls):

  1. Worker start now passes launchReadiness to tui-idle, so agents that show only their name (Copilot, Aider, Mimo, agy, OpenCode) also need output to go quiet. If one keeps repainting at rest, a worker start that settled on main now times out at 60 s. There are no captured transcripts for these agents yet.
  2. agent.launch with create-worktree and a setup-gated repo starts the 60 s readiness clock before setup finishes, so a long prompt can be reported not delivered when setup runs past 60 s. No current caller does this; the structured-chat path already waits for setup.

@brennanb2025

brennanb2025 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Re-run at e463aea, M4Air (zsh), 2026-10-01

What changed and why it was re-run. At d268089 the PR moved every multi-line or over-512-byte prompt off the typed launch line and pasted it after Claude was ready. The phone's AI buttons and review notes, whose multi-line prompts main types whole into zsh, then reached Claude 0.5–3 s later. e463aea changes the rule for a local zsh host: a multi-line prompt now rides the launch line as on main (with an immediate RPC reply) when every line is at most 512 bytes and the whole line at most 8 KB. Everything else keeps the paste.

Short answer.

  • Review notes from the phone and the raw agent.launch call now behave exactly like main: the prompt rides the launch line, the RPC replies at once, and timing is the same.
  • The phone's commit-failure Fix still pastes, so its gap remains. Its prompt carries the hook's failure output as one JSON-string line (2,163 bytes in this fixture), over the 512-byte per-line cap. Real failure output will usually exceed it too. This round's load (9–58) was too noisy to measure the gap; round 1 measured it at 0.5–1.5 s at equal load.
  • Every prompt was delivered once, and no prompt line ever ran as its own shell command.

Setup.

  • Builds:
    • Heads re-checked with gh pr view before building.
    • PR e463aea99c8057aa607cf1f1c4e2e9e26d3eecc6 against main 1b0ee969bccd6a58f8412dc1b425079c47810e2a: the same main, same rigs, seed, fixture, Claude 2.1.286 shim and start state as before.
    • Build proof: devBranch e463aea99c; the new zsh multi-line rule (…shellName===zsh&&…split(…, nameLocalTypedLineShell) is in the PR's built main bundle and absent on main and on d268089.
  • How the host names the shell: every rig showed SHELL=/bin/zsh in Electron's env and no terminalDefaultShell in the seed or profile, so the host named zsh from SHELL.
  • Load: the arms were interleaved, and the phone and desktop rigs ran at the same time (1-min load 9–58).

Phone (Android emulator, orca emulator only, against a desktop host on each arm), 2 runs each

Row Prompt main: what zsh received / tap→submitted PR e463: what zsh received / tap→submitted Verdict
Review notes → new Claude session 11 lines, 381 B, longest line 115 B whole prompt as Claude's quoted argument / 3.61, 1.73 s (load 30–31) the same launch line, no paste / 2.01, 4.06 s (load 33) PASS, now matches main (same mechanism; timing brackets main at the same load)
Commit failure → Fix 21 lines, 3,502 B, longest line 2,163 B whole prompt as Claude's quoted argument / 3.28, 3.16 s (load 15–34) claude '--dangerously-skip-permissions' only, prompt pasted after readiness / 2.60, 2.79 s (load 9–13) delivery PASS; still the paste path (one line > 512 B); timing not comparable at these loads
  • Every run: one submitted user message, normSha the same as round 1 (notes 97a7f3edd2dd, Fix fcbd0eba9ede). The phone showed "Agent started in main" after every Fix and landed on the new Claude tab after every notes send.
  • One main Fix run is excluded: the phone was still running the PR's phone JS, and was re-run. One PR notes attempt is voided: a scripted tap landed on the New Tab sheet, nothing started, and it was re-run. Both are recorded in lane C's notes.

Raw agent.launch over the runtime socket (the CLI's RPC client), same 2,175 B / 14-line prompt (longest line 160 B), 3 runs each, interleaved

Measure main PR e463
What zsh received 2,217 B, 14-line launch line (sha 6a42805ab46d) identical
Call → RPC reply 172–451 ms 162–424 ms
Call → submitted 773–1,060 ms 676–1,048 ms
Launch line → submitted 472–507 ms 469–533 ms
Count 1 1
  • Fallback still works: a single 962-byte line on the PR stayed off the launch line (zsh ran only the 39-byte claude line) and was pasted once 1.87 s after Claude was ready, with Enter 49 ms after the paste. The RPC replied at 2.72 s, after the paste.
  • Verdict: PASS, identical to main for a short-lined prompt.

Screenshots (PR e463)

Phone: review notes sent to a new Claude session, now on the launch line. The phone lands on the agent tab with the 3 notes and "PONG":
e463 phone notes

The same agent tab on the desktop host: the notes prompt once, "PONG":
e463 desktop notes

Phone commit-failure Fix on the desktop host. The prompt is pasted once; the "Failure output JSON string" is the single 2,163-byte line that keeps this prompt on the paste path:
e463 Fix pasted

Raw agent.launch fallback: a 962-byte single line. The shell ran only claude '--dangerously-skip-permissions', then the prompt was pasted once and Claude answered PONG:
e463 fallback

Safety.

  • Real ~/.claude/settings.json, ~/.codex/hooks.json and ~/.codex/config.toml were unchanged on the M4Air and the laptop.
  • The phone was paired by typing the bare code through orca emulator, 40 characters at a time with a read-back after each chunk. The code was never printed or captured.
  • Every rig and the emulator were stopped by recorded pid.
  • On one PR Fix run Claude began a read-only git status before Esc; the fixture was unchanged.

Live QA of the callers that use agent.launch without the desktop buttons, main vs PR

What was tested, and why. On main, the host's agent.launch types a launch prompt into the shell as part of the command that starts the agent, at any size and with its line breaks. This PR keeps a long or multi-line prompt off that line. The agent starts clean, and the host pastes the prompt once the agent shows it is ready. Until now this host code had been run live only through #23748's desktop buttons. This round runs the other callers on their own, against main 1b0ee969bcc: a CLI/RPC launch, orchestration worker start, the phone's AI buttons and review notes, and Qwen Code on Windows.

Short answer.

  • Delivery passes everywhere it ran. Every prompt arrived exactly once and was submitted. On the PR's agent.launch path nothing was ever typed into a shell (M4Air zsh, Windows PowerShell).
  • Better than main: on Windows, the host launching Qwen Code now submits the prompt (3 of 3). Main lost it every time (0 of 3), once while reporting it delivered.
  • Worse than main, on timing only: the phone's buttons get their prompt to Claude about 0.5–3 s later. This is the paste-after-ready design the body marks Temporary.
  • Worker start matches main: on a quiet machine the brief is submitted at a median 8.49 s on the PR vs 8.53 s on main. An earlier 0.5 s gap at load 16–80 was load, not the PR.
  • Two PR-body claims don't match what happens: the CLI row and the new-workspace dialog row. Details are below.
  • Blocked: the SSH run, because the laptop's key couldn't be unlocked.

Worse than main (read first)

  1. Phone AI buttons: the prompt reaches Claude later (M4Air host, Android emulator, Claude 2.1.286, 2 runs per action per arm).
    • Commit-failure Fix (21-line prompt): tap→submitted 2.67 / 2.59 s on the PR vs 1.13 / 2.08 s on main.
    • Review notes → new Claude session (3 notes, 11 lines): 3.98 / 3.74 s vs 0.94 / 1.21 s.
    • The phone's "Agent started in main" notice also comes later on the PR (about 6.1 s vs 2.85–4.45 s), because the host now answers only after the paste.
    • Cause: main puts the prompt on Claude's command line, so Claude starts with it. The PR starts Claude bare and pastes after readiness. The PR runs also ran at higher load (9.0–13.1 vs 4.8–11.4), which explains part of the gap but not all of it.
    • This is the body's Temporary deviation, which Carry an agent's first prompt on its command line instead of pasting it (step 3 of 7) #24080 removes for Claude and Codex.
  2. Raw agent.launch from the CLI's RPC client: the prompt lands later after Claude is ready, but the total is not worse.
    • Ready → submitted: PR 1.80 s vs main 0.68 s (median).
    • Launch line → submitted: 2.54 vs 2.66 s.
    • Call → submitted: 3.31 vs 3.86 s.
    • Claude takes longer to draw its ready title when given a 2 KB argv prompt, which offsets the later paste.
    • The RPC call now returns after delivery, 2.8–3.5 s after the call; main returns right after the spawn.

Where the PR body doesn't match what happens

  • The CLI row. The table says "CLI and RPC agent.launch with a prompt … After: rides the line only if it stays one line … otherwise pasted after readiness".
    • The CLI's only prompt command, orca worktree create --agent <agent> --prompt …, never calls agent.launch. It calls worktree.create (src/cli/handlers/worktree.ts:221), whose host path builds the startup without onPromptCarry, and so keeps main's fold ("Absent keeps the CLI's fold", runtime-worktree-agent-startup.ts).
    • Live on the M4Air, zsh received the identical 2217-byte, 14-line launch line on both arms.
    • Not worse than main, but the CLI caller still types a multi-line prompt into the shell, which is exactly what the PR exists to stop. Either route the CLI's --prompt through the same carry/paste rule, or take the CLI out of that row.
  • The new-workspace dialog row. The table says "Desktop draft paste (new-workspace dialog …) … Qwen Code now also gets a second Enter here".
    • The dialog has no prompt box on either arm (initialPrompt: ''). The only text it hands an agent is a linked work item's draft, which pasteDraftToAgentPtyWhenReady pastes with no Enter on purpose.
    • Measured on Windows: pasted once, left unsubmitted, the same on both arms, with no stray Enter on the PR.
    • The second Enter does reach the source-control buttons' paste (agent-paste-draft.ts) and the host write (orca-runtime-write-terminal-agent-prompt.ts).

Results by caller (main vs PR)

# Caller Machine main PR Verdict
1a CLI worktree create --agent claude --prompt (2.2 KB, 14 lines), 2 runs each M4Air zsh ran the whole prompt as the launch line; delivered once identical: same 2217 B launch line; delivered once same as main; PR body's CLI claim wrong
1b Raw agent.launch over the runtime socket, long prompt, 5 runs each M4Air prompt on the launch line (2217 B into zsh); delivered once zsh ran only claude '--dangerously-skip-permissions' (39 B); one 2175 B bracketed paste; Enter 35–47 ms after it; delivered once PASS; timing see item 2
1b′ Raw agent.launch, short one-line prompt (<512 B) M4Air on the launch line on the launch line, same as main PASS
1 SSH 1a and 1b over SSH to openclaw laptop → openclaw – – BLOCKED (below)
2 Orchestration worker start, Claude, 5 quiet pairs (1-min load 3.9–7.4, fresh rigs, alternating order) M4Air brief submitted 8.40–8.58 s, median 8.53; tab 207–251 ms; ✳ 0.38–0.56 s 8.42–8.67 s, median 8.49; tab 202–297 ms; ✳ 0.40–0.63 s; per pair PR − main −70 / +258 / −108 / +2 / +16 ms PASS, same as main (one message, only the launch line in zsh; paste 2–8 ms after ✳, Enter 8.0 s later on both)
2′ The same, 9 earlier pairs at 1-min load 16–80 M4Air median 8.89 s median 9.44 s; later in 8 of 9 load/rig-state noise: the quiet re-run shows no gap, and the PR changes nothing that runs between worker-start and the worker's spawn (its change is the wait after it)
3a Phone: commit failure → Fix (21 lines), 2 each M4Air + Android emulator whole prompt typed into zsh as one quoted argument; submitted once at 1.13 / 2.08 s zsh ran only the launch line; pasted once; submitted at 2.67 / 2.59 s delivery PASS; timing worse (item 1)
3b Phone: session diff → Send review notes to AI → Claude, new session (11 lines), 2 each M4Air + Android emulator typed into zsh as one quoted argument; submitted at 1.21 / 0.94 s; phone lands on the agent tab launch line only; pasted once; submitted at 3.98 / 3.74 s; phone lands on the agent tab delivery PASS; timing worse (item 1)
3c Phone: review-screen notes, Fix checks, Resolve conflicts – – – not exercised: the review screen needs a long-press, which orca emulator can't send on Android; the other two need a hosted GitHub PR
4 Windows: new-workspace dialog, Qwen Code 0.24.7, linked issue + 3-line note, 2 each Windows high spec draft pasted once, unsubmitted by design the same; no stray Enter PASS, same (the second Enter can't apply here)
4b Windows: Explain changes with Qwen, 2 each Windows high spec prompt never pasted, no notice the same FAIL on both, not worse: the pane's shell spawns ≈12.1 s after the click, past the renderer's 8 s spawn budget. #23748 moves this button onto the host path
4c Windows: host agent.launch with Qwen, 3-line prompt, 3 each Windows high spec 0 of 3 delivered: run 1 reported handed-to-terminal but the prompt is nowhere; runs 2–3 not-delivered after 60 s submitted once, 3 of 3 (4.6 / 9.3 / 4.6 s), about 1.3 s after the paste, which fits the new +1.2 s second Enter; no empty extra message PASS, better than main

Every delivered run: exactly one user message carrying the QA marker, text byte-identical across arms, never left unsubmitted. Never typed on a shell command line on the PR's agent.launch path. Enter bytes were tapped on the M4Air (1b, 2). On Windows the second Enter is inferred from timing, because the host writes it and the renderer input could not be tapped.

Blocked

  • SSH (laptop rig → openclaw): no runs. At 19:05 one main-arm connect worked and deployed its relay. From about 19:15 every ssh openclaw from the laptop failed. ssh -v shows openclaw answering "Server accepts key" for ~/.ssh/id_ed25519, followed by "Permission denied (publickey)". The laptop can't sign: the agent holds no identity, and the key's passphrase lives only in the Keychain, which isn't reachable non-interactively. Loading the key into the agent is the machine owner's call, so it was not done. Both rigs, proven builds and scripts are kept ready for a resume.
  • Keychain prompt incident (fixed). The SSH rig reused an earlier launch script whose --use-mock-keychain flag (passed through ELECTRON_CLI_ARGS) the dev runner never applies. The first main-arm connect therefore raised a real macOS Keychain prompt on the laptop desktop at 19:05. The rig was stopped (SIGKILL; its main thread was blocked in the Keychain call) and the prompt was never touched. The rig now execs Electron through a shim with --use-mock-keychain --password-store=basic, both checked in ps before any connect.

Builds and safety

  • Arms (copied from git rev-parse; PR head re-checked with gh pr view before each build): PR d2680896f72d6b50953430a2b02fbbf33fc9ec08, main 1b0ee969bccd6a58f8412dc1b425079c47810e2a.
  • Caveat: main has 8 commits the PR's base (b5948985331) lacks, among them fix(agent-status): preserve hook presence when process checks cannot answer (step 1 of 3) #23947 (agent-status process checks), feat(native-chat): the chat strip and the sidebar read the host's child records #22614, fix(worktrees): let git delete removed checkouts so chat sends never wait behind them #23837 and fix(worktree): update local main safely, once per branch, alongside the checkout #23698. So every main-vs-PR difference here covers more than the PR's own diff. None of these verdicts depends on it.
  • Build proof on every launch:
    • devBranch d2680896f7 / 1b0ee969bc.
    • agent.launch.prompt-carry.v1 and the PR-only agent_not_in_foreground (launched-agent-write-guard.ts) appear only in the PR's main bundle.
    • Qwen's submitRetryDelayMs:1200 appears only in the PR's bundle and page.
    • The phone bundle references the new agent-launch-replay-refusal module only on the PR (3 hits vs 0).
  • Rigs: every rig had an isolated HOME, CLAUDE_CONFIG_DIR, CODEX_HOME, XDG_CONFIG_HOME and profile, was background-launched with ORCA_BACKGROUND_LAUNCH=1 from an env -i allowlist, and had 0 token variables (ps eww).
  • Driving:
    • Electron rigs: Playwright CDP only.
    • Phone: orca emulator only. Pairing used "Paste code instead" with the bare code typed in 40-character chunks and read back through orca emulator ax; the code was never printed. No adb input, no deep link.
  • Real config: the real ~/.claude/settings.json, ~/.codex/hooks.json and ~/.codex/config.toml were identical before, at each rig start and after, on the M4Air, the laptop and the Windows box.
  • Cleanup: every rig process was stopped by its recorded pid, and the emulator was shut down.
  • Agent behaviour worth knowing:
    • Orca launches Claude with --dangerously-skip-permissions and Qwen with --approval-mode yolo, on both arms.
    • Claude ran read-only git status / node --check in the throwaway fixture in a few commit-failure runs before Esc; nothing was edited.
    • Qwen pointed at a refusing fake endpoint.

Screenshots (full window, right sidebar collapsed; phone frames from the emulator)

1b, PR: raw agent.launch. The 14-line prompt is Claude's first message, submitted once ("PONG"):
1b PR

1a, PR: the CLI's worktree create --prompt. The same delivery, but zsh received the whole prompt as the launch line on both arms (the zsh log is the evidence; the TUI covers the line):
1a PR

2, PR: the worker brief submitted once; Claude answered READY:
2 PR

3a, PR, phone: commit failed → Fix → "Agent started in main":
3a PR phone

3b, PR, phone: review notes sent to a new Claude session. The phone lands on the agent tab, which shows the 3 notes as one prompt and "PONG":
3b PR phone

3b, PR, desktop host: the same agent tab, notes pasted once (phone-width because the phone holds the terminal size):
3b PR desktop

4c, PR, Windows: host agent.launch with Qwen Code. The 3-line prompt was sent once (then the fake endpoint's error) and the input box is empty:
4c PR

4c, main, Windows: the run that reported handed-to-terminal. Qwen's input box is empty and nothing was sent:
4c main

…h launch line, as main did

#24257 moved every multi-line or over-512-byte prompt off the typed launch line and pasted it after readiness. The phone's AI buttons and review notes, whose multi-line prompts main typed whole into zsh, then reached Claude 0.5-3 s later and their RPC reply waited for the paste.

The host now names the shell a local macOS or Linux line is typed into, the way the spawn picks it, and a multi-line prompt rides a zsh line when every line is at most 512 bytes and the whole line at most 8 KB. A real-zsh test types such a line through Orca's own ready barrier and startup write, including when a slow user config makes the write land early. Elsewhere the measured unsafe cases keep the paste: bash 3.2 runs multi-line lines piecemeal, fish drops an early multi-line write, and any shell loses a line over 1 KB written early.
…unch-host-prompt-delivery

# Conflicts:
#	src/main/runtime/tui-idle-evidence.ts
…dows lane's gate scan

The Windows lane registration check read `const ZSH_PATH = process.platform === 'win32'` (the head of a multi-line ternary) as a Windows-true flag, so `describe.skipIf(!ZSH_PATH)` looked like a Windows-only suite. The file is POSIX-only; the zsh lookup is now a function.
… module

Main's protocol-version.ts sits at the 300-line cap, so the prompt-carry capability pushed it over. The four agent.launch capabilities and their doc move to agent-launch-runtime-capability.ts, re-exported by name and spread into RUNTIME_CAPABILITIES at the same position; the advertised lists and every export are unchanged.
…wn module

`export *` from protocol-version left the four names undefined under the mobile recording loader, which resolves a relative import through a Proxy with no own keys, so 37 phone recordings lost agent.launchReplay. Importers now name agent-launch-runtime-capability directly; protocol-version only spreads its list.
@brennanb2025
brennanb2025 marked this pull request as ready for review October 1, 2026 10:22
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

[High risk] Restructures agent launch prompt delivery and readiness logic.

The PR does not appear safe to merge while local desktop replay-safe launches fail and a delayed Enter can reach a returned shell.

Findings

  1. P1 Delayed Enter can reach shell ▶

Summary

The PR keeps prompts on a measured-safe launch line and otherwise delivers them after terminal-agent readiness, while consolidating launch replay and readiness behavior.

  • The subsequent removal of the trusted-local replay identity prevents local desktop replay-safe launches.
  • Removing the view preference also conflicts with the source-control caller in the next stacked PR.
  • The earlier delayed-Enter concern remains outstanding.

Reviews (2) · Last reviewed commit: "fix(agent-launch): hold rule-file name t..."

Comment on lines +29 to +31
const beforeWrite = async (ptyId: string): Promise<void> => {
if (cleared?.ptyId === ptyId && !cleared.shellMayHaveReturned) {
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Delayed Enter can reach shell

If the shell returns to its prompt without emitting a bracketed-paste or shell-integration marker, this guard reuses its earlier “no shell” result. Codex can exit after receiving the paste but before its delayed second Enter. If the user has started typing at the returned shell prompt, that Enter submits the pending shell command instead of going to Codex. Check foreground ownership again before the delayed Enter.

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d15f17e9-6f6c-4005-9e34-eda078103b91
📥 Commits

Reviewing files that changed from the base of the PR and between 0811b34 and 6c16914.

📒 Files selected for processing (2)
  • config/scripts/build-mobile-web-app-bundle.test.mjs
  • config/scripts/verify-mobile-web-app-bundle.mjs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

Startup planning now checks whether a prompt fits the launch command and reports whether it was carried. Launch surfaces publish state before prompt delivery. Fresh-launch delivery uses readiness and foreground checks, shared bracketed-paste handling, and configured submit retries. Replay handling uses shared refusal classification and combines operation admission with claiming in one transaction. The launch-source schema accepts explain_commit.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 6c169

Inspected launch paths either report prompt carry or preserve prompts through managed-worktree planning; no actionable merge-blocking risk remains.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 6c169

More launch text now reaches terminals after startup. The foreground check can permit a write even when shell-return output arrives during that check, leaving a security-sensitive handoff unresolved. Identity checks and durable replay protections limit exposure but do not close this gap.

Retained concerns

  • Medium · security · inferred: The new foreground guard permits the current write when shell-return output arrives during its asynchronous foreground read, provided that read returns an agent result. It checks the recorded invalidation only on a subsequent call. Because additional prompts now use PTY delivery, launch text can cross into a shell after an observed handoff; a combined paste-and-submit write can also submit it. Generation and permission checks do not inspect this guard invalidation. Actual exploitation and the complete process-exit fencing behavior remain unverified.
Security review details

Security Blast Radius

  • inferred — The relevant sink is input to the selected local or SSH terminal, not a newly demonstrated unauthenticated endpoint. If attacker-influenced launch text reaches a shell, its effective authority is that terminal user's execution environment, potentially including that user's files and credentials. Broader tenant, fleet, or privileged-service exposure was not established.

Security Findings and Attack Paths

  • inferred — The retained architecture concern is an authority-handoff gap, not a verified exploit. A foreground read can return agent after the guard has observed shell-return output; the first write remains permitted. The test confirms that invalidation protects only the following call. Newly deferred launch text can therefore encounter a shell on the current write, subject to the remaining lifecycle checks.

Trust Boundaries and Controls

  • observed — Remote replay identity uses the paired-device subject rather than a rotating bearer credential and refuses missing paired identity. Recorded results and intent fingerprints govern replay. These controls constrain replay ownership; they do not establish which process consumes a later PTY write.
  • observed — The terminal writer checks cancellation, PTY generation, and permission state around writes. The foreground guard rejects initial shell or unknown reads, watches for shell-return markers, and disposes its subscription. These are meaningful countercontrols, but the observed marker-during-read case still permits the current write.

Resilience and Maintainability Implications

  • observed — An unobserved turn start is treated differently from proven non-delivery: stalled submission returns terminal handoff rather than inviting a duplicate resend. Other delivery errors return non-delivery. Complete classification of failures after partial writes remains unresolved, so this review does not establish that every non-delivery receipt means no bytes reached the terminal.

Hardening Proposals

  • proposed — Invalidate the current foreground grant when shell-return output arrives during its read, rather than deferring the recheck to the next write. For later writes, use an explicit process-ownership fence or renewed foreground proof instead of relying solely on optional terminal escape markers.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 95 functions across 81 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed No active directly linked issue remains. Issue #24323 is closed and completed, so it provides historical context only. No linked-issue coding requirements apply.
Out of Scope Changes check ✅ Passed The changes support agent.launch prompt delivery, readiness, replay handling, capability advertisement, and related tests. The mobile web bundle sweep remeasurement follows the capability-module move …
Title check ✅ Passed The title clearly describes the main change: keeping long prompts off the launch line and pasting them after readiness. The step number adds context but does not obscure the change.
Description check ✅ Passed The description is detailed and covers the required sections, including the user impact, implementation, rationale, linked-issue status, visual-proof rationale, testing, and cross-platform considerati…
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: b4e34bac-eed4-4c28-a630-f0495b116638

📥 Commits

Reviewing files that changed from the base of the PR and between bd90da7 and 8dc5516.

📒 Files selected for processing (89)
  • mobile/src/session/mobile-existing-agent-launch.ts
  • mobile/src/tasks/agent-launch-mobile-replay.test.ts
  • mobile/src/tasks/agent-launch-request.ts
  • mobile/src/tasks/mobile-agent-launch-architecture.test.ts
  • mobile/src/tasks/worktree-create-capability.test.ts
  • mobile/src/transport/mobile-runtime-client-capabilities.test.ts
  • mobile/src/transport/mobile-runtime-client-capabilities.ts
  • src/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txt
  • src/main/agent-launch/agent-launch-executor.test.ts
  • src/main/agent-launch/agent-launch-executor.ts
  • src/main/agent-launch/agent-launch-prompt-delivery.ts
  • src/main/agent-launch/agent-launch-surface-factories.ts
  • src/main/agent-launch/host-agent-startup-attribution.test.ts
  • src/main/ipc/desktop-renderer-runtime-capabilities.test.ts
  • src/main/ipc/desktop-renderer-runtime-capabilities.ts
  • src/main/ipc/runtime-agent-launch-capability.test.ts
  • src/main/ipc/runtime.ts
  • src/main/providers/startup-line-typed-length.live-shell.test.ts
  • src/main/runtime/__fixtures__/zsh-prompt-runs-command.meta.json
  • src/main/runtime/__fixtures__/zsh-prompt-runs-command.txt
  • src/main/runtime/agent-launch-typed-line-shell.test.ts
  • src/main/runtime/agent-launch-typed-line-shell.ts
  • src/main/runtime/agent-session-operation-admission.ts
  • src/main/runtime/agent-session-record-store.ts
  • src/main/runtime/agent-terminal-startup-prompt.test.ts
  • src/main/runtime/agent-transcript-pane-test-harness.ts
  • src/main/runtime/launched-agent-composer-readiness-claude.test.ts
  • src/main/runtime/launched-agent-composer-readiness-codex.test.ts
  • src/main/runtime/launched-agent-composer-readiness.test.ts
  • src/main/runtime/launched-agent-composer-readiness.ts
  • src/main/runtime/launched-agent-shell-foreground.ts
  • src/main/runtime/launched-agent-shell-handoff-readiness.test.ts
  • src/main/runtime/launched-agent-write-guard.test.ts
  • src/main/runtime/launched-agent-write-guard.ts
  • src/main/runtime/orca-runtime-activate-managed-worktree.ts
  • src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts
  • src/main/runtime/orca-runtime-create-agent-prompt-render-gate.ts
  • src/main/runtime/orca-runtime-create-managed-worktree.ts
  • src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts
  • src/main/runtime/orca-runtime-tests/agent-launch-prompt-submit.spec.ts
  • src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts
  • src/main/runtime/orca-runtime.test.ts
  • src/main/runtime/rpc/core.ts
  • src/main/runtime/rpc/dispatcher-stream-options.ts
  • src/main/runtime/rpc/dispatcher-trusted-local-caller.test.ts
  • src/main/runtime/rpc/dispatcher.ts
  • src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts
  • src/main/runtime/rpc/methods/agent-launch-caller-selection.ts
  • src/main/runtime/rpc/methods/agent-launch-prompt-carry.test.ts
  • src/main/runtime/rpc/methods/agent-launch-replay.test.ts
  • src/main/runtime/rpc/methods/agent-launch-replay.ts
  • src/main/runtime/rpc/methods/agent-launch-surfaces.ts
  • src/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.ts
  • src/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.ts
  • src/main/runtime/rpc/methods/agent-launch-terminal-prompt.ts
  • src/main/runtime/rpc/methods/agent-launch-worktree-creation.ts
  • src/main/runtime/rpc/methods/agent-launch.test-fixture.ts
  • src/main/runtime/rpc/methods/agent-launch.test.ts
  • src/main/runtime/rpc/methods/agent-launch.ts
  • src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts
  • src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts
  • src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts
  • src/main/runtime/rpc/rpc-streaming-dispatcher.ts
  • src/main/runtime/runtime-managed-worktree-create-types.ts
  • src/main/runtime/runtime-store-contract.ts
  • src/main/runtime/runtime-terminal-contracts.ts
  • src/main/runtime/runtime-terminal-idle-polls.ts
  • src/main/runtime/runtime-terminal-wait.ts
  • src/main/runtime/runtime-worktree-agent-startup.test.ts
  • src/main/runtime/runtime-worktree-agent-startup.ts
  • src/main/runtime/runtime-worktree-startup-readiness.ts
  • src/main/runtime/tui-idle-evidence.ts
  • src/main/runtime/tui-idle-launch-readiness-shell-title.test.ts
  • src/main/runtime/zcode-readiness-transcript.test.ts
  • src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts
  • src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts
  • src/renderer/src/lib/agent-paste-draft.ts
  • src/shared/agent-launch-intent.ts
  • src/shared/agent-launch-replay-refusal.ts
  • src/shared/agent-launch-runtime-capability.ts
  • src/shared/agent-prompt-injection.ts
  • src/shared/agent-title-status.ts
  • src/shared/protocol-version.ts
  • src/shared/rpc-contract/agent-launch-params.ts
  • src/shared/startup-line-prompt-carry.test.ts
  • src/shared/startup-line-prompt-carry.ts
  • src/shared/telemetry-property-schemas.ts
  • src/shared/terminal-bracketed-paste-text.ts
  • src/shared/tui-agent-config.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.

Comment on lines +29 to +56
const beforeWrite = async (ptyId: string): Promise<void> => {
if (cleared?.ptyId === ptyId && !cleared.shellMayHaveReturned) {
return
}
dispose()
let carry = ''
const watch = { ptyId, shellMayHaveReturned: false, unsubscribe: (): void => {} }
// Subscribed before the read, so a shell that returns while it runs is not missed.
watch.unsubscribe = runtime.subscribeToTerminalData(ptyId, (data) => {
const window = carry + data
carry = window.slice(-MARKER_CARRY_CHARS)
if (SHELL_RETURN_MARKERS.some((marker) => window.includes(marker))) {
watch.shellMayHaveReturned = true
}
})
let shellInFront: boolean
try {
shellInFront = await runtime.isLaunchShellInFront(ptyId, agent)
} catch (error) {
watch.unsubscribe()
throw error
}
if (shellInFront) {
watch.unsubscribe()
throw new Error('agent_not_in_foreground')
}
cleared = watch
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

cat src/main/runtime/launched-agent-write-guard.ts
sed -n '45,70p' src/main/runtime/launched-agent-write-guard.test.ts
sed -n '27,145p' src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts
cat src/main/runtime/launched-agent-shell-foreground.ts

Repository: stablyai/orca

Length of output: 12471


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- guard tests ---'
cat -n src/main/runtime/launched-agent-write-guard.test.ts | sed -n '1,240p'
printf '%s\n' '--- guard usages and prompt writer definitions ---'
rg -n -C 5 'beforeWrite|createLaunchedAgentWriteGuard|writeTerminalAgentPrompt' src/main/runtime src/main | head -n 500
printf '%s\n' '--- foreground check usages/implementation ---'
rg -n -C 6 'isLaunchShellInFront|isShellInFrontOfLaunchedAgent' src/main/runtime src/main | head -n 500
printf '%s\n' '--- focused PR diff ---'
git diff --no-ext-diff --unified=35 bd90da7a5bc29487ae48f96617406e39fa27668e 8dc55168365ff31b802064fe4f3d0995a8797439 -- src/main/runtime/launched-agent-write-guard.ts src/main/runtime/launched-agent-write-guard.test.ts src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts

Repository: stablyai/orca

Length of output: 41915


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- guard tests ---'
cat -n src/main/runtime/launched-agent-write-guard.test.ts | sed -n '1,240p'
printf '%s\n' '--- guard usages and prompt-writer definitions ---'
rg -n -C 5 'beforeWrite|createLaunchedAgentWriteGuard|writeTerminalAgentPrompt' src/main/runtime src/main | head -n 500
printf '%s\n' '--- foreground check usages/implementation ---'
rg -n -C 6 'isLaunchShellInFront|isShellInFrontOfLaunchedAgent' src/main/runtime src/main | head -n 500
printf '%s\n' '--- focused PR diff ---'
git diff --no-ext-diff --unified=35 bd90da7a5bc29487ae48f96617406e39fa27668e 8dc55168365ff31b802064fe4f3d0995a8797439 -- src/main/runtime/launched-agent-write-guard.ts src/main/runtime/launched-agent-write-guard.test.ts src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts

Repository: stablyai/orca

Length of output: 41833


Recheck invalidated evidence before allowing the current write.

beforeWrite can resolve after the terminal reports a shell return during its first asynchronous check. The current test expects rejection only on the next check. The prompt writer then performs the paste write immediately. For joined submission, the Enter is included in that same write, so the payload can reach the returned shell before the next guard check.

Suggested fix
     if (shellInFront) {
       watch.unsubscribe()
       throw new Error('agent_not_in_foreground')
     }
+    if (watch.shellMayHaveReturned) {
+      watch.unsubscribe()
+      throw new Error('agent_not_in_foreground')
+    }
     cleared = watch

Update the timing test to expect the first beforeWrite call to reject when the marker arrives during its check.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const beforeWrite = async (ptyId: string): Promise<void> => {
if (cleared?.ptyId === ptyId && !cleared.shellMayHaveReturned) {
return
}
dispose()
let carry = ''
const watch = { ptyId, shellMayHaveReturned: false, unsubscribe: (): void => {} }
// Subscribed before the read, so a shell that returns while it runs is not missed.
watch.unsubscribe = runtime.subscribeToTerminalData(ptyId, (data) => {
const window = carry + data
carry = window.slice(-MARKER_CARRY_CHARS)
if (SHELL_RETURN_MARKERS.some((marker) => window.includes(marker))) {
watch.shellMayHaveReturned = true
}
})
let shellInFront: boolean
try {
shellInFront = await runtime.isLaunchShellInFront(ptyId, agent)
} catch (error) {
watch.unsubscribe()
throw error
}
if (shellInFront) {
watch.unsubscribe()
throw new Error('agent_not_in_foreground')
}
cleared = watch
}
const beforeWrite = async (ptyId: string): Promise<void> => {
if (cleared?.ptyId === ptyId && !cleared.shellMayHaveReturned) {
return
}
dispose()
let carry = ''
const watch = { ptyId, shellMayHaveReturned: false, unsubscribe: (): void => {} }
// Subscribed before the read, so a shell that returns while it runs is not missed.
watch.unsubscribe = runtime.subscribeToTerminalData(ptyId, (data) => {
const window = carry + data
carry = window.slice(-MARKER_CARRY_CHARS)
if (SHELL_RETURN_MARKERS.some((marker) => window.includes(marker))) {
watch.shellMayHaveReturned = true
}
})
let shellInFront: boolean
try {
shellInFront = await runtime.isLaunchShellInFront(ptyId, agent)
} catch (error) {
watch.unsubscribe()
throw error
}
if (shellInFront) {
watch.unsubscribe()
throw new Error('agent_not_in_foreground')
}
if (watch.shellMayHaveReturned) {
watch.unsubscribe()
throw new Error('agent_not_in_foreground')
}
cleared = watch
}

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one narrow edge case in the new shell detection, inline.

Reviewed changes

  • Typed-line carry rule — new src/shared/startup-line-prompt-carry.ts decides whether a prompt rides the built launch command (single control-free line ≤ 512 B, or a local-zsh multi-line line with each line ≤ 512 B and the whole line ≤ 8 KB); otherwise the launch starts clean and reports promptCarried: false so the host pastes later.
  • Host readiness + paste — launched-agent-composer-readiness.ts reuses the desktop paste's composer signal and falls back to tui-idle; agent-launch-terminal-prompt.ts waits out user dialogs and pastes on the desktop's frame/timing; launched-agent-write-guard.ts/launched-agent-shell-foreground.ts refuse a write when a shell is proven in front.
  • Shell hand-off readiness — runtime-worktree-startup-readiness.ts now only counts output after the shell's last ?2004l, with async accept/shell-in-front vetoes; tui-idle-evidence.ts gains launchReadiness so a name-only title needs a quiet stream; agent-title-status.ts stops stamping Gemini's rest glyph onto a bare gemini title.
  • Replay / identity — replay admission+claim folded into one durable transaction (admitAndClaimOperation), agent-launch-replay-refusal.ts unifies desktop and mobile refusal classification, and the desktop renderer gets a host-set trustedLocalCallerId so its replays key stably.
  • Protocol / step-2 seams — the four agent.launch capabilities move to agent-launch-runtime-capability.ts; viewMode, explain_commit, and the caller-selection move to onSurfacePublished prepare step 2 without changing step-1 behavior.

I read the full diff and traced the carry decision against the actual spawn shell selection, the write guard against every write path, and the replay ledger against the removed two-step flow. The ledger fold and the paste guard hold; the one gap I found is anchored below.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +21 to +25
const shellPath =
args.shellOverride?.trim() ||
args.defaultShellSetting?.trim() ||
(args.envShell ?? process.env.SHELL) ||
'/bin/zsh'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name resolved here can disagree with the shell the PTY actually spawns, so a multi-line prompt can still be typed into a shell that submits each line separately. The spawn chain is args.shellOverride → getDefaultShell() → args.env.SHELL → process.env.SHELL → '/bin/zsh' (src/main/providers/local-pty-launch-plan.ts:240-245) and can substitute /bin/bash//bin/sh via resolveUnixShellPath (src/main/providers/local-pty-utils.ts:66-79); this function omits the launch-env SHELL term (its envShell parameter is never passed by either call site) and that fallback, so it can name zsh while the PTY runs a non-bracketed-paste shell.

Technical details
# carry decision predicts a shell the spawn can override

## Affected sites
- `src/main/runtime/agent-launch-typed-line-shell.ts:24` — `envShell` is defined but neither production caller supplies it (only `agent-launch-typed-line-shell.test.ts` does).
- `src/main/runtime/runtime-worktree-agent-startup.ts:156-165` — passes `defaultShellSetting` only.
- `src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts:276-282` — passes `shellOverride`/`defaultShellSetting` only, not `opts.env.SHELL`.
- Spawn truth: `src/main/providers/local-pty-launch-plan.ts:240-245`, `src/main/providers/local-pty-utils.ts:66-79`, and the daemon twin `src/main/daemon/shell-ready.ts`.

## Concrete case
- Local POSIX host, `terminalDefaultShell` empty, `process.env.SHELL=/bin/zsh`, launch env `SHELL=/bin/sh` (spread from `agentDefaultEnv` via `shared/agent-startup-plan-inputs.ts:60` → `shared/tui-agent-startup.ts:114`).
- Agent with `promptInjectionMode: 'argv'`, multi-line prompt with each line ≤ 512 B.
- `startupLineCarriesPrompt` returns true (`args.shellName === 'zsh'`), the prompt is folded into `launchCommand`, and the spawn runs `/bin/sh` — where `isBracketedPasteSafeShell` is false, so the embedded `\n`s are submitted line by line.
- Same class without an env override: a configured shell path that does not exist/exec resolves to `/bin/bash` or `/bin/sh` in the spawn while the decision still sees the configured basename.

## Required outcome
- The carry rule must classify the shell the PTY will actually run, or only carry multi-line when the host can guarantee the spawned shell is zsh.

## Suggested approach
- Thread the launch env's `SHELL` into `envShell` at both call sites and account for `resolveUnixShellPath`'s fallback; or gate the multi-line carry on a shell name the host reports after spawn.

## Open questions for the human
- Is `agentDefaultEnv[...].SHELL` a supported configuration? If not, is the unusable-configured-shell fallback the only reachable divergence?

@brennanb2025
brennanb2025 marked this pull request as draft October 2, 2026 02:04
… local caller id

Both were inert in step 1 and existed only for step 2. agent.launch will become a
public plugin API, so every wire field is permanent once shipped; a top-level
viewMode reads as "choose terminal vs chat", which the host decides. Step 2
introduces placement and view intent under a placement object instead.
…unch-host-prompt-delivery

# Conflicts:
#	src/main/ipc/desktop-renderer-runtime-capabilities.test.ts
…es' hold anchor

Main (#24375) moved Codex's provisional-header check into codex.json's
provisional_startup hold anchor and deleted codex-terminal-readiness.ts, so the
launch readiness hold now asks showsHoldAnchor, as main's own settled check does.
…and census the zsh fixture

Main (#24375) answers a name-only title from each agent's rule file ahead of the
sustained-title lane, so gemini.json's name_title settled a launch readiness wait
on the shell's auto-title while Gemini was still booting. A launch now asks quiet
of every weak idle verdict, as that lane did.

Main's readiness census requires a recorder for every runtime fixture; the zsh
prompt recording is a non-agent control. Gemini's synthetic baseline is
regenerated for this PR's stated change: a bare gemini title is no longer its
rest mark, so name-only rows settle weak, and a fresh working or blocked status
is no longer overridden.
@brennanb2025
brennanb2025 marked this pull request as ready for review October 2, 2026 06:28
@greptile-apps

greptile-apps Bot commented Oct 2, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 10813e6c-47d6-4faa-ba47-2cbf9b506e61

📥 Commits

Reviewing files that changed from the base of the PR and between 8dc5516 and 39419d2.

📒 Files selected for processing (13)
  • src/main/agent-launch/agent-launch-surface-factories.ts
  • src/main/ipc/desktop-renderer-runtime-capabilities.test.ts
  • src/main/ipc/desktop-renderer-runtime-capabilities.ts
  • src/main/ipc/runtime-agent-launch-capability.test.ts
  • src/main/runtime/__fixtures__/readiness-census/synthetic--gemini.json
  • src/main/runtime/__fixtures__/readiness-census/transcript--zsh-prompt-runs-command@unknown.json
  • src/main/runtime/agent-session-record-store.ts
  • src/main/runtime/launched-agent-composer-readiness.ts
  • src/main/runtime/readiness-census-transcript-catalog.ts
  • src/main/runtime/rpc/methods/agent-launch-surfaces.ts
  • src/main/runtime/rpc/methods/structured-agent-session-admission.test.ts
  • src/main/runtime/tui-idle-evidence.ts
  • src/shared/protocol-version.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

) {
return 'dialog'
}
return showsHoldAnchor(waitText.toLowerCase()) ? 'starting' : null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
# Inspect the text projection, acceptance path, and Codex header cases.
rg -n -C 7 'readFreshComposerHold|buildTerminalWaitText|buildTuiIdleProbeResult|model: loading' \
  src/main/runtime/orca-runtime-activate-managed-worktree.ts \
  src/main/runtime/launched-agent-composer-readiness-codex.test.ts \
  src/main/runtime/agent-state-rules/agent-state-text-anchors.ts

Repository: stablyai/orca

Length of output: 6960


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- readiness helper ---'
cat -n src/main/runtime/launched-agent-composer-readiness.ts | sed -n '1,150p'
printf '%s\n' '--- activation acceptance and idle probe ---'
cat -n src/main/runtime/orca-runtime-activate-managed-worktree.ts | sed -n '175,245p'
printf '%s\n' '--- terminal wait text ---'
cat -n src/main/runtime/terminal-wait-tail-state.ts | sed -n '1,180p'
printf '%s\n' '--- Codex readiness tests ---'
cat -n src/main/runtime/launched-agent-composer-readiness-codex.test.ts | sed -n '1,180p'
printf '%s\n' '--- related fixtures and symbols ---'
rg -n -C 5 'model: loading|buildTuiIdleProbeResult|readLiveTerminalScreenLines|buildTerminalWaitText|readFreshComposerHold' src/main/runtime

Repository: stablyai/orca

Length of output: 42109


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- hold anchor and blocked detection ---'
rg -n -C 8 'function showsHoldAnchor|export .*showsHoldAnchor|showsHoldAnchor|detectTerminalWaitBlockedReason' src/main/runtime/agent-state-rules src/main/runtime/terminal-wait-detection.ts
printf '%s\n' '--- Codex screen predicates ---'
cat -n src/main/runtime/agent-state-rules/codex-screen-predicates.ts | sed -n '1,180p'
printf '%s\n' '--- Codex readiness rule ---'
cat -n src/main/runtime/agent-state-rules/codex.json | sed -n '1,80p'
printf '%s\n' '--- transcript readiness test ---'
cat -n src/main/runtime/codex-header-readiness-transcripts.test.ts | sed -n '80,210p'
printf '%s\n' '--- remaining launched-agent Codex test ---'
cat -n src/main/runtime/launched-agent-composer-readiness-codex.test.ts | sed -n '90,150p'

Repository: stablyai/orca

Length of output: 28566


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- buildTuiIdleProbeResult definition and uses ---'
rg -n -C 12 'buildTuiIdleProbeResult' src/main/runtime
printf '%s\n' '--- readiness helper tests ---'
rg -n -C 10 'readFreshComposerHold|waitForFreshWorkerComposer|screen-only|provisional' src/main/runtime --glob '*test.ts'

Repository: stablyai/orca

Length of output: 42290


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- transcript test helpers and imports ---'
cat -n src/main/runtime/codex-header-readiness-transcripts.test.ts | sed -n '1,95p'
printf '%s\n' '--- quiet/known readiness bindings ---'
rg -n -C 12 'function isQuietReadyScreenBody|export function isQuietReadyScreenBody|function isKnownReadyPromptBody|export function isKnownReadyPromptBody' src/main/runtime
printf '%s\n' '--- hold-anchor tests and compiled hold rule references ---'
cat -n src/main/runtime/agent-state-rules/agent-state-rule-regions.test.ts | sed -n '145,175p'
rg -n -C 8 '"state": "hold"|holds|provisional_startup|compileTextAnchors' src/main/runtime/agent-state-rules

Repository: stablyai/orca

Length of output: 35581


Reject screen-only startup holds before accepting readiness.

readFreshComposerHold checks starting holds only in waitText. If screenLines still contains Codex's model: loading header, the composer signal can be accepted and input can be discarded.

🐛 Suggested fix
-  return showsHoldAnchor(waitText.toLowerCase()) ? 'starting' : null
+  return showsHoldAnchor(
+    `${waitText}\n${screenLines?.join('\n') ?? ''}`.toLowerCase()
+  )
+    ? 'starting'
+    : null
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return showsHoldAnchor(waitText.toLowerCase()) ? 'starting' : null
return showsHoldAnchor(
`${waitText}\n${screenLines?.join('\n') ?? ''}`.toLowerCase()
)
? 'starting'
: null

brennanb2025 added a commit that referenced this pull request Oct 2, 2026
A launch line that is long or has several lines could lose lines or wedge a
shell that is still starting, because it was typed into the terminal before
the shell could read it whole. The terminal host (local daemon, and the SSH
relay) now writes such a line to a script file and types one short line that
runs it, for every POSIX shell; a shell that cannot read Orca's quoting runs
Orca's own agent lines through /bin/sh. If the script cannot be written, the
terminal says so.

The SSH background launch path no longer types the line from the renderer;
the relay stages it like any other terminal.

Bumps the terminal daemon protocol to 40 (main took 39); a v39 daemon keeps
its sessions and gets no staged line.

Restacked onto #24257 from the version reviewed on top of #23748; carries
none of #23748's changes.
brennanb2025 added a commit that referenced this pull request Oct 5, 2026
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Live re-check of the two latest fixes at ff0e1df9b63. Compared with main e2da3a1eb19, using stub agents on macOS zsh and Windows cmd: nothing is worse than main.

Last check found two problems:

  • A worker start on a paired server, whose agent crashed at startup, typed the worker brief into the server's shell, which ran it. Main does the same.
  • A Grok worker's brief arrived about 12 s later than main.

Now:

  • Paired-server worker start, agent crashes at startup (3/3): the start fails at 4.1 s with dispatch_input / agent_not_in_foreground, and nothing is typed into the server's shell. Main still types and runs the brief.
  • Paired-server worker start, ready agent: the brief arrives once, with the same timing as main (9.1 s).
  • Grok worker start: the brief lands 13–32 ms after Grok's composer appears. That is 0.8 s on macOS and 0.5 s on Windows, against main's 2.1 s; last check it was 14 s.
  • DSH worker start: main never delivers the brief (it times out). This PR delivers it in 1.0–1.4 s.
  • Grok worker crash on macOS: main types and runs the brief. This PR types nothing.
  • Startup crash through agent.launch (macOS zsh, Windows cmd): nothing is typed into the shell.
  • Gemini, with the shell's auto-title already reading gemini: the prompt is never pasted while Gemini is still booting (4 s silent or spinner boot). It is pasted once, after Gemini is ready. If Gemini crashes, the prompt is not delivered and nothing is typed.

Not verified:

  • The Windows brief check never ran: a crashed worker times out at 90 s before reaching it, on both main and this PR.
  • Stub agents only, no real agents.
  • Why the paired-server wait settles on the shell after about 4 s is not traced. The new check before the write stops the brief either way.

brennanb2025 added a commit that referenced this pull request Oct 5, 2026
…o the stack is one chain

#24934 was stacked on #24257; it now sits on #24081 (24257 -> 23962 -> 24080 -> 24081 -> 24934).
The prompt-delivery table keeps #24080's argv row and this branch's unconfirmed row. The restart
replay test pins its stub to the paste path, since #24080's fixture now reports an offered prompt
carried unless told otherwise.
@brennanb2025
brennanb2025 merged commit 761d63a into main Oct 5, 2026
40 checks passed
brennanb2025 added a commit that referenced this pull request Oct 5, 2026
…rompt-on-command-line

Main now carries #24257 (squashed as 761d63a) and #25579, so this PR's diff is its own changes
again. #24257's files are identical on both sides; no conflicts.
brennanb2025 added a commit that referenced this pull request Oct 5, 2026
brennanb2025 added a commit that referenced this pull request Oct 5, 2026
brennanb2025 added a commit that referenced this pull request Oct 5, 2026
brennanb2025 added a commit that referenced this pull request Oct 6, 2026
…ole (step 2 of 7) (#23962)

* feat(agent-launch): host-side prompt delivery for agent.launch

The host's agent.launch typed any launch prompt into the shell as part of the
launch command. A long or multi-line prompt then ran line by line in the
shell, and an agent that never showed readiness or crashed at startup had
nothing guarding where its text went.

agent.launch now carries a prompt on the typed line only when the line stays
one line, control-free and at most 512 bytes; otherwise the agent starts
clean and the host pastes the prompt once the agent's own ready signal fires
(bracketed paste plus its composer marker or a quiet render, read only after
the shell's last hand-off, never while the pane's own shell is proven in
front), with main's draft-paste bytes and an Enter 50 ms later. Orchestration
worker starts wait on tui-idle as before. A replay-safe launch admits and
claims its ledger row in one write, Qwen Code gets a second Enter, the
desktop and phone share one launch-refusal classifier, and hosts advertise
agent.launch.prompt-carry.v1.

Split out of #23748, which moves the desktop source-control buttons onto
this path.

* fix(agent-launch): keep a short-lined multi-line prompt on a local zsh launch line, as main did

#24257 moved every multi-line or over-512-byte prompt off the typed launch line and pasted it after readiness. The phone's AI buttons and review notes, whose multi-line prompts main typed whole into zsh, then reached Claude 0.5-3 s later and their RPC reply waited for the paste.

The host now names the shell a local macOS or Linux line is typed into, the way the spawn picks it, and a multi-line prompt rides a zsh line when every line is at most 512 bytes and the whole line at most 8 KB. A real-zsh test types such a line through Orca's own ready barrier and startup write, including when a slow user config makes the write land early. Elsewhere the measured unsafe cases keep the paste: bash 3.2 runs multi-line lines piecemeal, fish drops an early multi-line write, and any shell loses a line over 1 KB written early.

* test(agent-launch): keep the real-zsh launch-line test out of the Windows lane's gate scan

The Windows lane registration check read `const ZSH_PATH = process.platform === 'win32'` (the head of a multi-line ternary) as a Windows-true flag, so `describe.skipIf(!ZSH_PATH)` looked like a Windows-only suite. The file is POSIX-only; the zsh lookup is now a function.

* refactor(protocol): move the agent.launch capabilities into their own module

Main's protocol-version.ts sits at the 300-line cap, so the prompt-carry capability pushed it over. The four agent.launch capabilities and their doc move to agent-launch-runtime-capability.ts, re-exported by name and spread into RUNTIME_CAPABILITIES at the same position; the advertised lists and every export are unchanged.

* refactor(protocol): import the agent.launch capabilities from their own module

`export *` from protocol-version left the four names undefined under the mobile recording loader, which resolves a relative import through a Proxy with no own keys, so 37 phone recordings lost agent.launchReplay. Importers now name agent-launch-runtime-capability directly; protocol-version only spreads its list.

* refactor(agent-launch): drop the unshipped viewMode field and trusted local caller id

Both were inert in step 1 and existed only for step 2. agent.launch will become a
public plugin API, so every wire field is permanent once shipped; a top-level
viewMode reads as "choose terminal vs chat", which the host decides. Step 2
introduces placement and view intent under a placement object instead.

* fix(agent-launch): read Codex's provisional startup from the rule files' hold anchor

Main (#24375) moved Codex's provisional-header check into codex.json's
provisional_startup hold anchor and deleted codex-terminal-readiness.ts, so the
launch readiness hold now asks showsHoldAnchor, as main's own settled check does.

* fix(agent-launch): hold rule-file name titles to quiet for a launch, and census the zsh fixture

Main (#24375) answers a name-only title from each agent's rule file ahead of the
sustained-title lane, so gemini.json's name_title settled a launch readiness wait
on the shell's auto-title while Gemini was still booting. A launch now asks quiet
of every weak idle verdict, as that lane did.

Main's readiness census requires a recorder for every runtime fixture; the zsh
prompt recording is a non-agent control. Gemini's synthetic baseline is
regenerated for this PR's stated change: a bare gemini title is no longer its
rest mark, so name-only rows settle weak, and a fresh working or blocked status
is no longer overridden.

* fix(launch): stage long or multi-line launch lines so they arrive whole

A launch line that is long or has several lines could lose lines or wedge a
shell that is still starting, because it was typed into the terminal before
the shell could read it whole. The terminal host (local daemon, and the SSH
relay) now writes such a line to a script file and types one short line that
runs it, for every POSIX shell; a shell that cannot read Orca's quoting runs
Orca's own agent lines through /bin/sh. If the script cannot be written, the
terminal says so.

The SSH background launch path no longer types the line from the renderer;
the relay stages it like any other terminal.

Bumps the terminal daemon protocol to 40 (main took 39); a v39 daemon keeps
its sessions and gets no staged line.

Restacked onto #24257 from the version reviewed on top of #23748; carries
none of #23748's changes.

* fix(launch): run a staged launch line as its own job in fish and ksh

fish and ksh run the commands of a sourced file in the shell's own process
group, so a staged agent was not a job: Ctrl-Z was dropped (a typed line
stops), and the shell-in-front check saw the shell's group in the foreground
while the agent ran. fish now runs the script with
`eval (string collect < '<path>')`, which runs it as if typed and leaves the
shell's job-control mode alone. ksh and mksh run a long Orca-built line through
`/bin/sh '<path>'`, a real job; a long command the user wrote is typed as
before. The real-shell test now asserts the agent's process group is its own
and is the terminal's foreground group.

The staging rule also reuses hasControlByte and the 512-byte budget from
startup-line-prompt-carry so the carry and staging limits cannot drift.

* fix(agent-launch): paste a launch prompt only when the launched agent is proven in front

A launch pasted its prompt unless a shell was proven in the terminal's
foreground, so any read that could not prove one let the prompt through. After
an agent exited at startup, its shell turned bracketed paste on at the next
prompt, readiness fired on it, and the prompt was typed into the shell:

- macOS: a pane runs its shell under login, so the process-group fence's root
  was never the shell's group and never proved it; the cached foreground name
  could also still name the exited process.
- Windows Git Bash and WSL: the shell-alone-in-its-job check never answers.

Now one fresh read of the terminal's foreground decides: agent, shell or
unknown. Only 'agent' lets a write through (paste, Enter, second Enter, reused
panes too); 'shell' still drops a ready signal. A Windows host never proves the
agent, so there the launch line carries the prompt at any size, as on main.

* test(agent-launch): cover the Windows QA stub, a grok override that exits at once

* fix(agent-launch): keep the local socket alive while a prompted launch waits for its agent

A launch with a prompt now waits up to 60 s for the terminal agent to be
ready before it writes the prompt, and reports not-delivered when the agent
never is. The local runtime socket closes a connection idle for 30 s unless
the request is a long poll, so a launch whose agent exited at startup lost its
reply and the caller saw 'runtime closed the connection' instead of
not-delivered. Classify a prompted agent.launch and agent.launchReplay as a
long poll, as orchestration.workerStart already is for the same wait.

* refactor(agent-launch): narrow the launch params by 'in' instead of a cast

* fix(agent-launch): find a launched agent behind a wrapper that leads its process group

A tcsh or nu launch line runs the agent from /bin/sh '<script>', and a
wrapper script that does not exec its agent does the same: the wrapper leads
the terminal's foreground process group and the agent is a member of it. The
fresh foreground read names the group's leader, sh, so a prompted launch was
refused or pasted late (M4Air tcsh: 2 of 4 not delivered, 2 pasted ~9 s late).

Before that read, take the host's process-group observation as positive proof
when it names the launched agent among the foreground group's members and is
younger than a ready signal's quiet window. It never proves a shell.

* fix(launch): type a plain Orca agent line as is in tcsh and nu

In tcsh and nu, every Orca-built agent line ran through `/bin/sh '<script>'`,
so the agent was a child of sh in sh's process group. When the prompt was
pasted after the agent was ready, the paste guard read sh in front and, on a
loaded Mac, reported the prompt not delivered (4/4 live runs in tcsh).

Now only a line that needs it goes through `/bin/sh`: one over 512 bytes, with
a control byte, or with a character those shells would not read literally
(`!`, backslash, `"`, `$`, backtick). A plain line such as
`claude '--dangerously-skip-permissions'` is typed as on main, so the agent is
the shell's own job.

* fix(agent-launch): judge the foreground-group proof by when its capture began, not how long ps took

The age the host stamps on a process-group observation runs from the start
of its whole-machine ps, so on a loaded Mac a capture begun after the read
was asked for still read as older than 1 s and the proof was dropped. Count
an observation whose capture began after the read was asked for, less the
window a shared capture is reused across.

* test(agent-launch): keep the crash-guard live test out of the Windows lane's gate scan

The Windows-lane registration scan read the const assigned from a platform
check as a Windows-only gate, though the suite runs everywhere but Windows;
find zsh in a function instead, as the real-zsh typed-line test does.

Under load the fresh foreground scan can fail to answer, which lets the
shell's prompt settle readiness (2 of 4 paired runs). The guard still refuses
that write, so assert the refused write, the property that must always hold.

* perf(agent-launch): read a local pane's foreground from its own terminal, not the whole process table

The foreground read that gates every launch paste ran the daemon's
inspectProcess capture and then a fresh scan, each a whole-machine ps; the
fresh one also waits for any capture already running before it starts its own.
Measured here at load 5: 1.2 s a read (M4Air QA: 3.4-5.0 s, and worker starts
17.6-32 s against main's 9-12 s at load 25-84).

On a local macOS or Linux host, take the pane's root pid from the provider's
session inventory and run one ps limited to that pane's terminal. Its
foreground process group decides: the launched agent or any non-shell member
is the agent (a wrapper that did not exec its agent leads the group), a group
of shells alone is the shell. Same pane, same verdict: 2.7 ms a read. SSH hosts
keep the relay's observation and name.

* test(mobile): re-measure the web app's script sweep after agent.launch's capabilities moved out of protocol-version

The mobile web bundle check failed at 124 assets against a ceiling of 123. Main already sat
exactly on that ceiling: its sweep table read 69 scripts at 16 routes while the tree builds 73,
the whole margin of 4. This branch imports the agent.launch capabilities from their own module,
so protocol-version is no longer pulled into the root layout and four other routes. That moves
which routes share which modules, and the Qoder capability module, imported by protocol-version
and the AI-vault resume path, no longer shares an importer set with anything, so it gets a chunk
of its own: 74 scripts.

The fence says to re-derive the bound rather than raise it, so the sweep is re-measured on this
head (every prefix of the sorted route list). The worst route now adds 10 scripts (session), not
9, which moves the pinned shell crossing from 32 to 30 routes; main re-measured on its own lands
on the same crossing.

* fix(agent-launch): a worker's brief needs its agent found in front, and Grok's start answers on its composer

A paired-server worker start whose agent exited at startup typed its brief into the server's
shell, which ran it: the idle wait can settle on a shell back at its prompt, and the brief was
written with no foreground read. Both worker-start paths now check before each brief write, as a
launch prompt is checked: on a host that can find the agent in front it must be there; on one that
cannot (Windows) a shell proven in front still refuses, and anything else writes as before.

A Grok worker start waited ~10 s more than main: its only rest signal is its bare name, which a
launch holds to quiet output, and Grok animates its logo for ten seconds after its composer glyph.
A worker start for an agent whose rest signal is its bare name and whose composer draws a marker
(Grok, DSH, mimo-code) now also answers on that marker, whichever comes first.

* test(startup-staging): expect the CR that submits a typed launch line since #23672
brennanb2025 added a commit that referenced this pull request Oct 6, 2026
…indows shell would damage

Readiness 05/06: widening when-host-proves-agent to Windows cmd/PowerShell
also moved prompts onto pastes that have no such guard: a worktree create's
follow-up writes the prompt raw with Enter and never checks for the shell,
and the window's composer paste cannot prove the agent on Windows. A new
mode, when-host-guards-paste, carries that widening; agent.launch asks for
it, and every other caller keeps #24257's rule (the line, or a file).
brynnclaw added a commit to brynnclaw/orca that referenced this pull request Oct 9, 2026
… stay within the writer's line limit

Main's composer-ready launch path (stablyai#24257) added a resubmit method to the
prompt writer, which put the rebased file at 330 counted lines against
the repo's 300-line limit. The two composer reads (the residue read and
the synchronous parked re-read) move unchanged into the type-checked
ledger, which reads the pane's screen through a narrow host. No behavior
change.
Jinwoo-H added a commit that referenced this pull request Oct 11, 2026
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
treeman99 added a commit to treeman99/orca that referenced this pull request Oct 12, 2026
증상: 사내 Windows 설치본이 창은 뜨지만 하얀 화면에서 멈춘다.

원인: 포크가 src/shared/agent-prompt-injection.ts 에 남긴 모듈 최상위
`AGENT_PROMPT_SUBMIT_DELAY_MS = process.platform === 'win32' ? …` 를 upstream stablyai#24257
(v1.4.222)이 렌더러 agent-paste-draft.ts 에서 import 하기 시작했다. 번들러는 안 쓰는 선언은
지우되 전역 읽기 `process.platform;` 은 부작용으로 남기고, sandbox 렌더러에는 process 가
없어 ESM 부팅 그래프 전체가 ReferenceError 로 죽는다(main.tsx 미실행, #root 비어 있음,
크래시 진단도 설치 전). Windows 패키지 빌드는 10초 뒤 창을 강제로 드러내 하얀 창이 된다.
첫 영향 v1.4.223-samsungds, 마지막 정상 v1.4.221-samsungds. 정책·OS 와 무관.

수정
- 상수를 유일한 소비자인 메인 전용 orca-runtime-agent-prompt-rescue.ts 로 옮긴다.
- 재발 게이트: renderer-boot-graph-node-globals.mjs 가 부팅 청크를 vite.parseAst 로 읽어
  모듈 평가 시점의 Node 전역(process·Buffer·global·require·__dirname·__filename) 읽기를
  잡는다. typeof 로 가드한 분기는 통과. verifyRendererBootGraph 에 물려
  build:electron-vite 끝에서 빌드를 실패시킨다(단위 테스트·tc:web 는 Node 위라 못 잡는다).
- 원장: 게이트 훅 앵커 2, absentSymbols(AGENT_PROMPT_SUBMIT_DELAY_MS 의 shared 복귀 금지).

검증
- 뮤테이션: 수정 전 번들에서 게이트가 agent-paste-draft 청크의 그 한 지점만 잡고 실패,
  수정 후 재빌드는 통과.
- 숨김 Electron + CDP: 정책 없음 / lockdown+허용목록 둘 다 온보딩까지 그려짐, 렌더러 에러 0.
- 관련 테스트 38파일 754건, 원장, tc:node, tc:web 통과.
- Windows 실기 빌드는 미확인.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Jinwoo-H added a commit that referenced this pull request Oct 14, 2026
…ne (#26501)

* 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): 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

* 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): 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

* 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 flag

eff410f dropped `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

* 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

* 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

* 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(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

* 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>

* 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>
Jinwoo-H added a commit that referenced this pull request Oct 14, 2026
…ne (#26533)

* 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

* 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): 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

* 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 flag

eff410f dropped `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

* 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): 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

* 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(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

* 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>

* 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>
Jinwoo-H added a commit that referenced this pull request Oct 14, 2026
…#27749)

* 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): 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

* 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): 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

* 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 flag

eff410f dropped `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

* 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

* 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

* 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

* feat(mobile): view and drive server browser pages this desktop renders

A phone could not see or open browser tabs a server workspace places on the
desktop: the server hides them from callers without browser.clientHost.v1 and
cannot screencast them. The desktop now serves those calls itself.

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

* fix(mobile): keep browser tab creation on the server and test the screencast release

tabCreate's page names a page to create, so it always relays; the census now
asserts that rule. Adds a relay round-trip for screencast subscribe/unsubscribe.

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

* 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>

* 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).

* fix(browser): keep client-hosted browser pages opaque

Client-hosted pages were created without transparent=false, so a page with no
background had a transparent canvas: the desktop's backdrop showed through it,
and its screencast to the phone was solid black (JPEG has no alpha). Both
browser-page guests now share one preferences constant.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Jinwoo-H added a commit that referenced this pull request Oct 14, 2026
…#26518)

* 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

* feat(push): carry a notification's workspace server to the phone

A desktop relays alerts for agents on its paired servers, but the push named
only the server's worktree, so a tap opened it on the desktop and bounced.
The gateway accepts an optional runtime execution host and forwards it.

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

* feat(notifications): tell the phone which server runs a notified workspace

A server agent's alert reached the phone with the server's worktree id and no
host, so tapping it opened the desktop's workspace list and bounced. The
producers now name the runtime host, and main carries it into the push.

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

* feat(mobile): open a notified server workspace on its server

A push that names a runtime host now routes its tap through that server, like
any other server workspace. Absent or non-runtime hosts open as before.

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

* fix(mobile): keep viewing suppression to the viewed workspace's own host

A desktop workspace and a server workspace can share an id, so viewing one
must not silence the other's alerts. Also pins a runtime-stamped tab owner.

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

* test(mobile): name the push provider payload builder for its role

The anti-slop gate rejects "shape" as a symbol name; the loop variable builds
an APNs or FCM payload, so it is named for that.

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

* 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): 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

* 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 flag

eff410f dropped `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

* 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

* 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

* 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(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

* 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>

* 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>
Jinwoo-H added a commit that referenced this pull request Oct 14, 2026
…desktop (#26521)

* 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

* test(mobile): prove a server browser page streams to a phone through its desktop

Server-placed browser tabs already reach a desktop-paired phone: browser.* and status.get route to the
execution host, and the relay passes screencast binary frames through untouched. This pins it end to end:
the tab lists, and real JPEG frames arrive on the phone socket.

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

* test(mobile): stable request ids and checked replies in the server browser e2e

Date.now() ids could repeat within a millisecond and match a stale reply; a counter cannot. The reply
helper now checks its frame instead of asserting it non-null.

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

* 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): 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

* 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 flag

eff410f dropped `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(e2e): stop requiring the dropped stale flag in the server-browser spec

eff410f removed stale from mobileRelay.hosts.worktrees, so the schema never parsed and the row poll timed out.

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

* 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

* 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(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

* 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>

* 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>
Jinwoo-H added a commit that referenced this pull request Oct 14, 2026
)

* 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

* 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

* 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): 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 flag

eff410f dropped `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

* 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(e2e): stop requiring the dropped stale flag in the files and git review spec

eff410f removed 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

* 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): 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

* 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(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

* 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>

* 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>
Jinwoo-H added a commit that referenced this pull request Oct 14, 2026
… screen (#27741)

* 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 flag

eff410f dropped `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 spec

eff410f removed 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
(c4c058a, 4a5ccaa, 3b414b8, a2bb1cf, adb8ac5) and the server-SSH parts of
ce4b125: 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 from ce4b125: 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>
brennanb2025 added a commit that referenced this pull request Oct 16, 2026
… message carries (#24488)

* fix(quick-commands): save agent prompts whole, bounded by what a chat message carries

Agent quick-command prompts were silently cut to 6,000 characters on save:
the editor accepted a long paste, settings normalization trimmed it, and
reopening showed the shortened copy. The 6,000 was a Windows launch speed
threshold, not a limit on prompts.

- Storage no longer trims. A save is refused, never trimmed, when a prompt's
  chat text block is over 256 KiB of JSON (measured on its real escaped size,
  the measure the chat send schema applies) or the list is too large for one
  paired reply. Host and desktop store both check before writing.
- The editor shows the size against the limit and blocks Save past it, and
  stays open with the text when the host refuses the save.
- terminal.quick-commands.long-prompts.v1: hosts withhold prompts over 6,000
  characters from clients that do not advertise it (older clients reject a
  whole list holding one); editors keep an older host's 6,000 cap.
- The phone editor no longer drops pasted text past 6,000 via maxLength.

Launch delivery of long prompts is left to the launch-prompt series
(#24257, #23962, #24080); this branch's earlier paste-after-ready delivery
duplicated it and is removed.

* fix(quick-commands): drop a stale duplicate doc comment

* fix(quick-commands): advertise long prompts to this build's own host too

* fix(quick-commands): keep submitted editor immutable until save settles

* test(quick-commands): keep storage and save lifecycle coverage focused

* fix(quick-commands): keep portalled choices within pending form ownership

* Preserve retained credentials when settings validation refuses a save

---------

Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant