Repository navigation
feat(agent-launch): keep long prompts off the launch line and paste them after readiness (step 1 of 7) - #24257
Conversation
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.
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).
|
Review status (one pass after the split, 2026-09-30) No blockers. This PR works on its own for its current callers: RPC
Open, not fixed (design calls):
|
Re-run at e463aea, M4Air (zsh), 2026-10-01What 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.
Setup.
Phone (Android emulator,
|
| 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, Fixfcbd0eba9ede). 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
claudeline) 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":

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

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:

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:

Safety.
- Real
~/.claude/settings.json,~/.codex/hooks.jsonand~/.codex/config.tomlwere 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 statusbefore 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.launchpath 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)
- 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.
- Raw
agent.launchfrom 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.launchwith 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 callsagent.launch. It callsworktree.create(src/cli/handlers/worktree.ts:221), whose host path builds the startup withoutonPromptCarry, 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
--promptthrough the same carry/paste rule, or take the CLI out of that row.
- The CLI's only prompt command,
- 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, whichpasteDraftToAgentPtyWhenReadypastes 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).
- The dialog has no prompt box on either arm (
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 openclawfrom the laptop failed.ssh -vshows 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-keychainflag (passed throughELECTRON_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 inpsbefore any connect.
Builds and safety
- Arms (copied from
git rev-parse; PR head re-checked withgh pr viewbefore each build): PRd2680896f72d6b50953430a2b02fbbf33fc9ec08, main1b0ee969bccd6a58f8412dc1b425079c47810e2a. - 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.v1and the PR-onlyagent_not_in_foreground(launched-agent-write-guard.ts) appear only in the PR's main bundle.- Qwen's
submitRetryDelayMs:1200appears only in the PR's bundle and page. - The phone bundle references the new
agent-launch-replay-refusalmodule only on the PR (3 hits vs 0).
- devBranch
- Rigs: every rig had an isolated HOME,
CLAUDE_CONFIG_DIR,CODEX_HOME,XDG_CONFIG_HOMEand profile, was background-launched withORCA_BACKGROUND_LAUNCH=1from anenv -iallowlist, and had 0 token variables (ps eww). - Driving:
- Electron rigs: Playwright CDP only.
- Phone:
orca emulatoronly. Pairing used "Paste code instead" with the bare code typed in 40-character chunks and read back throughorca emulator ax; the code was never printed. No adb input, no deep link.
- Real config: the real
~/.claude/settings.json,~/.codex/hooks.jsonand~/.codex/config.tomlwere 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-permissionsand Qwen with--approval-mode yolo, on both arms. - Claude ran read-only
git status/node --checkin the throwaway fixture in a few commit-failure runs before Esc; nothing was edited. - Qwen pointed at a refusing fake endpoint.
- Orca launches Claude with
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"):

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

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

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

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, desktop host: the same agent tab, notes pasted once (phone-width because the phone holds the terminal size):

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, main, Windows: the run that reported handed-to-terminal. Qwen's input box is empty and nothing was sent:

…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.
…unch-host-prompt-delivery
|
| const beforeWrite = async (ptyId: string): Promise<void> => { | ||
| if (cleared?.ptyId === ptyId && !cleared.shellMayHaveReturned) { | ||
| return |
There was a problem hiding this comment.
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.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (2)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthroughStartup 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 Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to Inspected launch paths either report prompt carry or preserve prompts through managed-worktree planning; no actionable merge-blocking risk remains. Security Architecture ReviewSecurity architecture risk: 🟡 Moderate · up to 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
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: b4e34bac-eed4-4c28-a630-f0495b116638
📒 Files selected for processing (89)
mobile/src/session/mobile-existing-agent-launch.tsmobile/src/tasks/agent-launch-mobile-replay.test.tsmobile/src/tasks/agent-launch-request.tsmobile/src/tasks/mobile-agent-launch-architecture.test.tsmobile/src/tasks/worktree-create-capability.test.tsmobile/src/transport/mobile-runtime-client-capabilities.test.tsmobile/src/transport/mobile-runtime-client-capabilities.tssrc/main/agent-launch/__fixtures__/host-agent-startup-call-sites.txtsrc/main/agent-launch/agent-launch-executor.test.tssrc/main/agent-launch/agent-launch-executor.tssrc/main/agent-launch/agent-launch-prompt-delivery.tssrc/main/agent-launch/agent-launch-surface-factories.tssrc/main/agent-launch/host-agent-startup-attribution.test.tssrc/main/ipc/desktop-renderer-runtime-capabilities.test.tssrc/main/ipc/desktop-renderer-runtime-capabilities.tssrc/main/ipc/runtime-agent-launch-capability.test.tssrc/main/ipc/runtime.tssrc/main/providers/startup-line-typed-length.live-shell.test.tssrc/main/runtime/__fixtures__/zsh-prompt-runs-command.meta.jsonsrc/main/runtime/__fixtures__/zsh-prompt-runs-command.txtsrc/main/runtime/agent-launch-typed-line-shell.test.tssrc/main/runtime/agent-launch-typed-line-shell.tssrc/main/runtime/agent-session-operation-admission.tssrc/main/runtime/agent-session-record-store.tssrc/main/runtime/agent-terminal-startup-prompt.test.tssrc/main/runtime/agent-transcript-pane-test-harness.tssrc/main/runtime/launched-agent-composer-readiness-claude.test.tssrc/main/runtime/launched-agent-composer-readiness-codex.test.tssrc/main/runtime/launched-agent-composer-readiness.test.tssrc/main/runtime/launched-agent-composer-readiness.tssrc/main/runtime/launched-agent-shell-foreground.tssrc/main/runtime/launched-agent-shell-handoff-readiness.test.tssrc/main/runtime/launched-agent-write-guard.test.tssrc/main/runtime/launched-agent-write-guard.tssrc/main/runtime/orca-runtime-activate-managed-worktree.tssrc/main/runtime/orca-runtime-controller-knows-pty-is-live.tssrc/main/runtime/orca-runtime-create-agent-prompt-render-gate.tssrc/main/runtime/orca-runtime-create-managed-worktree.tssrc/main/runtime/orca-runtime-resolve-worktree-removal-target.tssrc/main/runtime/orca-runtime-tests/agent-launch-prompt-submit.spec.tssrc/main/runtime/orca-runtime-write-terminal-agent-prompt.tssrc/main/runtime/orca-runtime.test.tssrc/main/runtime/rpc/core.tssrc/main/runtime/rpc/dispatcher-stream-options.tssrc/main/runtime/rpc/dispatcher-trusted-local-caller.test.tssrc/main/runtime/rpc/dispatcher.tssrc/main/runtime/rpc/methods/agent-launch-caller-selection.test.tssrc/main/runtime/rpc/methods/agent-launch-caller-selection.tssrc/main/runtime/rpc/methods/agent-launch-prompt-carry.test.tssrc/main/runtime/rpc/methods/agent-launch-replay.test.tssrc/main/runtime/rpc/methods/agent-launch-replay.tssrc/main/runtime/rpc/methods/agent-launch-surfaces.tssrc/main/runtime/rpc/methods/agent-launch-terminal-preferences.test.tssrc/main/runtime/rpc/methods/agent-launch-terminal-prompt.test.tssrc/main/runtime/rpc/methods/agent-launch-terminal-prompt.tssrc/main/runtime/rpc/methods/agent-launch-worktree-creation.tssrc/main/runtime/rpc/methods/agent-launch.test-fixture.tssrc/main/runtime/rpc/methods/agent-launch.test.tssrc/main/runtime/rpc/methods/agent-launch.tssrc/main/runtime/rpc/methods/orchestration/worker/local-worker-start.tssrc/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.tssrc/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.tssrc/main/runtime/rpc/rpc-streaming-dispatcher.tssrc/main/runtime/runtime-managed-worktree-create-types.tssrc/main/runtime/runtime-store-contract.tssrc/main/runtime/runtime-terminal-contracts.tssrc/main/runtime/runtime-terminal-idle-polls.tssrc/main/runtime/runtime-terminal-wait.tssrc/main/runtime/runtime-worktree-agent-startup.test.tssrc/main/runtime/runtime-worktree-agent-startup.tssrc/main/runtime/runtime-worktree-startup-readiness.tssrc/main/runtime/tui-idle-evidence.tssrc/main/runtime/tui-idle-launch-readiness-shell-title.test.tssrc/main/runtime/zcode-readiness-transcript.test.tssrc/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.tssrc/renderer/src/components/terminal-pane/terminal-bracketed-paste.tssrc/renderer/src/lib/agent-paste-draft.tssrc/shared/agent-launch-intent.tssrc/shared/agent-launch-replay-refusal.tssrc/shared/agent-launch-runtime-capability.tssrc/shared/agent-prompt-injection.tssrc/shared/agent-title-status.tssrc/shared/protocol-version.tssrc/shared/rpc-contract/agent-launch-params.tssrc/shared/startup-line-prompt-carry.test.tssrc/shared/startup-line-prompt-carry.tssrc/shared/telemetry-property-schemas.tssrc/shared/terminal-bracketed-paste-text.tssrc/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.
| 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 | ||
| } |
There was a problem hiding this comment.
🎯 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.tsRepository: 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.tsRepository: 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.tsRepository: 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 = watchUpdate 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.
| 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 | |
| } |
There was a problem hiding this comment.
ℹ️ 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.tsdecides 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 reportspromptCarried: falseso the host pastes later. - Host readiness + paste —
launched-agent-composer-readiness.tsreuses the desktop paste's composer signal and falls back totui-idle;agent-launch-terminal-prompt.tswaits out user dialogs and pastes on the desktop's frame/timing;launched-agent-write-guard.ts/launched-agent-shell-foreground.tsrefuse a write when a shell is proven in front. - Shell hand-off readiness —
runtime-worktree-startup-readiness.tsnow only counts output after the shell's last?2004l, with async accept/shell-in-front vetoes;tui-idle-evidence.tsgainslaunchReadinessso a name-only title needs a quiet stream;agent-title-status.tsstops stamping Gemini's rest glyph onto a baregeminititle. - Replay / identity — replay admission+claim folded into one durable transaction (
admitAndClaimOperation),agent-launch-replay-refusal.tsunifies desktop and mobile refusal classification, and the desktop renderer gets a host-settrustedLocalCallerIdso its replays key stably. - Protocol / step-2 seams — the four
agent.launchcapabilities move toagent-launch-runtime-capability.ts;viewMode,explain_commit, and the caller-selection move toonSurfacePublishedprepare 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.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| const shellPath = | ||
| args.shellOverride?.trim() || | ||
| args.defaultShellSetting?.trim() || | ||
| (args.envShell ?? process.env.SHELL) || | ||
| '/bin/zsh' |
There was a problem hiding this comment.
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?… 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
…unch-host-prompt-delivery
…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.
Comments Outside DiffThese findings could not be posted inline.
|
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 10813e6c-47d6-4faa-ba47-2cbf9b506e61
📒 Files selected for processing (13)
src/main/agent-launch/agent-launch-surface-factories.tssrc/main/ipc/desktop-renderer-runtime-capabilities.test.tssrc/main/ipc/desktop-renderer-runtime-capabilities.tssrc/main/ipc/runtime-agent-launch-capability.test.tssrc/main/runtime/__fixtures__/readiness-census/synthetic--gemini.jsonsrc/main/runtime/__fixtures__/readiness-census/transcript--zsh-prompt-runs-command@unknown.jsonsrc/main/runtime/agent-session-record-store.tssrc/main/runtime/launched-agent-composer-readiness.tssrc/main/runtime/readiness-census-transcript-catalog.tssrc/main/runtime/rpc/methods/agent-launch-surfaces.tssrc/main/runtime/rpc/methods/structured-agent-session-admission.test.tssrc/main/runtime/tui-idle-evidence.tssrc/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 |
There was a problem hiding this comment.
🎯 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.tsRepository: 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/runtimeRepository: 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-rulesRepository: 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.
| return showsHoldAnchor(waitText.toLowerCase()) ? 'starting' : null | |
| return showsHoldAnchor( | |
| `${waitText}\n${screenLines?.join('\n') ?? ''}`.toLowerCase() | |
| ) | |
| ? 'starting' | |
| : null |
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.
|
Live re-check of the two latest fixes at Last check found two problems:
Now:
Not verified:
|
…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.
…/launch-prompt-argv-carry
…/worker-brief-on-launch-line
…/agent-launch-identity-record
…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
…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).
… 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.
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
증상: 사내 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>
…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>
…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>
…#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>
…#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>
…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>
) * 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>
… 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>
… 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>

ELI5
The problem. The host's
agent.launchtyped 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.launchkeeps 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:
agent.launch.What changes for existing callers
agent.launchwith a prompt, terminal agent (the phone and other RPC clients; noorcaCLI command calls it)tui-idle; ZCode, OpenCode and OpenCode2 wait for the composer marker their captured startups prove (main's lane table, #24601)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 plaintui-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 atdispatch_inputand nothing is typedsrc/sharedso 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 writeMeasured 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-startagainst 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
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.startup-line-typed-length.live-shell.test.tspins the zsh case, both normal and early.tui-idlefor 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 thentui-idle.src/main/runtime/launched-agent-foreground.ts). On a local macOS or Linux host it is onepslimited 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 notexecit; 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.launchHostProvesAgentInFront).wrapTerminalBracketedPasteText, moved tosrc/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.src/shared/agent-launch-replay-refusal.ts). Hosts advertiseagent.launch.prompt-carry.v1. One addition exists only for step 7: anexplain_commitlaunch source for telemetry.agent.launchgains 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 ownplacementobject rather than as a top-level field. A promptedagent.launchis a long poll, so the local socket stays open while it waits for the agent.Other changes outside the launch path.
geminiwindow 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.Not covered by this PR.
orca worktree create --promptcallsworktree.create, notagent.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
tui-idleand otherwise reports the prompt as not delivered.docs/reference/ssh-execution-boundary.md); it only declines to type into a terminal Orca cannot vouch for.execit, 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/sha tcsh or nu launch line runs) is found as a member of the terminal's foreground process group.agent.launchis a long poll, so the local socket stays open for that wait. The reason (agent_not_in_foregroundor 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 theagent.launchplugin API, not this fix.dispatch_inputwith 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.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). Nops -tsample 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 notexecits 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
^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.launchcallers were tested on d268089 and re-run on e463aea: phone review notes and rawagent.launchnow 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.tsandworker-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), pluslaunched-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-idleas 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-versionkeeps 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.launchandagent.launchReplayare long polls, asorchestration.workerStartalready is. The local runtime socket closes a connection idle for 30 s, so a launch whose agent exited (now a 60 s wait) answeredruntime_unavailableinstead 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 wholepsrun 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 namedsh(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
inspectProcesscapture and then a fresh scan, each a whole-machineps, 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 onepslimited to that pane's terminal. Measured on this laptop at load 5 on the samelogin-wrapped zsh pane, both reads answeringshell: 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 islogin, 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 underloginas a macOS pane is, the daemon's own foreground tracker, a stub that crashes and one that stays up) andlaunched-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.tsand now lives incodex.json'sprovisional_startuphold anchor, so the launch readiness hold asksshowsHoldAnchor, 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, sogemini.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.tsfailed without this and passes with it. (3) Main's readiness census needs a recorder for every runtime fixture, and this PR'szsh-prompt-runs-commandrecording now has one as a non-agent control. Gemini's synthetic census baseline is regenerated for this PR's stated change. Only the 24title=name-onlyrows change: a baregeminititle 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 everytui-idle,terminal-waitand rule-engine test.At 2847488: removed the unshipped top-level
viewModerequest field and the trusted local caller id (aa5db2d).agent.launchwill become a public plugin API, so a top-levelviewModewould read as "choose terminal or chat", which the host decides. Nothing in this PR used either one (no desktop code callsagent.launchuntil step 7). Step 6 adds placement and view intent under aplacementobject. Then merged main (7577366). Its newstructured-agent-session-admission.test.tsimportedAGENT_LAUNCH_RUNTIME_CAPABILITYfromprotocol-version, which this PR moved, so that import now points atagent-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, leavingELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIESimported fromprotocol-version(fix(build): import the Electron remote capability list from its own module #24494 fixes it), and main'sstructured-agent-session-held-signal.test.tsis missing the requiredlogger.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.tskeeps 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'sprotocol-version.tssat at the 300-line cap, so the fouragent.launchcapabilities move tosrc/shared/agent-launch-runtime-capability.ts, andprotocol-version.tsspreads 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 viaexport *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 andwin32-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.tspass withSHELLset 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
psrows and the desktop's paste frame.Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)