Merge remote-tracking branch 'origin/main' into brennanb2025/resolve-11446-i18n-ctx-a963

# Conflicts:
#	mobile/src/session/use-mobile-native-chat-message-send.ts
#	src/main/daemon/daemon-pty-router.ts
This commit is contained in:
Brennan Benson
2026-07-30 11:41:11 -07:00
218 changed files with 13189 additions and 1554 deletions
+20 -8
View File
@@ -3087,15 +3087,16 @@
"providers": ["paired-runtime", "ssh"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["paired-runtime"],
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
"coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, ordinary-park five client xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore 5,000 rows on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, mixed-version paired hosts, live SSH, and production-scale paired hosts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8652",
"https://github.com/stablyai/orca/pull/10625"
],
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
"invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Hosts without the capability must retain the existing limit/TTL force-parking fallback. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence must first probe authoritative state and replace only if that probe times out. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.",
"oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
"ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_DISABLE_PAIRED_TERMINAL_PARKING=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
@@ -3124,6 +3125,7 @@
"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
"src/renderer/src/runtime/runtime-client-events.test.ts",
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"src/shared/remote-runtime-shared-control-connection.test.ts",
"tests/e2e/paired-remote-terminal-truncated-tail-first-paint.spec.ts",
"tests/e2e/paired-remote-terminal-stall-recovery.spec.ts",
"tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts",
@@ -3152,7 +3154,8 @@
{
"file": "tests/e2e/paired-remote-terminal-retention-memory.spec.ts",
"assertions": [
"ordinary-parks paired terminals and restores authoritative host scrollback"
"ordinary-parks paired terminals and restores authoritative host scrollback",
"client recovery completes while every target terminal remains unmounted in the host renderer"
]
},
{
@@ -3276,7 +3279,7 @@
"command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
"result": "passed",
"durationSeconds": 14.5,
"summary": "Six real paired worktrees and host PTYs ordinary-parked five client xterms with the lossy budget disabled, released at least 55% of staged cells within heap and timer-lag budgets, then restored requested row 4,000, parked-time output, and live I/O on the original PTY."
"summary": "Six real paired worktrees and host PTYs ordinary-parked five client xterms with the lossy budget disabled, released at least 55% of staged cells within heap and timer-lag budgets, then restored requested row 4,000, parked-time output, and live I/O on the original PTY while every target terminal remained unmounted in the host renderer."
},
{
"date": "2026-07-29",
@@ -3304,6 +3307,15 @@
"result": "passed",
"durationSeconds": 18.6,
"summary": "A host without terminal.paired-parking.v1 preserved the existing lossy limit-one fallback, restored retained output, and continued PTY input/output."
},
{
"date": "2026-07-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts",
"result": "passed",
"durationSeconds": 3.16,
"summary": "All 27 encrypted shared-control tests passed. The new oracle was red on rc.1-equivalent behavior with the exact Refreshing remote runtime control transport error, then passed with one replacement connection, one delivered host RPC, and zero retained pending bytes."
}
],
"runtimeBudget": {
@@ -4301,7 +4313,7 @@
"providers": ["ssh", "remote-runtime", "wsl"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["ssh", "remote-runtime"],
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.",
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
@@ -4309,8 +4321,8 @@
"https://github.com/stablyai/orca/pull/7009",
"https://github.com/stablyai/orca/pull/8597"
],
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.",
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.",
"invariant": "SSH, WSL, and remote-runtime restore paths must treat provider listing failures and unknown liveness as unknown, not dead, while still avoiding duplicate spawn and clearing expired relay leases exactly once. Direct SSH reconnect must atomically clear only exact-target live PTY bindings, preserve relay identity, retry Git and folder panes without paired close or provider shutdown, and allow at most two automatic attempts in one authority chain even when each settlement exceeds the rolling window. A rejected acknowledgement mutates no store map. A successful exact split-pane spawn or reattach must retain that attempt as shared live authority until sibling leaves settle; the first success cannot consume sibling authority, a sibling failure can start at most one second tab-wide attempt, and prior-attempt callbacks become inert after rotation. Once the retry budget is exhausted, a failure cannot start attempt three or revoke attempt-two authority from siblings that may still settle. Primary PTY exit must promote a bound survivor or preserve exact authority through an empty activation gap, and split detach must project that authority to both resulting tabs. Hydrated PTY hints cannot supersede a current exact-attempt owner, and target snapshot hydration/reconnect cannot reset sibling SSH, local, WSL, or runtime-owned state. Every restored remote terminal must preserve its provider PTY identity, including the authoritative incarnation returned by a successful session-ID reattach. After a recoverable partition the same authenticated runtime must reattach the same PTY, reject detached input, apply the latest viewport, and report healthy only after authoritative replay. A successful one-shot reachability probe may replace a pre-ready shared-control socket, but waiting RPCs must continue onto the replacement under their original deadline without duplicate host delivery or retained request bytes. Automatic PTY recovery stops after one bounded minute without a fatal terminal error; a manual reconnect starts a newly fenced epoch against the same PTY, and closed panes retain no recovery UI state. One capability-gated terminal-create mutation must produce at most one host PTY across an unknown response outcome, remain manually retryable after cutoff, and never let a stale completion replace a newer pane lifecycle.",
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
+2
View File
@@ -12,6 +12,8 @@
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/ipc/local-agent-install-dir-detection.ts",
"../src/main/ipc/tui-agent-detection-commands.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
+63
View File
@@ -729,6 +729,69 @@ deliberately. The post-upgrade binary and version record are retained in
artifacts and remove them according to your retention policy after the rollback
is resolved.
## Installing Agent Skills Without A Desktop
Orca's agent skills (CLI usage, orchestration, computer use, etc.) are normally
installed from Orca Settings, which pre-fills an `npx skills add ... --global`
command in a terminal for you to run. A headless host has no Settings UI, so
use `orca skills install` instead:
```bash
orca skills install # list installable skills
orca skills install --skill orca-cli --skill orchestration # install globally (default)
orca skills install --skill orca-cli --local # install into the current project only
orca skills install --all # install every bundled skill
orca skills install --all --dry-run # print the npx command without running it
```
This resolves the same `npx skills add <repo> --skill <name> ...` command
Settings would show you (adding `--global` unless `--local` is passed), then
runs it and forwards its output and exit code. It requires `node`/`npx` on the
host; it does not need a running Orca runtime.
Unlike the command Settings shows, the spawned one adds `npx --yes` and `-y`.
Without them the `skills` CLI opens an interactive agent picker and blocks
forever on any allocated TTY — which includes a normal `ssh` session. Use
`--dry-run` to see the exact command that will run.
Settings keeps that picker deliberately, because choosing which agents get a
skill is a real decision. A headless run cannot answer it, so instead of dropping
the choice Orca makes it explicitly: it passes an `--agent` list built from the
coding agents it detects on the host, plus the shared `.agents/skills` directory
it reads itself. Left to decide on its own with no agent detected, the `skills`
CLI installs into all ~75 agents it knows and leaves a config directory for each.
Override the targets yourself, or narrow to the shared directory alone:
```bash
orca skills install --skill orca-cli --agent claude-code,codex
orca skills install --skill orca-cli --agent universal
```
If Orca detects no agent at all, `orca skills install` stops and asks for
`--agent` rather than guessing.
To refresh already-installed skills, `orca skills update` mirrors the same
selection flags (`--skill`, `--all`, `--local`, `--dry-run`) and resolves to
`npx skills update <names...>` with a matching scope flag — `--global`, or
`--project` when you pass `--local`:
```bash
orca skills update --all # update every bundled skill globally
orca skills update --skill orca-cli --dry-run # print the npx command without running it
```
`orca skills update` only refreshes skills that are already installed — it exits
0 without doing anything for a skill that is missing, so install it first. More
generally, a 0 exit means the `skills` CLI ran without erroring, not that it
wrote anything; read its output to confirm what changed.
`--json` covers the skill listing and `--dry-run`. A real run streams the
`skills` CLI's own non-JSON output and rejects `--json`.
Both commands install onto the machine that runs them. In an Orca SSH workspace
or the WSL bridge the `orca` shim forwards commands to the Orca host, so they
refuse to run there and print the command to run on the machine you want.
## Troubleshooting
- `dlopen(): error loading libfuse.so.2`: install `libfuse2`.
@@ -3682,6 +3682,7 @@ export default function SessionScreen() {
getActiveWorktreeConnectionId,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft,
showToast,
onNativeChatSendError: nativeChatSendError.show,
onSuccess: triggerSelection,
@@ -28,6 +28,7 @@ export type MobileSessionTab =
launchAgent?: TuiAgent
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
@@ -27,6 +27,7 @@ export type MobileNativeChatTab = {
agentStatus?: AgentStatusEntry | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
}
/** Resolve a session tab to the transcript identity native chat needs, or
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
function sendResult(accepted: boolean, id = 'send'): RpcSuccess {
return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
@@ -101,3 +102,52 @@ describe('pasteMobileNativeChatImagePaths', () => {
}
})
})
describe('clearing a parked multi-line launch draft before the image paste', () => {
it('leads with the caller-sized burst instead of one Ctrl+U', async () => {
// One Ctrl+U kills only the LAST line, so the draft's earlier lines would
// survive and ride along with the image as part of the prompt body.
const client = clientWithResponses([sendResult(true), sendResult(true)])
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
await pasteMobileNativeChatImagePaths({
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png'],
clearInput
})
expect(client.calls[0]?.params.text).toBe(clearInput)
expect(client.calls[0]?.params.text).not.toBe('\x15')
})
it('clears once, before the paste — never between or after the image writes', async () => {
const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
const clearInput = buildAgentTuiClearInputForText('a\nb\nc')
await pasteMobileNativeChatImagePaths({
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
clearInput
})
expect(client.calls.filter((call) => call.params.text === clearInput)).toHaveLength(1)
expect(client.calls[0]?.params.text).toBe(clearInput)
})
it('falls back to a single Ctrl+U when no draft is parked', async () => {
const client = clientWithResponses([sendResult(true), sendResult(true)])
await pasteMobileNativeChatImagePaths({
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png']
})
expect(client.calls[0]?.params.text).toBe('\x15')
})
})
@@ -26,6 +26,10 @@ type PasteImagesArgs = {
/** Budget shared with the rest of the user action (the text body that follows, or
* the send this is healing for). Omit to open a fresh one for this paste alone. */
readonly deadline?: number
/** Bytes for the leading clear. Defaults to a single Ctrl+U, which clears only
* ONE logical line — callers holding a parked multi-line launch draft must
* pass a burst, or its earlier lines survive and glue onto the message. */
readonly clearInput?: string
}
/** Clears the agent's unsubmitted input line, then pastes each uploaded image
@@ -38,7 +42,8 @@ export async function pasteMobileNativeChatImagePaths({
terminal,
deviceToken,
imagePaths,
deadline: sharedDeadline
deadline: sharedDeadline,
clearInput
}: PasteImagesArgs): Promise<boolean> {
const mobileClient: MobileTerminalClient | null = deviceToken
? { id: deviceToken, type: 'mobile' }
@@ -49,7 +54,7 @@ export async function pasteMobileNativeChatImagePaths({
// once and let each write draw from what's left.
const deadline = sharedDeadline ?? openMobileNativeChatSendBudget()
for (const text of [
MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
...imagePaths.map(buildMobileImagePastePayload)
]) {
const remainingMs = deadline - Date.now()
@@ -5,9 +5,11 @@ import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-clien
import {
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS,
openMobileNativeChatSendBudget,
clearMobileNativeChatInput,
sendMobileNativeChatMessage,
sendMobileNativeChatMessageWithOutcome
} from './mobile-native-chat-send'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
function clientWithResponse(response: unknown): RpcClient {
return {
@@ -29,6 +31,7 @@ describe('sendMobileNativeChatMessage', () => {
client,
terminal: 'term',
text: 'hello',
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
mobileClient: { id: 'device', type: 'mobile' }
})
).resolves.toBe(true)
@@ -38,6 +41,7 @@ describe('sendMobileNativeChatMessage', () => {
terminal: 'term',
text: 'hello',
enter: true,
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
client: { id: 'device', type: 'mobile' }
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
@@ -285,3 +289,83 @@ describe('sendMobileNativeChatMessage', () => {
expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS)
})
})
describe('clearMobileNativeChatInput', () => {
const accepted = {
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
}
const params = (client: RpcClient) =>
vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string; enter: boolean }
it('writes the burst as its OWN non-submitting write', async () => {
// Bundling the burst into the body write reached the agent as LITERAL Ctrl+U
// text and the parked draft concatenated (observed live).
const client = clientWithResponse(accepted)
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
await expect(
clearMobileNativeChatInput({ client, terminal: 'term', clearInput })
).resolves.toBe(true)
expect(params(client)).toMatchObject({ text: clearInput, enter: false })
})
it('reports failure when the host rejects the clear', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: false } },
_meta: { runtimeId: 'runtime' }
})
await expect(
clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' })
).resolves.toBe(false)
})
it('refuses to start an underfunded clear rather than half-clearing', async () => {
const client = clientWithResponse(accepted)
await expect(
clearMobileNativeChatInput({
client,
terminal: 'term',
clearInput: '\x15',
deadline: Date.now() + 10
})
).resolves.toBe(false)
expect(client.sendRequest).not.toHaveBeenCalled()
})
})
describe('the body write never carries a multi-line burst', () => {
const accepted = {
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
}
const sentText = (client: RpcClient): string =>
(vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string }).text
it('still prefixes only a single Ctrl+U when asked to clear first', async () => {
const client = clientWithResponse(accepted)
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
clearInputFirst: true
})
expect(sentText(client)).toBe('\x15hello')
})
it('never prefixes a clear when the caller already pasted (image sends)', async () => {
const client = clientWithResponse(accepted)
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'caption',
clearInputFirst: false
})
expect(sentText(client)).toBe('caption')
})
})
@@ -11,6 +11,11 @@ type MobileTerminalClient = {
// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the
// same byte before its body), so a launch-context prefill parked there cannot
// concatenate with a mobile chat message. The host writes text bytes verbatim.
//
// One Ctrl+U clears ONE logical line, which is all this prefix can do. A parked
// launch draft is routinely multi-line (every Linear block is); callers that know
// one is parked must call clearMobileNativeChatInput FIRST — see
// src/shared/agent-tui-input-clear.ts for the measured 2N-1 law.
const CLEAR_UNSUBMITTED_INPUT = '\x15'
type MobileNativeChatSendArgs = {
@@ -19,6 +24,8 @@ type MobileNativeChatSendArgs = {
text: string
enter?: boolean
clearInputFirst?: boolean
/** Exact host launch draft this submitting write resolves when accepted. */
resolvedLaunchDraft?: { text: string; createdAt: number }
mobileClient?: MobileTerminalClient
/** Shared budget for a whole user action (heal → paste → text, or one selector's
* keystroke sequence). Omit to give this write its own full budget. */
@@ -59,6 +66,7 @@ export async function sendMobileNativeChatMessageWithOutcome(
terminal: args.terminal,
text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text,
enter: args.enter ?? true,
...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}),
...(args.mobileClient ? { client: args.mobileClient } : {})
},
// The budget covers this whole write, reconnect wait included — a chat send
@@ -84,3 +92,42 @@ export async function sendMobileNativeChatMessage(
): Promise<boolean> {
return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted'
}
/**
* Clear the agent's input line as its OWN write, before any body.
*
* Why not prefix it onto the body write: a multi-line clear burst bundled into
* the same `terminal.send` as the text reached the agent as LITERAL Ctrl+U
* characters — the draft survived and the burst landed in the middle of the
* message (observed live: draft + 21 literal \x15 + body). A standalone write is
* the shape the image paste has always used, and it clears as intended.
*/
export async function clearMobileNativeChatInput(args: {
client: RpcClient
terminal: string
clearInput: string
mobileClient?: MobileTerminalClient
deadline?: number
}): Promise<boolean> {
const timeoutMs =
args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now()
if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
return false
}
try {
const response = await args.client.sendRequest(
'terminal.send',
{
terminal: args.terminal,
text: args.clearInput,
enter: false,
...(args.mobileClient ? { client: args.mobileClient } : {})
},
{ timeoutMs, budgetSpansConnect: true }
)
return isTerminalSendRpcAccepted(response)
} catch {
// A failed clear must not send the body on top of an uncleared line.
return false
}
}
@@ -102,12 +102,14 @@ describe('mobile terminal records', () => {
}
const seeded: MobileTerminalSessionTab = {
...base,
launchDraft: 'https://github.com/o/r/issues/12'
launchDraft: 'https://github.com/o/r/issues/12',
launchDraftCreatedAt: 1
}
expect(mobileSessionTabsEqual([base], [seeded])).toBe(false)
expect(mobileSessionTabsEqual([seeded], [base])).toBe(false)
expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true)
expect(mobileSessionTabsEqual([seeded], [{ ...seeded, launchDraftCreatedAt: 2 }])).toBe(false)
})
it('treats terminal agent-status changes as session-tab changes', () => {
@@ -20,6 +20,7 @@ export type MobileTerminalSessionTab = {
agentStatus?: AgentStatusEntry | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
@@ -90,6 +91,7 @@ function mobileSessionTabEqual(
// A frame whose only delta is the launch draft appearing or retracting
// still has to reach the chat composer.
a.launchDraft === b.launchDraft &&
a.launchDraftCreatedAt === b.launchDraftCreatedAt &&
JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) &&
JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null)
)
@@ -35,6 +35,8 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
setComposerText: vi.fn(),
pending: [],
captureSendOrigin,
readSeededLaunchDraft: () => null,
readSeededLaunchDraftSeed: () => null,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -327,6 +329,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
terminal: 'term-1',
launchAgent: 'claude',
launchDraft: 'https://github.com/o/r/issues/12',
launchDraftCreatedAt: 7,
isActive: true
}
@@ -385,6 +388,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
expect(draftsArgs.at(-1)).toMatchObject({
tabId: 'tab-1',
launchDraft: 'https://github.com/o/r/issues/12',
launchDraftCreatedAt: 7,
chatActive: true,
transcriptLoading: false
})
@@ -73,6 +73,9 @@ export type MobileNativeChatController = {
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text still parked on the agent's TUI input line, or null.
* Image sends read it to size their leading clear (one Ctrl+U per line). */
readSeededLaunchDraft: () => string | null
}
/** Owns mobile native-chat state and teardown outside the already dense session
@@ -134,6 +137,8 @@ export function useMobileNativeChatController(args: {
setComposerText: setChatComposerText,
pending: chatPending,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -145,6 +150,7 @@ export function useMobileNativeChatController(args: {
sessionId: activeChatSessionId,
messages: nativeChatSession.messages,
launchDraft: activeSessionTab?.launchDraft ?? null,
launchDraftCreatedAt: activeSessionTab?.launchDraftCreatedAt ?? null,
// Why: pass the raw draft plus this flag rather than nulling it off-chat —
// a null is indistinguishable from a host retraction, and peeking at the
// terminal view would permanently decline the prefill.
@@ -243,6 +249,7 @@ export function useMobileNativeChatController(args: {
handleRef: activeHandleRef,
deviceTokenRef,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -278,6 +285,7 @@ export function useMobileNativeChatController(args: {
loadNativeChatFiles,
handleNativeChatQuestionAnswer,
handleNativeChatSend,
handleNativeChatSendWithOutcome
handleNativeChatSendWithOutcome,
readSeededLaunchDraft
}
}
@@ -38,6 +38,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
sessionId = `session-${tabId}`,
messages = [],
launchDraft = null,
launchDraftCreatedAt = null,
chatActive = true,
transcriptLoading = false
}: {
@@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
sessionId?: string | null
messages?: NativeChatMessage[]
launchDraft?: string | null
launchDraftCreatedAt?: number | null
chatActive?: boolean
transcriptLoading?: boolean
}): null {
@@ -55,6 +57,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
sessionId,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading
})
@@ -97,6 +100,21 @@ describe('useMobileNativeChatDrafts launch draft', () => {
expect(state?.composerText).toBe('')
})
it('captures the generation paired with the adopted text', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
launchDraft: 'issue link',
launchDraftCreatedAt: 7
})
)
)
expect(state?.readSeededLaunchDraftSeed()).toEqual({ text: 'issue link', createdAt: 7 })
})
it('does not overwrite typed composer text with a launch draft', async () => {
await mount('a')
act(() => state?.setComposerText('typed first'))
@@ -8,6 +8,8 @@ import {
type UnconfirmedSend
} from './mobile-native-chat-draft-reconcile'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
export type MobileNativeChatPendingMessage = {
id: string
@@ -43,6 +45,7 @@ export function useMobileNativeChatDrafts(args: {
messages: readonly NativeChatMessage[]
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string | null
launchDraftCreatedAt?: number | null
/** Whether the tab is currently resolved to the chat view. Off-chat the
* launch-draft effects hold their state instead of acting on it. */
chatActive?: boolean
@@ -55,6 +58,11 @@ export function useMobileNativeChatDrafts(args: {
setComposerText: Dispatch<SetStateAction<string>>
pending: MobileNativeChatPendingMessage[]
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
/** Launch-context text still believed to be parked on the agent's TUI input
* line, or null once it has been declined or retired. Send paths size their
* pre-clear from it, since one Ctrl+U clears only one logical line. */
readSeededLaunchDraft: () => string | null
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
/** Clear the composer at send time, before the RPC settles. */
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
/** Put the text back after a definite rejection, unless newer edits exist. */
@@ -73,6 +81,7 @@ export function useMobileNativeChatDrafts(args: {
sessionId,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive = true,
transcriptLoading
} = args
@@ -91,62 +100,15 @@ export function useMobileNativeChatDrafts(args: {
activePendingKeyRef.current = pendingKey
const mountedRef = useRef(false)
// Seeded launch-context text per tab; '' marks a permanent decline so a
// cleared composer never resurrects the prefill.
const seededLaunchDraftByKeyRef = useRef(new Map<string, string>())
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
// adopt it once as the composer draft so mobile shows the same context.
useEffect(() => {
if (
!draftKey ||
!chatActive ||
!launchDraft?.trim() ||
seededLaunchDraftByKeyRef.current.has(draftKey)
) {
return
}
// Why: `session.tabs` carries launchDraft before the transcript read settles,
// and an empty (or previous tab's) list would let the decline below misjudge
// an already-submitted prefill — long enough for a send to duplicate it.
if (transcriptLoading) {
return
}
// A user turn already in the transcript means the one-line TUI prefill was
// submitted or deliberately cleared; decline instead of resurrecting it.
if (messages.some((message) => normalizedUserText(message) !== null)) {
seededLaunchDraftByKeyRef.current.set(draftKey, '')
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft)
setDrafts((previous) =>
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
)
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
// it (desktop sent or reconciled it). User edits are always kept.
useEffect(() => {
// Same gates as the seed: off-chat there is no retraction to read (the tab
// publishes no draft to us), and an untrusted transcript would wipe an
// untouched copy on the strength of another tab's user turns.
if (!draftKey || !chatActive || transcriptLoading) {
return
}
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
if (!seeded) {
return
}
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
if (!hasUserTurn && launchDraft?.trim()) {
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, '')
setDrafts((previous) =>
(previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous
)
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({
draftKey,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading,
setDrafts
})
const setComposerText: Dispatch<SetStateAction<string>> = useCallback(
(value) => {
@@ -358,6 +320,8 @@ export function useMobileNativeChatDrafts(args: {
setComposerText,
pending,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -1,5 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
@@ -69,6 +70,7 @@ function baseArgs(overrides: Partial<HookArgs> & Pick<HookArgs, 'client'>): Hook
showToast: vi.fn(),
onSendError: vi.fn(),
baseSend: vi.fn().mockResolvedValue('accepted'),
readSeededLaunchDraft: () => null,
sleep: async () => {},
...overrides
}
@@ -201,6 +203,40 @@ describe('useMobileNativeChatImageAttachments', () => {
expect(hook!.attachments).toEqual([])
})
it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => {
// A single Ctrl+U kills only the last line, so the draft's earlier lines
// would survive the clear and ride along with the image as prompt body.
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
sendResult(true),
sendResult(true)
])
const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
mount(
baseArgs({
client: client as unknown as RpcClient,
deviceTokenRef: { current: 'device-1' },
readSeededLaunchDraft: () => draft
})
)
await act(async () => {
await hook!.attachImage('library')
})
await act(async () => {
await hook!.sendNativeChat('look at this')
})
const firstSend = client.calls.find((c) => c.method === 'terminal.send')
expect(firstSend?.params).toMatchObject({
text: buildAgentTuiClearInputForText(draft),
enter: false
})
expect(firstSend?.params.text).not.toBe('\x15')
})
it('spends one budget across the image paste and the text body that follows', async () => {
vi.useFakeTimers()
try {
@@ -1,5 +1,6 @@
import { useCallback, useRef, useState } from 'react'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
@@ -57,6 +58,10 @@ type Args = {
imagePreviewUris?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text parked on the agent's TUI input line, or null. The
* paste's leading clear must cover every line of it, or the draft's earlier
* lines survive and ride along with the image. */
readonly readSeededLaunchDraft: () => string | null
readonly onAttachSuccess?: () => void
readonly onError?: () => void
// Injected so the settle between image paste and submit is instant in tests.
@@ -107,6 +112,7 @@ export function useMobileNativeChatImageAttachments({
showToast,
onSendError,
baseSend,
readSeededLaunchDraft,
onAttachSuccess,
onError,
sleep = defaultSleep
@@ -270,12 +276,16 @@ export function useMobileNativeChatImageAttachments({
return false
}
try {
const seededLaunchDraft = readSeededLaunchDraft()
const pasted = await pasteMobileNativeChatImagePaths({
client,
terminal: handle,
deviceToken: deviceTokenRef.current,
imagePaths: pendingImages.map((attachment) => attachment.path),
deadline
deadline,
...(seededLaunchDraft
? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) }
: {})
})
if (!pasted) {
// Keep the chips so the user can retry; the failed paste never submitted.
@@ -352,6 +362,7 @@ export function useMobileNativeChatImageAttachments({
enabled,
onError,
onSendError,
readSeededLaunchDraft,
scopeKey,
sleep
]
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { normalizedUserText } from './mobile-native-chat-draft-reconcile'
export type MobileNativeChatLaunchDraftSeed = {
text: string
createdAt: number | null
}
/**
* Adopting the host's launch-context prefill as the mobile composer draft, and
* retiring it again once it is resolved elsewhere. Split out of the drafts hook
* so the general draft/pending accounting stays separate from this one concern.
*/
export function useMobileNativeChatLaunchDraftSeed(args: {
draftKey: string | null
messages: readonly NativeChatMessage[]
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string | null
launchDraftCreatedAt?: number | null
chatActive: boolean
transcriptLoading?: boolean
setDrafts: Dispatch<SetStateAction<Record<string, string>>>
}): {
/** Text still believed to be parked on the agent's TUI input line, or null
* once declined or retired. Send paths size their pre-clear from it, since
* one Ctrl+U clears only one logical line. */
readSeededLaunchDraft: () => string | null
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
} {
const {
draftKey,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading,
setDrafts
} = args
// Seeded launch-context text per tab; null marks a permanent decline so a
// cleared composer never resurrects the prefill.
const seededLaunchDraftByKeyRef = useRef(
new Map<string, MobileNativeChatLaunchDraftSeed | null>()
)
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
// adopt it once as the composer draft so mobile shows the same context.
useEffect(() => {
if (
!draftKey ||
!chatActive ||
!launchDraft?.trim() ||
seededLaunchDraftByKeyRef.current.has(draftKey)
) {
return
}
// Why: `session.tabs` carries launchDraft before the transcript read settles,
// and an empty (or previous tab's) list would let the decline below misjudge
// an already-submitted prefill — long enough for a send to duplicate it.
if (transcriptLoading) {
return
}
// A user turn already in the transcript means the TUI prefill was submitted
// or deliberately cleared; decline instead of resurrecting it.
if (messages.some((message) => normalizedUserText(message) !== null)) {
seededLaunchDraftByKeyRef.current.set(draftKey, null)
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, {
text: launchDraft,
createdAt: launchDraftCreatedAt ?? null
})
setDrafts((previous) =>
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
)
}, [
chatActive,
draftKey,
launchDraft,
launchDraftCreatedAt,
messages,
setDrafts,
transcriptLoading
])
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
// it (desktop sent or reconciled it). User edits are always kept.
useEffect(() => {
// Same gates as the seed: off-chat there is no retraction to read (the tab
// publishes no draft to us), and an untrusted transcript would wipe an
// untouched copy on the strength of another tab's user turns.
if (!draftKey || !chatActive || transcriptLoading) {
return
}
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
if (!seeded) {
return
}
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
if (!hasUserTurn && launchDraft?.trim()) {
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, null)
setDrafts((previous) =>
(previous[draftKey] ?? '') === seeded.text ? { ...previous, [draftKey]: '' } : previous
)
}, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading])
// A missing or declined entry means there is nothing of ours on the TUI line.
const readSeededLaunchDraft = useCallback(
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey)?.text ?? null) : null),
[draftKey]
)
const readSeededLaunchDraftSeed = useCallback(
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey) ?? null) : null),
[draftKey]
)
return { readSeededLaunchDraft, readSeededLaunchDraftSeed }
}
@@ -0,0 +1,176 @@
// Covers the wiring the image-attachments suite structurally cannot: that hook
// injects its own baseSend stub, so it never observes the real send params.
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const sendWithOutcome = vi.fn()
const clearInputWrite = vi.fn()
vi.mock('./mobile-native-chat-send', () => ({
sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args),
clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args),
openMobileNativeChatSendBudget: () => Date.now() + 15_000,
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000,
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS: 2_000
}))
vi.mock('./mobile-native-chat-stale-input', () => ({
healMobileNativeChatStaleInput: () => Promise.resolve(true)
}))
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
type Send = ReturnType<typeof useMobileNativeChatMessageSend>
const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
describe('useMobileNativeChatMessageSend', () => {
let renderer: ReactTestRenderer | null = null
let api: Send | null = null
const mount = (
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null
): void => {
function Probe(): null {
api = useMobileNativeChatMessageSend({
client: { sendRequest: vi.fn() } as never,
enabled: true,
handleRef: { current: 'term' },
deviceTokenRef: { current: 'device' },
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
readSeededLaunchDraftSeed,
clearDraftForSend: () => {},
restoreRejectedDraft: () => {},
acceptSend: () => {},
holdUnconfirmedSend: () => {},
onSendError: () => {}
})
return null
}
act(() => {
renderer = create(createElement(Probe))
})
}
const sentArgs = (): {
clearInputFirst?: boolean
resolvedLaunchDraft?: { text: string; createdAt: number }
} =>
sendWithOutcome.mock.calls[0]![0] as {
clearInputFirst?: boolean
resolvedLaunchDraft?: { text: string; createdAt: number }
}
const clearArgs = (): { clearInput?: string } =>
(clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string }
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
sendWithOutcome.mockReset()
sendWithOutcome.mockResolvedValue('accepted')
clearInputWrite.mockReset()
clearInputWrite.mockResolvedValue(true)
})
afterEach(() => {
act(() => {
renderer?.unmount()
})
renderer = null
api = null
})
it('sizes the pre-clear to every line of a parked launch draft', async () => {
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('hello')
})
expect(clearArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT))
})
it('issues the burst as its OWN write, before the body', async () => {
// Bundled into the body write it arrived as literal Ctrl+U text.
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('hello')
})
expect(clearInputWrite).toHaveBeenCalledTimes(1)
expect(sendWithOutcome).toHaveBeenCalledTimes(1)
expect(clearInputWrite.mock.invocationCallOrder[0]).toBeLessThan(
sendWithOutcome.mock.invocationCallOrder[0]!
)
})
it('aborts without sending the body when the clear is rejected', async () => {
// Sending on top of an uncleared line is exactly the concatenation bug.
clearInputWrite.mockResolvedValue(false)
mount(() => ({ text: DRAFT, createdAt: 1 }))
let result: boolean | undefined
await act(async () => {
result = await api!.send('hello')
})
expect(result).toBe(false)
expect(sendWithOutcome).not.toHaveBeenCalled()
})
it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => {
// A Ctrl+U written immediately before body text in the SAME write arrives as
// a literal control character, so it would head the received message.
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('hello')
})
expect(sentArgs().clearInputFirst).toBe(false)
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
})
it('keeps the single-Ctrl+U prefix when no dedicated clear ran', async () => {
mount(() => null)
await act(async () => {
await api!.send('hello')
})
expect(sentArgs().clearInputFirst).toBe(true)
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
it('writes no clear at all when nothing is parked on the line', async () => {
mount(() => null)
await act(async () => {
await api!.send('hello')
})
expect(clearInputWrite).not.toHaveBeenCalled()
})
it('reads the draft at send time, so a retired seed stops widening the clear', async () => {
let parked: { text: string; createdAt: number } | null = { text: DRAFT, createdAt: 1 }
mount(() => parked)
await act(async () => {
await api!.send('first')
})
parked = null
await act(async () => {
await api!.send('second')
})
expect(sendWithOutcome.mock.calls[1]![0]).toMatchObject({ clearInputFirst: true })
expect(clearInputWrite).toHaveBeenCalledTimes(1)
expect(sendWithOutcome.mock.calls[0]![0]).toMatchObject({ clearInputFirst: false })
})
it('does not clear an image send after the image was pasted', async () => {
// A second clear here would wipe the image that was just pasted.
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('caption', ['file:///a.png'])
})
expect(clearInputWrite).not.toHaveBeenCalled()
expect(sentArgs().clearInputFirst).toBe(false)
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
})
it('does not resolve a composer seed from a question-card answer', async () => {
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.answerQuestion('1')
})
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
})
@@ -1,6 +1,7 @@
import { useCallback, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import {
clearMobileNativeChatInput,
openMobileNativeChatSendBudget,
sendMobileNativeChatMessageWithOutcome,
type MobileNativeChatSendOutcome
@@ -8,6 +9,8 @@ import {
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
import { t } from '@/i18n/mobile-i18n'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
export type MobileNativeChatMessageSend = {
/** Composer send that syncs the draft (clear on send, restore on rejection). */
@@ -33,6 +36,9 @@ export function useMobileNativeChatMessageSend(args: {
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
/** Launch-context text Orca parked on the agent's TUI input line, or null. Read
* at send time so the pre-clear can be sized to every line it occupies. */
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
@@ -49,6 +55,7 @@ export function useMobileNativeChatMessageSend(args: {
handleRef,
deviceTokenRef,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -96,6 +103,36 @@ export function useMobileNativeChatMessageSend(args: {
if (syncComposer) {
clearDraftForSend(origin, text)
}
// Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears
// only one logical line. Size the clear to the text Orca injected, with
// slack — the user can also have typed into the TUI line directly, so that
// line count is a lower bound. Mobile cannot read the agent's screen, so
// there is no empty-line observable to confirm against here; the upper
// bound plus the host's write acceptance is what makes it safe.
//
// The burst goes out as its OWN write: bundled into the body write it
// arrived as literal Ctrl+U text and the draft concatenated (see
// clearMobileNativeChatInput). A rejected clear aborts the send rather
// than pasting on top of an uncleared line.
const seededLaunchDraft = readSeededLaunchDraftSeed()
if (seededLaunchDraft && !images?.length) {
const cleared = await clearMobileNativeChatInput({
client,
terminal: handle,
clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text),
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
if (!cleared) {
if (syncComposer) {
restoreRejectedDraft(origin, text)
}
onSendError(t('m.ZkABdzg'))
return 'rejected'
}
}
const outcome = await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
@@ -106,7 +143,20 @@ export function useMobileNativeChatMessageSend(args: {
// this message. An image send already led its own paste with Ctrl+U, and a
// second one here would wipe the image it just pasted (desktop's image path
// likewise clears once, before the paste, and never again).
clearInputFirst: !images?.length,
//
// Also skipped once the dedicated clear above ran: the line is already
// empty, and a Ctrl+U written immediately before body text in the SAME
// write reaches the agent as a literal control character rather than a
// keypress (observed live as a stray \x15 heading the received message).
clearInputFirst: !images?.length && !seededLaunchDraft,
...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
? {
resolvedLaunchDraft: {
text: seededLaunchDraft.text,
createdAt: seededLaunchDraft.createdAt
}
}
: {}),
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
@@ -140,6 +190,7 @@ export function useMobileNativeChatMessageSend(args: {
handleRef,
holdUnconfirmedSend,
onSendError,
readSeededLaunchDraftSeed,
restoreRejectedDraft
]
)
@@ -31,6 +31,9 @@ type Args = {
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text parked on the agent's TUI input line, or null — sizes
* the image paste's leading clear so a multi-line draft cannot ride along. */
readonly readSeededLaunchDraft: () => string | null
readonly showToast: (message: string, durationMs?: number) => void
/** Native-chat send failures — rendered in the composer's inline banner. */
readonly onNativeChatSendError: (message: string) => void
@@ -54,6 +57,7 @@ export function useMobileSessionImageAttachments({
getActiveWorktreeConnectionId,
beforeTerminalSend,
nativeChatBaseSend,
readSeededLaunchDraft,
showToast,
onNativeChatSendError,
onSuccess,
@@ -86,6 +90,7 @@ export function useMobileSessionImageAttachments({
showToast,
onSendError: onNativeChatSendError,
baseSend: nativeChatBaseSend,
readSeededLaunchDraft,
onAttachSuccess: onSuccess,
onError
})
+2 -1
View File
@@ -30,6 +30,7 @@ export const BOOLEAN_FLAGS = new Set([
'include-archived',
'interrupt',
'json',
'local',
'messages',
'me',
'mobile',
@@ -55,7 +56,7 @@ export const BOOLEAN_FLAGS = new Set([
])
export const REPEATED_FLAG_SEPARATOR = '\u0000'
const REPEATABLE_STRING_FLAGS = new Set(['label'])
const REPEATABLE_STRING_FLAGS = new Set(['label', 'skill'])
function setFlagValue(flags: Map<string, string | boolean>, name: string, value: string): void {
const existing = flags.get(name)
+1 -1
View File
@@ -218,7 +218,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
},
{
name: 'skills',
keys: ['skills list', 'skills get'],
keys: ['skills list', 'skills get', 'skills install', 'skills update'],
load: async () => (await import('./handlers/skills.js')).SKILL_HANDLERS
}
]
@@ -63,6 +63,41 @@ describe('lightweight Run CLI handlers', () => {
})
})
it('passes Run pagination flags to the runtime', async () => {
callMock.mockResolvedValue({
result: { runs: [], nextCursor: null }
})
await ORCHESTRATION_HANDLERS['orchestration run-list']({
flags: new Map([
['limit', '25'],
['cursor', 'next-page']
]),
client: { call: callMock },
json: true
} as never)
expect(callMock).toHaveBeenCalledWith('orchestration.runList', {
limit: 25,
cursor: 'next-page'
})
})
it('opts into bounded Run pagination by default', async () => {
callMock.mockResolvedValue({ result: { runs: [], nextCursor: null } })
await ORCHESTRATION_HANDLERS['orchestration run-list']({
flags: new Map(),
client: { call: callMock },
json: true
} as never)
expect(callMock).toHaveBeenCalledWith('orchestration.runList', {
limit: 100,
cursor: undefined
})
})
it('passes explicit legacy takeover only when requested', async () => {
callMock.mockResolvedValue({
result: { run: { id: 'run_adopted', objective: 'Recovered work' } }
+18 -11
View File
@@ -22,6 +22,7 @@ import type {
import type { NativeChatMessage } from '../../shared/native-chat-types'
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract'
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination'
import {
formatMessageReadOnlyTag,
formatOrchestrationCheckText,
@@ -463,19 +464,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
)
},
'orchestration run-list': async ({ client, json }) => {
'orchestration run-list': async ({ flags, client, json }) => {
const result = await client.call<{
runs: { id: string; objective: string; legacy: number }[]
}>('orchestration.runList', {})
printResult(result, json, (r) =>
r.runs.length === 0
? 'No Runs found.'
: r.runs
.map(
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
)
.join('\n')
)
nextCursor: string | null
}>('orchestration.runList', {
limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT,
cursor: getOptionalStringFlag(flags, 'cursor')
})
printResult(result, json, (r) => {
const rows =
r.runs.length === 0
? 'No Runs found.'
: r.runs
.map(
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
)
.join('\n')
return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows
})
},
'orchestration run-show': async ({ flags, client, json }) => {
+272 -1
View File
@@ -1,5 +1,21 @@
import { spawn } from 'node:child_process'
import type { CommandHandler } from '../dispatch'
import { RuntimeClientError } from '../runtime-client'
import { delimiter, dirname } from 'node:path'
import { getRepeatedStringFlag } from '../flags'
import { resolveCliCommand } from '../../main/codex-cli/command'
import { detectCommandsInInstallDirs } from '../../main/ipc/local-agent-install-dir-detection'
import {
getTuiAgentDetectionProbeCommands,
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
resolveDetectedTuiAgentIds
} from '../../main/ipc/tui-agent-detection-commands'
import { getSpawnArgsForWindows, UnsafeWindowsBatchArgumentsError } from '../../main/win32-utils'
import { isSkillsCliAgentKeyShaped, toSkillsCliAgentKeys } from '../../shared/skills-cli-agent-keys'
import {
buildAgentFeatureSkillInstallArgs,
buildAgentFeatureSkillUpdateArgs
} from '../../shared/agent-feature-install-commands'
type BundledSkillGuide = {
name: string
@@ -46,6 +62,259 @@ function writeStdout(value: string): void {
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
}
function resolveSelectedSkillNames(
flags: Map<string, string | boolean>,
guides: BundledSkillGuide[]
): string[] {
const requestedSkills = getRepeatedStringFlag(flags, 'skill')
const selectAll = flags.get('all') === true
if (flags.has('skill') && requestedSkills.length === 0) {
throw new RuntimeClientError('invalid_argument', 'Missing required --skill')
}
if (selectAll && requestedSkills.length > 0) {
throw new RuntimeClientError('invalid_argument', 'Use either --all or --skill, not both.')
}
if (!selectAll && requestedSkills.length === 0) {
return []
}
if (selectAll) {
return guides.map((guide) => guide.name)
}
const availableTopics = guides.map((guide) => guide.name).join(', ')
const guideByTopic = new Map<string, BundledSkillGuide>(
guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide]))
)
const canonicalNames = new Set<string>()
for (const requested of requestedSkills) {
const guide = guideByTopic.get(requested)
if (!guide) {
throw new RuntimeClientError(
'invalid_argument',
`Unknown skill "${requested}". Available skills: ${availableTopics}`
)
}
canonicalNames.add(guide.name)
}
return [...canonicalNames].sort()
}
/** PATH with the resolved npx's own directory first, so its `env node` shebang resolves. */
function buildNpxPath(resolvedNpx: string): string {
// Why: resolveCliCommand falls back to the bare name when it finds nothing, and
// dirname('npx') is '.', so prepending it blindly would run ./npx out of the
// caller's checkout instead of reporting that npx is missing.
const own = dirname(resolvedNpx)
const existing = process.env.PATH ?? process.env.Path ?? ''
// Why: every version manager ships node beside npx in the same bin directory,
// so the resolved sibling is all the child needs to run npx's shebang.
return [own === '.' ? '' : own, existing].filter(Boolean).join(delimiter)
}
function runNpxSkills(args: string[]): Promise<number> {
return new Promise((resolve, reject) => {
// Why: a bare PATH lookup misses nvm/fnm/volta installs, and hardcoding
// `npx.cmd` both misses `npx.exe` shims and hides a missing npx behind
// cmd.exe's own exit code, so the friendly error below never fires.
const resolved = resolveCliCommand('npx')
let spawnCmd: string
let spawnArgs: string[]
try {
;({ spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolved, args))
} catch (error) {
// Why: the guard rejects cmd metacharacters, and only the resolved npx
// path can carry them here — a username like `A&B` puts them in it.
if (!(error instanceof UnsafeWindowsBatchArgumentsError)) {
reject(error)
return
}
reject(
new RuntimeClientError(
'invalid_environment',
`Cannot run npx from "${resolved}": the path contains characters cmd.exe would ` +
'reinterpret. Install Node.js somewhere without & | < > ^ " % ! in the path.'
)
)
return
}
// Why: npx is an `#!/usr/bin/env node` script, so resolving it off PATH is
// not enough — without node alongside it the child exits 127 with no
// 'error' event and the message below never fires.
const child = spawn(spawnCmd, spawnArgs, {
stdio: 'inherit',
env: { ...process.env, PATH: buildNpxPath(resolved) }
})
// Why: a missing npx/Node on a headless host surfaces as a raw spawn ENOENT;
// wrap it so the CLI reports an actionable message like every other failure here.
child.once('error', (error) => {
const detail = error instanceof Error ? error.message : String(error)
reject(
new RuntimeClientError(
'invalid_environment',
`Could not run npx: ${detail}. Install Node.js and ensure npx is on PATH.`
)
)
})
child.once('exit', (code, signal) => {
resolve(typeof code === 'number' ? code : signal ? 1 : 0)
})
})
}
type SkillMutationVerb = 'install' | 'update'
/** Agents Orca can see on this host, as `skills --agent` keys. */
function detectSkillsCliAgentKeys(): string[] {
const runtime = process.platform
const probes = getTuiAgentDetectionProbeCommands(KNOWN_TUI_AGENT_DETECTION_COMMANDS, runtime)
const detected = resolveDetectedTuiAgentIds(
KNOWN_TUI_AGENT_DETECTION_COMMANDS,
detectCommandsInInstallDirs(probes),
runtime
)
return detected.length === 0 ? [] : toSkillsCliAgentKeys(detected)
}
function resolveInstallAgentKeys(flags: Map<string, string | boolean>): string[] {
const requested = flags.get('agent')
if (flags.has('agent') && typeof requested !== 'string') {
throw new RuntimeClientError('invalid_argument', 'Missing required --agent')
}
if (typeof requested === 'string') {
// Why: one comma-separated value rather than a repeatable flag — `agent` is a
// single-value flag on other commands and the repeatable set is process-wide,
// so making it repeatable here would change how those parse a second --agent.
const keys = [
...new Set(
requested
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
)
]
// Why: a value like "," parses to nothing. Falling through to detection would
// be surprising, and emitting no --agent would restore the all-agents install.
if (keys.length === 0) {
throw new RuntimeClientError('invalid_argument', 'Missing required --agent')
}
const unusable = keys.find((key) => !isSkillsCliAgentKeyShaped(key))
if (unusable !== undefined) {
// Why: the skills CLI drops a value starting with `-`, which leaves it with
// no target and installs into every agent it knows.
throw new RuntimeClientError(
'invalid_argument',
`Invalid --agent value "${unusable}". Pass agent names such as claude-code, ` +
'codex, or universal.'
)
}
return keys
}
const detected = detectSkillsCliAgentKeys()
if (detected.length > 0) {
return detected
}
// Why: without --agent, `skills add -y` falls into its own zero-detected branch
// and installs into every agent it knows (~75), creating config directories for
// agents this host does not have. Say so instead.
throw new RuntimeClientError(
'invalid_environment',
'No coding agent detected on this host, so there is no install target. Pass ' +
'--agent <name>[,<name>...] to choose targets explicitly — --agent universal ' +
'writes only the shared .agents/skills directory that Orca reads.'
)
}
function buildNpxSkillsArgs(
verb: SkillMutationVerb,
skillNames: string[],
global: boolean,
agents: string[]
): string[] {
const skillArgs =
verb === 'install'
? buildAgentFeatureSkillInstallArgs(skillNames, { global, yes: true, agents })
: buildAgentFeatureSkillUpdateArgs(skillNames, { global, yes: true })
// Why: a cold package cache makes bare `npx` prompt before it will fetch
// `skills`, which strands an unattended host just like the picker does.
return ['--yes', ...skillArgs]
}
/** Render the exact argv a real run spawns, so --dry-run can never drift from it. */
function formatNpxCommand(args: string[]): string {
return `npx ${args.join(' ')}`
}
function formatSkillSelectionHelp(verb: SkillMutationVerb, skillNames: string[]): string {
return [
`Choose one or more skills to ${verb}:`,
...skillNames.map((name) => ` ${name}`),
'',
`Usage: orca skills ${verb} --skill <name> [--skill <name> ...]`,
` or: orca skills ${verb} --all`
].join('\n')
}
function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler {
return async ({ flags, json }) => {
// Why: keep the large generated table off the eager handler registry path.
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
const guides = canonicalGuides(BUNDLED_SKILL_GUIDES)
const skillNames = resolveSelectedSkillNames(flags, guides)
if (skillNames.length === 0) {
const names = guides.map((guide) => guide.name)
writeStdout(
json
? JSON.stringify({ availableSkills: names }, null, 2)
: formatSkillSelectionHelp(verb, names)
)
return
}
// Why: this runs before target resolution because the answer belongs to the
// other machine — agents detected here would be the wrong host's, and a host
// that detects none would hide the forwarding problem behind that error.
if (process.env.ORCA_CLI_CWD) {
throw new RuntimeClientError(
'invalid_environment',
`orca skills ${verb} writes to the machine that runs it, but this shell forwards ` +
`orca to the Orca host. Run the same orca skills ${verb} command on the machine ` +
"you want it on, where it can detect that host's agents."
)
}
const global = flags.get('local') !== true
// Why: install scopes its targets; update only refreshes what is already placed.
const agents = verb === 'install' ? resolveInstallAgentKeys(flags) : []
const npxArgs = buildNpxSkillsArgs(verb, skillNames, global, agents)
const command = formatNpxCommand(npxArgs)
const dryRun = flags.get('dry-run') === true
if (dryRun) {
writeStdout(
json
? JSON.stringify({ command, skills: skillNames, global, executed: false }, null, 2)
: `${command}\n\nRerun without --dry-run to ${verb} now.`
)
return
}
if (json) {
// Why: a real run inherits npx's own stdout so progress stays visible live;
// that stream is not JSON, so --json can't be honored here.
throw new RuntimeClientError(
'invalid_argument',
`orca skills ${verb} --json only supports --dry-run. Real ${verb}s stream ` +
"npx's own output, which isn't JSON."
)
}
// Why: stdio is inherited for the child below, so this status line must go to
// stderr — stdout is npx's own output, not this command's JSON channel.
process.stderr.write(`Running: ${command}\n`)
process.exitCode = await runNpxSkills(npxArgs)
}
}
export const SKILL_HANDLERS: Record<string, CommandHandler> = {
'skills list': async ({ json }) => {
// Why: the embedded guide table is large, so unrelated CLI commands must not
@@ -72,5 +341,7 @@ export const SKILL_HANDLERS: Record<string, CommandHandler> = {
const full = flags.has('full')
const markdown = full ? guide.fullMarkdown : guide.markdown
writeStdout(json ? JSON.stringify({ name: guide.name, full, markdown }, null, 2) : markdown)
}
},
'skills install': createSkillMutationHandler('install'),
'skills update': createSkillMutationHandler('update')
}
+7
View File
@@ -21,6 +21,8 @@ Agent Discovery:
Skills:
skills list List version-matched skill guides bundled with this Orca CLI
skills get Print a version-matched skill guide as Markdown
skills install Install bundled Orca skills globally via the community skills CLI
skills update Update already-installed Orca skills via the community skills CLI
Environments:
environment add Save a remote Orca runtime from a pairing code
@@ -412,6 +414,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string {
function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
const command = commandPath.join(' ')
if (command === 'skills install' && flag === 'agent') {
return '--agent <names> Comma-separated install targets; default is detected agents'
}
if (command === 'terminal close' && flag === 'tab') {
return '--tab Close the whole tab and wait for durable persistence'
}
@@ -521,6 +526,8 @@ export function formatFlagHelp(flag: string): string {
json: '--json Emit machine-readable JSON',
key: '--key <key> Key argument for this command',
limit: '--limit <n> Maximum number of rows to return',
local: '--local Target the current project instead of the global install',
skill: '--skill <name> Bundled skill to act on; repeat for several',
mode: '--mode <mode> Mode such as edit, diff, or both',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
modifiers: '--modifiers <chord> Modifier keys held only for this click',
+778 -26
View File
@@ -1,8 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { delimiter } from 'node:path'
import type * as CodexCliCommandModule from '../main/codex-cli/command'
const { guideModuleLoadMock, runtimeClientConstructorMock } = vi.hoisted(() => ({
const {
detectCommandsMock,
guideModuleLoadMock,
resolveCliCommandMock,
runtimeClientConstructorMock,
spawnMock
} = vi.hoisted(() => ({
detectCommandsMock: vi.fn(() => new Set<string>(['claude'])),
guideModuleLoadMock: vi.fn(),
runtimeClientConstructorMock: vi.fn()
resolveCliCommandMock: vi.fn(() => 'npx'),
runtimeClientConstructorMock: vi.fn(),
spawnMock: vi.fn()
}))
// Why: agent detection probes the real machine, so pin it or every install
// assertion depends on what the test runner happens to have installed.
vi.mock('../main/ipc/local-agent-install-dir-detection', () => ({
detectCommandsInInstallDirs: detectCommandsMock
}))
// Why: override only the npx lookup so the real Windows .cmd rail still runs.
vi.mock('../main/codex-cli/command', async (importOriginal) => ({
...(await importOriginal<typeof CodexCliCommandModule>()),
resolveCliCommand: resolveCliCommandMock
}))
vi.mock('node:child_process', () => ({
spawn: spawnMock
}))
vi.mock('./bundled-skill-guides.js', () => {
@@ -22,38 +50,33 @@ vi.mock('./bundled-skill-guides.js', () => {
markdown: '# Alpha\n\nShort.\n',
fullMarkdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n',
aliases: ['legacy-alpha']
},
{
name: 'gamma',
description:
'Use when gamma work spans several sentences describing exactly how a ' +
'coding agent should decide whether gamma applies to the current task at hand.',
markdown: '# Gamma\n',
fullMarkdown: '# Gamma\n\n## References\n\nGamma reference.\n',
aliases: []
}
]
}
})
vi.mock('./runtime-client', () => {
vi.mock('./runtime-client', async () => {
// Why: re-export the REAL error classes rather than redefining them. format.ts
// narrows with `instanceof` against ./runtime/types, so a look-alike class
// here would make every CLI error fall through to the generic `runtime_error`
// shape — mirroring the barrel keeps the mock faithful to production.
const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js')
class RuntimeClient {
constructor() {
runtimeClientConstructorMock()
}
}
class RuntimeClientError extends Error {
readonly code: string
readonly data?: unknown
constructor(code: string, message: string, data?: unknown) {
super(message)
this.code = code
this.data = data
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
readonly response: unknown
constructor(response: unknown) {
super('runtime_error', 'runtime_error')
this.response = response
}
}
return {
RuntimeClient,
RuntimeClientError,
@@ -70,9 +93,19 @@ describe('orca skills CLI', () => {
beforeEach(() => {
vi.restoreAllMocks()
runtimeClientConstructorMock.mockClear()
resolveCliCommandMock.mockReset()
resolveCliCommandMock.mockReturnValue('npx')
detectCommandsMock.mockReset()
detectCommandsMock.mockReturnValue(new Set<string>(['claude']))
spawnMock.mockReset()
process.exitCode = undefined
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
it('keeps the bundled table off the eager command-registry path', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {})
@@ -102,7 +135,10 @@ describe('orca skills CLI', () => {
await main(['skills', 'list'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
'alpha: Use when alpha work is needed.\nzeta: Use when zeta work spans lines.\n'
'alpha: Use when alpha work is needed.\n' +
'gamma: Use when gamma work spans several sentences describing exactly how a ' +
'coding agent should decide whether gamma applies to the current task at hand.\n' +
'zeta: Use when zeta work spans lines.\n'
)
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
})
@@ -133,6 +169,12 @@ describe('orca skills CLI', () => {
{
topics: [
{ name: 'alpha', description: 'Use when alpha work is needed.' },
{
name: 'gamma',
description:
'Use when gamma work spans several sentences describing exactly how a ' +
'coding agent should decide whether gamma applies to the current task at hand.'
},
{ name: 'zeta', description: 'Use when zeta work spans lines.' }
]
},
@@ -176,7 +218,14 @@ describe('orca skills CLI', () => {
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
'get Print a version-matched skill guide'
)
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
'install Install bundled Orca skills'
)
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
'update Update already-installed Orca skills'
)
expect(String(logSpy.mock.calls[2]?.[0])).toContain('Skills:\n skills list')
expect(String(logSpy.mock.calls[2]?.[0])).toContain('skills update')
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
})
@@ -187,12 +236,715 @@ describe('orca skills CLI', () => {
expect(process.exitCode).toBe(1)
expect(errorSpy).toHaveBeenCalledWith(
'Unknown skill topic "missing". Available topics: alpha, zeta'
'Unknown skill topic "missing". Available topics: alpha, gamma, zeta'
)
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
})
it('lists installable skills when no --skill/--all is given', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(['skills', 'install'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
[
'Choose one or more skills to install:',
' alpha',
' gamma',
' zeta',
'',
'Usage: orca skills install --skill <name> [--skill <name> ...]',
' or: orca skills install --all',
''
].join('\n')
)
expect(spawnMock).not.toHaveBeenCalled()
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
})
it('gives install --json (no selection) a stable schema', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(['skills', 'install', '--json'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
`${JSON.stringify({ availableSkills: ['alpha', 'gamma', 'zeta'] }, null, 2)}\n`
)
})
it('rejects combining --all with --skill', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['skills', 'install', '--all', '--skill', 'alpha'], '/tmp/repo')
expect(process.exitCode).toBe(1)
expect(errorSpy).toHaveBeenCalledWith('Use either --all or --skill, not both.')
expect(spawnMock).not.toHaveBeenCalled()
})
it('rejects an unknown --skill name', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['skills', 'install', '--skill', 'missing'], '/tmp/repo')
expect(process.exitCode).toBe(1)
expect(errorSpy).toHaveBeenCalledWith(
'Unknown skill "missing". Available skills: alpha, gamma, zeta'
)
expect(spawnMock).not.toHaveBeenCalled()
})
it('rejects --skill without a value', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['skills', 'install', '--skill'], '/tmp/repo')
expect(process.exitCode).toBe(1)
expect(errorSpy).toHaveBeenCalledWith('Missing required --skill')
expect(spawnMock).not.toHaveBeenCalled()
})
it('rejects --json for a real (non-dry-run) install', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['skills', 'install', '--skill', 'alpha', '--json'], '/tmp/repo')
expect(process.exitCode).toBe(1)
expect(logSpy).toHaveBeenCalledWith(
JSON.stringify(
{
id: 'local',
ok: false,
error: {
code: 'invalid_argument',
message:
"orca skills install --json only supports --dry-run. Real installs stream npx's " +
"own output, which isn't JSON."
},
_meta: { runtimeId: null }
},
null,
2
)
)
expect(spawnMock).not.toHaveBeenCalled()
})
it('prints the resolved install command without running it for --dry-run', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' +
'Rerun without --dry-run to install now.\n'
)
expect(spawnMock).not.toHaveBeenCalled()
})
it('gives dry-run --json a stable schema', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(['skills', 'install', '--skill', 'legacy-alpha', '--dry-run', '--json'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
`${JSON.stringify(
{
command:
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y',
skills: ['alpha'],
global: true,
executed: false
},
null,
2
)}\n`
)
})
it('drops --global for --local in the dry-run command and JSON', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(['skills', 'install', '--skill', 'alpha', '--local', '--dry-run'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y\n\n' +
'Rerun without --dry-run to install now.\n'
)
stdoutSpy.mockClear()
await main(
['skills', 'install', '--skill', 'alpha', '--local', '--dry-run', '--json'],
'/tmp/repo'
)
expect(stdoutText(stdoutSpy)).toBe(
`${JSON.stringify(
{
command:
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --agent claude-code --agent universal -y',
skills: ['alpha'],
global: false,
executed: false
},
null,
2
)}\n`
)
})
it('runs npx without --global for --local', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'install', '--skill', 'alpha', '--local'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
expect(spawnMock).toHaveBeenCalledWith(
'npx',
[
'--yes',
'skills',
'add',
'https://github.com/stablyai/orca',
'--skill',
'alpha',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
],
expect.objectContaining({ stdio: 'inherit' })
)
})
it('routes a resolved Windows .cmd shim through cmd.exe', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe')
resolveCliCommandMock.mockReturnValue('C:\\Program Files\\nodejs\\npx.cmd')
const child = createFakeChild()
spawnMock.mockReturnValue(child)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
expect(spawnMock).toHaveBeenCalledWith(
'C:\\Windows\\System32\\cmd.exe',
[
'/d',
'/c',
'C:\\Program Files\\nodejs\\npx.cmd',
'--yes',
'skills',
'add',
'https://github.com/stablyai/orca',
'--skill',
'alpha',
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
],
expect.objectContaining({ stdio: 'inherit' })
)
})
it('spawns a resolved npx path directly when it is not a .cmd shim', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
resolveCliCommandMock.mockReturnValue('C:\\Program Files\\nodejs\\npx.exe')
const child = createFakeChild()
spawnMock.mockReturnValue(child)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
// Why: an .exe shim must stay a direct spawn so a missing npx still raises
// ENOENT on the child instead of hiding inside cmd.exe's own exit code.
expect(spawnMock.mock.calls[0]?.[0]).toBe('C:\\Program Files\\nodejs\\npx.exe')
expect(spawnMock.mock.calls[0]?.[1]?.[0]).toBe('--yes')
})
it('resolves a legacy topic alias to the canonical skill name for install', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'install', '--skill', 'legacy-alpha'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
expect(spawnMock).toHaveBeenCalledWith(
'npx',
[
'--yes',
'skills',
'add',
'https://github.com/stablyai/orca',
'--skill',
'alpha',
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
],
expect.objectContaining({ stdio: 'inherit' })
)
})
it('runs npx for --all and forwards its exit code', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'install', '--all'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 1, null)
await resultPromise
expect(spawnMock).toHaveBeenCalledWith(
'npx',
[
'--yes',
'skills',
'add',
'https://github.com/stablyai/orca',
'--skill',
'alpha',
'--skill',
'gamma',
'--skill',
'zeta',
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
],
expect.objectContaining({ stdio: 'inherit' })
)
expect(process.exitCode).toBe(1)
})
it('propagates a spawn error as a nonzero exit', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('error', new Error('spawn npx ENOENT'))
await resultPromise
expect(process.exitCode).toBe(1)
expect(errorSpy).toHaveBeenCalledWith(
'Could not run npx: spawn npx ENOENT. Install Node.js and ensure npx is on PATH.'
)
})
it('lists updatable skills when no --skill/--all is given', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(['skills', 'update'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
[
'Choose one or more skills to update:',
' alpha',
' gamma',
' zeta',
'',
'Usage: orca skills update --skill <name> [--skill <name> ...]',
' or: orca skills update --all',
''
].join('\n')
)
expect(spawnMock).not.toHaveBeenCalled()
})
it('prints the resolved update command without running it for --dry-run', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(['skills', 'update', '--skill', 'legacy-alpha', '--dry-run'], '/tmp/repo')
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills update alpha --global -y\n\nRerun without --dry-run to update now.\n'
)
expect(spawnMock).not.toHaveBeenCalled()
})
it('selects project scope for --local on update', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(
['skills', 'update', '--skill', 'alpha', '--local', '--dry-run', '--json'],
'/tmp/repo'
)
expect(stdoutText(stdoutSpy)).toBe(
`${JSON.stringify(
{
command: 'npx --yes skills update alpha --project -y',
skills: ['alpha'],
global: false,
executed: false
},
null,
2
)}\n`
)
})
it('runs local updates with explicit project scope', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'update', '--skill', 'alpha', '--local'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
expect(spawnMock).toHaveBeenCalledWith(
'npx',
['--yes', 'skills', 'update', 'alpha', '--project', '-y'],
expect.objectContaining({ stdio: 'inherit' })
)
})
it('refuses a real run when the shell forwards orca to the Orca host', async () => {
vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt')
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
// Why: the SSH relay and WSL bridge run argv on the Orca host, so a real
// install there would silently skip the machine the user is sitting on.
expect(spawnMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it')
})
it('refuses --dry-run through the host-forwarding shim too', async () => {
vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt')
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo')
// Why: the targets are resolved from THIS host's agents, so a command printed
// here would name the wrong machine's agents. Point at the target instead.
expect(spawnMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it')
})
it('puts the resolved npx directory on the child PATH', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
resolveCliCommandMock.mockReturnValue('/home/alice/.nvm/versions/node/v22/bin/npx')
vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
// Why: npx is an `env node` script, so an off-PATH npx exits 127 with no
// 'error' event unless node ships alongside it on the child's PATH.
const env = spawnMock.mock.calls[0]?.[2]?.env
// Why: the child still needs the inherited PATH and the rest of the parent
// environment; replacing it outright breaks git, node, HOME and npm config.
expect(env?.PATH).toBe(
`/home/alice/.nvm/versions/node/v22/bin${delimiter}/usr/bin${delimiter}/bin`
)
expect(env?.HOME ?? env?.USERPROFILE).toBe(process.env.HOME ?? process.env.USERPROFILE)
})
it('reports a Windows npx path cmd.exe would reinterpret', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe')
resolveCliCommandMock.mockReturnValue('C:\\Users\\A&B\\npx.cmd')
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
expect(spawnMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('cmd.exe would reinterpret')
})
it('never puts the current directory on the child PATH when npx is unresolvable', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
// Why: resolveCliCommand returns the bare name when it finds nothing, and
// dirname('npx') is '.', which would run ./npx out of the caller's checkout.
resolveCliCommandMock.mockReturnValue('npx')
vi.stubEnv('PATH', `/usr/bin${delimiter}/bin`)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
// Why: assert the constructed value, not the ambient one — a dev PATH with a
// trailing separator carries its own '' entry and would fake a failure here.
expect(spawnMock.mock.calls[0]?.[2]?.env?.PATH).toBe(`/usr/bin${delimiter}/bin`)
})
it('refuses to install when Orca detects no agent, instead of targeting them all', async () => {
detectCommandsMock.mockReturnValue(new Set<string>())
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
// Why: `skills add -y` with nothing detected installs into every agent it
// knows (~75), creating config dirs for agents the host does not have.
expect(spawnMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('No coding agent detected')
})
it('honours an explicit --agent list without probing the host', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
detectCommandsMock.mockReturnValue(new Set<string>())
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(
['skills', 'install', '--skill', 'alpha', '--agent', 'codex, claude-code ,codex'],
'/tmp/repo'
)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
const argv = spawnMock.mock.calls[0]?.[1] ?? []
const agents = argv.filter((_: string, i: number) => argv[i - 1] === '--agent')
// Why: trimmed and de-duplicated, and detection is not consulted at all.
expect(agents).toEqual(['codex', 'claude-code'])
expect(detectCommandsMock).not.toHaveBeenCalled()
})
it('maps detected agents onto the skills CLI namespace, not Orca ids', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
detectCommandsMock.mockReturnValue(new Set<string>(['claude', 'cursor-agent', 'rovo']))
await main(['skills', 'install', '--skill', 'alpha', '--dry-run'], '/tmp/repo')
// Why: `skills add` exits 1 on an unknown --agent, and the ids differ —
// Orca's `claude` is `claude-code` and its `rovo` is `rovodev`.
expect(stdoutText(stdoutSpy)).toContain(
'--agent claude-code --agent cursor --agent rovodev --agent universal'
)
})
it('never sends --agent for an update, and never refuses on a bare host', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
// Why: update refreshes what is already placed, so the no-agent refusal that
// guards install must not reach it.
detectCommandsMock.mockReturnValue(new Set<string>())
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'update', '--skill', 'alpha'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
// Why: update refreshes what is already placed; it chooses no new targets.
expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--agent')
})
it.each([
['a bare --agent', ['skills', 'install', '--skill', 'alpha', '--agent']],
['an empty --agent', ['skills', 'install', '--skill', 'alpha', '--agent', '']],
['a separator-only --agent', ['skills', 'install', '--skill', 'alpha', '--agent', ' , ,']]
])('rejects %s instead of installing to every agent', async (_label, argv) => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(argv, '/tmp/repo')
// Why: an --agent that resolves to nothing must not fall back to detection or
// emit no --agent at all — the latter restores the ~75-agent install.
expect(spawnMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Missing required --agent')
})
it.each([
['a dash-leading value', ['skills', 'install', '--skill', 'alpha', '--agent', '-y']],
['an inline dash value', ['skills', 'install', '--skill', 'alpha', '--agent=--copy']],
['a value with a space', ['skills', 'install', '--skill', 'alpha', '--agent', 'a b']]
])('rejects %s the skills CLI would silently drop', async (_label, argv) => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(argv, '/tmp/repo')
// Why: the skills CLI drops such a value, leaving it with no target — the same
// all-agents install as omitting --agent entirely.
expect(spawnMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('Invalid --agent value')
})
it('reports forwarding, not missing agents, when a forwarded host detects none', async () => {
vi.stubEnv('ORCA_CLI_CWD', '/home/alice/wt')
detectCommandsMock.mockReturnValue(new Set<string>())
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
// Why: resolving targets first would hide the forwarding problem behind a
// no-agent error about the wrong machine.
expect(String(errorSpy.mock.calls[0]?.[0])).toContain('writes to the machine that runs it')
})
it('documents --agent for skills install rather than the terminal-launch flag', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['skills', 'install', '--help'], '/tmp/repo')
const help = String(logSpy.mock.calls[0]?.[0])
expect(help).toContain('--agent <names>')
expect(help).not.toContain('Launch a known TUI agent')
})
it('accumulates a repeated --skill instead of keeping only the last one', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(
['skills', 'install', '--skill', 'zeta', '--skill', 'alpha'],
'/tmp/repo'
)
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
// Why: the documented primary invocation. Dropping 'skill' from the
// repeatable-flag set silently installs one skill instead of two.
expect(spawnMock).toHaveBeenCalledWith(
'npx',
[
'--yes',
'skills',
'add',
'https://github.com/stablyai/orca',
'--skill',
'alpha',
'--skill',
'zeta',
'--global',
'--agent',
'claude-code',
'--agent',
'universal',
'-y'
],
expect.objectContaining({ stdio: 'inherit' })
)
})
it('collapses an alias and its canonical name into one --skill', async () => {
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
await main(
['skills', 'install', '--skill', 'alpha', '--skill', 'legacy-alpha', '--dry-run'],
'/tmp/repo'
)
expect(stdoutText(stdoutSpy)).toBe(
'npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n\n' +
'Rerun without --dry-run to install now.\n'
)
expect(spawnMock).not.toHaveBeenCalled()
})
it('reports the command it is about to run on stderr', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'install', '--skill', 'alpha'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 0, null)
await resultPromise
// Why: stdout belongs to the child, so this record has to go to stderr.
expect(stderrSpy).toHaveBeenCalledWith(
'Running: npx --yes skills add https://github.com/stablyai/orca --skill alpha --global --agent claude-code --agent universal -y\n'
)
})
it('runs npx skills update for --all and forwards its exit code', async () => {
const child = createFakeChild()
spawnMock.mockReturnValue(child)
vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const resultPromise = main(['skills', 'update', '--all'], '/tmp/repo')
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
child.emit('exit', 2, null)
await resultPromise
expect(spawnMock).toHaveBeenCalledWith(
'npx',
['--yes', 'skills', 'update', 'alpha', 'gamma', 'zeta', '--global', '-y'],
expect.objectContaining({ stdio: 'inherit' })
)
expect(process.exitCode).toBe(2)
})
it('rejects --json for a real (non-dry-run) update', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['skills', 'update', '--skill', 'alpha', '--json'], '/tmp/repo')
expect(process.exitCode).toBe(1)
expect(logSpy).toHaveBeenCalledWith(
JSON.stringify(
{
id: 'local',
ok: false,
error: {
code: 'invalid_argument',
message:
"orca skills update --json only supports --dry-run. Real updates stream npx's " +
"own output, which isn't JSON."
},
_meta: { runtimeId: null }
},
null,
2
)
)
expect(spawnMock).not.toHaveBeenCalled()
})
})
function stdoutText(spy: ReturnType<typeof vi.spyOn>): string {
return spy.mock.calls.map((call) => String(call[0])).join('')
}
function createFakeChild(): EventEmitter {
return new EventEmitter()
}
+2 -2
View File
@@ -32,8 +32,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['orchestration', 'run-list'],
summary: 'List lightweight orchestration Runs',
usage: 'orca orchestration run-list [--json]',
allowedFlags: [...GLOBAL_FLAGS]
usage: 'orca orchestration run-list [--limit <n>] [--cursor <cursor>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'cursor']
},
{
path: ['orchestration', 'run-show'],
+62 -1
View File
@@ -9,7 +9,8 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [
allowedFlags: [...GLOBAL_FLAGS],
notes: [
'Reads bundled guide metadata locally without contacting the Orca runtime.',
'With --json, prints a topics array of canonical names and one-line descriptions.'
'With --json, prints a topics array of canonical names and one-line descriptions.',
'Use `orca skills get <name>` for the full guide, or `orca skills install` to install skills.'
]
},
{
@@ -25,5 +26,65 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [
'Use --json for a deterministic object containing canonical topic metadata and content.'
],
examples: ['orca skills get orca-cli', 'orca skills get orchestration --full']
},
{
path: ['skills', 'install'],
summary: 'Install bundled Orca skills via the community skills CLI',
usage:
'orca skills install [--skill <name>]... [--all] [--agent <name>[,<name>]] ' +
'[--local] [--dry-run] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'agent', 'local', 'dry-run'],
notes: [
'Reads the bundled skill registry locally without contacting the Orca runtime.',
'Resolves to the same `npx skills add <repo> --skill <name> ...` command used by ' +
'Orca Settings, plus the non-interactive flags an unattended host needs ' +
'(`npx --yes` and `-y`), then runs it and forwards its output and exit code.',
'Installs globally (all projects, adds --global) by default. Use --local to install ' +
'into the current project instead.',
'Targets the coding agents Orca detects on this host, plus the shared ' +
'.agents/skills directory. Without an explicit target the skills CLI installs ' +
'into every agent it knows about, which litters a host with config ' +
'directories for agents it does not have.',
'Use --agent <name>[,<name>...] to choose targets yourself, or --agent universal ' +
'for the shared directory alone. Required when Orca detects no agent.',
'Use --dry-run to print the resolved command without running it.',
'With --json, the skill listing and --dry-run emit JSON; a real install streams ' +
"npx's own non-JSON output live and rejects --json.",
'Omit --skill and --all to list installable skill names.',
'Intended for headless hosts (SSH, containers, CI) with no desktop Settings UI to copy the install command from.'
],
examples: [
'orca skills install',
'orca skills install --skill orca-cli --skill orchestration',
'orca skills install --skill orca-cli --local',
'orca skills install --skill orca-cli --agent claude-code,codex',
'orca skills install --all --dry-run'
]
},
{
path: ['skills', 'update'],
summary: 'Update already-installed Orca skills via the community skills CLI',
usage: 'orca skills update [--skill <name>]... [--all] [--local] [--dry-run] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'skill', 'all', 'local', 'dry-run'],
notes: [
'Reads the bundled skill registry locally without contacting the Orca runtime.',
'Resolves to the same `npx skills update <names...>` command used by Orca Settings, ' +
'plus the non-interactive flags an unattended host needs (`npx --yes` and `-y`), ' +
'then runs it and forwards its output and exit code.',
'Updates the global install (all projects, adds --global) by default. Use --local to ' +
'update the current project instead.',
'Only refreshes skills that are already installed; use `orca skills install` first.',
'Use --dry-run to print the resolved command without running it.',
'With --json, the skill listing and --dry-run emit JSON; a real update streams ' +
"npx's own non-JSON output live and rejects --json.",
'Omit --skill and --all to list updatable skill names.',
'Intended for headless hosts (SSH, containers, CI) with no desktop Settings UI to copy the update command from.'
],
examples: [
'orca skills update',
'orca skills update --skill orca-cli --skill orchestration',
'orca skills update --skill orca-cli --local',
'orca skills update --all --dry-run'
]
}
]
@@ -0,0 +1,73 @@
import type { PtyBackgroundStreamEvent } from '../providers/types'
import { combineUnsubscribes } from './combine-unsubscribes'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
export class DaemonPtyAdapterSubscriptionFanout {
private unsubscribers: (() => void)[] = []
private dataListeners: ((payload: DaemonPtyRouterDataEvent) => void)[] = []
private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = []
constructor(
private readonly adapters: readonly DaemonPtyAdapter[],
onAdapterExit: (id: string) => void
) {
for (const adapter of adapters) {
this.unsubscribers.push(
adapter.onData((payload) => {
for (const listener of this.dataListeners) {
listener(payload)
}
}),
adapter.onExit((payload) => {
onAdapterExit(payload.id)
for (const listener of this.exitListeners) {
listener(payload)
}
})
)
}
}
onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void {
this.dataListeners.push(callback)
return () => {
const index = this.dataListeners.indexOf(callback)
if (index !== -1) {
this.dataListeners.splice(index, 1)
}
}
}
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
return combineUnsubscribes(
this.adapters.map((adapter) => adapter.onBackgroundStreamEvent(callback))
)
}
// Why: main subscribes on the routed provider, so without this the dead-endpoint
// fan-out never reaches the renderer and only the written pane recovers (STA-2373).
onWriteUnavailable(callback: (payload: { id: string }) => void): () => void {
return combineUnsubscribes(this.adapters.map((adapter) => adapter.onWriteUnavailable(callback)))
}
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
return () => {}
}
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
this.exitListeners.push(callback)
return () => {
const index = this.exitListeners.indexOf(callback)
if (index !== -1) {
this.exitListeners.splice(index, 1)
}
}
}
dispose(): void {
for (const unsubscribe of this.unsubscribers.splice(0)) {
unsubscribe()
}
}
}
@@ -0,0 +1,15 @@
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type DaemonPtyRouterDataEvent = {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}
export type DaemonPtyRouterExitEvent = {
id: string
code: number
incarnationId?: PtyIncarnationId
}
+17 -69
View File
@@ -1,5 +1,5 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { combineUnsubscribes } from './combine-unsubscribes'
import { DaemonPtyAdapterSubscriptionFanout } from './daemon-pty-adapter-subscription-fanout'
import type {
IPtyProvider,
PtyBackgroundStreamEvent,
@@ -8,41 +8,23 @@ import type {
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import { probePtyOwners } from './daemon-pty-liveness-probe'
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
type PtyDataListener = Parameters<IPtyProvider['onData']>[0]
type PtyExitListener = Parameters<IPtyProvider['onExit']>[0]
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
export class DaemonPtyRouter implements IPtyProvider {
private current: DaemonPtyAdapter
private legacy: DaemonPtyAdapter[]
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
private unsubscribers: (() => void)[] = []
private dataListeners: PtyDataListener[] = []
private exitListeners: PtyExitListener[] = []
private readonly subscriptions: DaemonPtyAdapterSubscriptionFanout
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
this.current = opts.current
this.legacy = opts.legacy
for (const adapter of this.allAdapters()) {
this.unsubscribers.push(
adapter.onData((payload) => {
for (const listener of this.dataListeners) {
listener(payload)
}
}),
adapter.onExit((payload) => {
this.sessionAdapters.delete(payload.id)
for (const listener of this.exitListeners) {
listener(payload)
}
})
)
}
this.subscriptions = new DaemonPtyAdapterSubscriptionFanout(this.allAdapters(), (id) => {
this.sessionAdapters.delete(id)
})
}
async discoverLegacySessions(): Promise<void> {
@@ -224,52 +206,24 @@ export class DaemonPtyRouter implements IPtyProvider {
return this.current.getProfiles()
}
onData(
callback: (payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void
): () => void {
this.dataListeners.push(callback)
return () => {
const idx = this.dataListeners.indexOf(callback)
if (idx !== -1) {
this.dataListeners.splice(idx, 1)
}
}
onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void {
return this.subscriptions.onData(callback)
}
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
return combineUnsubscribes(
this.allAdapters().map((adapter) => adapter.onBackgroundStreamEvent(callback))
)
return this.subscriptions.onBackgroundStreamEvent(callback)
}
// Why: main subscribes on the routed provider, so without this the dead-endpoint
// fan-out never reaches the renderer and only the written pane recovers (STA-2373).
onWriteUnavailable(callback: (payload: { id: string }) => void): () => void {
return combineUnsubscribes(
this.allAdapters().map((adapter) => adapter.onWriteUnavailable(callback))
)
return this.subscriptions.onWriteUnavailable(callback)
}
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
return () => {}
onReplay(callback: (payload: { id: string; data: string }) => void): () => void {
return this.subscriptions.onReplay(callback)
}
onExit(
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
if (idx !== -1) {
this.exitListeners.splice(idx, 1)
}
}
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
return this.subscriptions.onExit(callback)
}
ackColdRestore(sessionId: string): void {
@@ -307,9 +261,7 @@ export class DaemonPtyRouter implements IPtyProvider {
}
dispose(): void {
for (const unsubscribe of this.unsubscribers.splice(0)) {
unsubscribe()
}
this.subscriptions.dispose()
for (const adapter of this.allAdapters()) {
adapter.dispose()
}
@@ -323,15 +275,11 @@ export class DaemonPtyRouter implements IPtyProvider {
// Without this, each restart leaked a router instance pinned by the legacy
// adapters' listener arrays (one pair per adapter per restart).
disposeRouterOnly(): void {
for (const unsubscribe of this.unsubscribers.splice(0)) {
unsubscribe()
}
this.subscriptions.dispose()
}
async disconnectOnly(): Promise<void> {
for (const unsubscribe of this.unsubscribers.splice(0)) {
unsubscribe()
}
this.subscriptions.dispose()
await Promise.all([...this.allAdapters()].map((adapter) => adapter.disconnectOnly()))
}
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest'
import { jsonUtf8ByteLength } from './json-utf8-byte-length'
describe('jsonUtf8ByteLength', () => {
it('matches JSON.stringify for escapes, Unicode, surrogates, and nested values', () => {
const values: unknown[] = [
'',
'"\\\b\t\n\f\r\u0000\u001f',
'plain ASCII',
'é漢😀',
'\ud800 lone high \udc00 lone low',
{
omitted: undefined,
finite: -1.25e100,
nonFinite: Number.POSITIVE_INFINITY,
nested: ['😀', undefined, null, { control: '\u0001' }]
}
]
for (const value of values) {
const json = JSON.stringify(value)
expect(jsonUtf8ByteLength(value)).toBe(Buffer.byteLength(json, 'utf8'))
}
})
it('rejects the same unsupported structural values as JSON.stringify', () => {
const circular: Record<string, unknown> = {}
circular.self = circular
expect(() => jsonUtf8ByteLength(circular)).toThrow('circular')
expect(() => jsonUtf8ByteLength(1n)).toThrow('BigInt')
})
})
-94
View File
@@ -1,94 +0,0 @@
function jsonStringUtf8Bytes(value: string): number {
let bytes = 2
for (let index = 0; index < value.length; index += 1) {
const codeUnit = value.charCodeAt(index)
if (codeUnit === 0x22 || codeUnit === 0x5c || codeUnit === 0x08 || codeUnit === 0x09) {
bytes += 2
} else if (codeUnit === 0x0a || codeUnit === 0x0c || codeUnit === 0x0d) {
bytes += 2
} else if (codeUnit < 0x20) {
bytes += 6
} else if (codeUnit < 0x80) {
bytes += 1
} else if (codeUnit < 0x800) {
bytes += 2
} else if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4
index += 1
} else {
bytes += 6
}
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
bytes += 6
} else {
bytes += 3
}
}
return bytes
}
export function jsonUtf8ByteLength(value: unknown): number {
const activeObjects = new Set<object>()
const measure = (current: unknown, arrayElement: boolean): number | null => {
if (current === null) {
return 4
}
switch (typeof current) {
case 'string':
return jsonStringUtf8Bytes(current)
case 'boolean':
return current ? 4 : 5
case 'number':
return Number.isFinite(current) ? JSON.stringify(current).length : 4
case 'undefined':
case 'function':
case 'symbol':
return arrayElement ? 4 : null
case 'bigint':
throw new TypeError('Do not know how to serialize a BigInt')
case 'object':
break
}
const object = current as object
if (activeObjects.has(object)) {
throw new TypeError('Converting circular structure to JSON')
}
activeObjects.add(object)
try {
if (Array.isArray(object)) {
let bytes = 2
for (let index = 0; index < object.length; index += 1) {
if (index > 0) {
bytes += 1
}
bytes += measure(object[index], true) ?? 4
}
return bytes
}
let bytes = 2
let entries = 0
for (const key of Object.keys(object)) {
const propertyBytes = measure((object as Record<string, unknown>)[key], false)
if (propertyBytes === null) {
continue
}
bytes += (entries > 0 ? 1 : 0) + jsonStringUtf8Bytes(key) + 1 + propertyBytes
entries += 1
}
return bytes
} finally {
activeObjects.delete(object)
}
}
const bytes = measure(value, false)
if (bytes === null) {
throw new TypeError('Value is not JSON serializable')
}
return bytes
}
@@ -0,0 +1,143 @@
import { describe, expect, it, vi } from 'vitest'
import type { TerminalSnapshot } from './types'
import { serializeTerminalCheckpointWithinLimit } from './terminal-checkpoint-serializer'
function snapshot(overrides: Partial<TerminalSnapshot> = {}): TerminalSnapshot {
return {
snapshotAnsi: 'visible',
scrollbackAnsi: '',
rehydrateSequences: '',
cwd: '/workspace',
modes: {
bracketedPaste: false,
mouseTracking: false,
applicationCursor: false,
alternateScreen: false
},
cols: 80,
rows: 24,
scrollbackLines: 0,
...overrides
}
}
const metadata = {
cwd: '/workspace',
generation: 1,
checkpointedAt: '2026-07-29T00:00:00.000Z'
}
describe('terminal checkpoint serializer', () => {
it('matches JSON.stringify exactly at the UTF-8 byte limit', async () => {
const input = snapshot({
snapshotAnsi: `é漢😀${String.fromCharCode(0xd800, 0xdc00)}"\\${String.fromCharCode(
0x00,
0x08,
0x09,
0x0a,
0x0c,
0x0d,
0x1f,
0xd800,
0xdc00
)}`,
oscLinks: [{ row: 0, startCol: 0, endCol: 1, uri: 'https://example.com/😀\n' }]
})
const expected = JSON.stringify({
snapshotAnsi: input.snapshotAnsi,
scrollbackAnsi: input.scrollbackAnsi,
oscLinks: input.oscLinks,
rehydrateSequences: input.rehydrateSequences,
cwd: metadata.cwd,
cols: input.cols,
rows: input.rows,
modes: input.modes,
scrollbackLines: input.scrollbackLines,
generation: metadata.generation,
checkpointedAt: metadata.checkpointedAt
})
const exactBytes = Buffer.byteLength(expected, 'utf8')
await expect(serializeTerminalCheckpointWithinLimit(input, metadata, exactBytes)).resolves.toBe(
expected
)
})
it('rejects multibyte input whose code-unit length fits under the byte cap', async () => {
const input = snapshot({
scrollbackAnsi: 'é\r\n'.repeat(100),
scrollbackLines: 100
})
const expected = JSON.stringify({
snapshotAnsi: input.snapshotAnsi,
scrollbackAnsi: input.scrollbackAnsi,
oscLinks: input.oscLinks,
rehydrateSequences: input.rehydrateSequences,
cwd: metadata.cwd,
cols: input.cols,
rows: input.rows,
modes: input.modes,
scrollbackLines: input.scrollbackLines,
generation: metadata.generation,
checkpointedAt: metadata.checkpointedAt
})
const maxBytes = expected.length + 1
expect(expected.length).toBeLessThan(maxBytes)
expect(Buffer.byteLength(expected, 'utf8')).toBeGreaterThan(maxBytes)
const serialized = await serializeTerminalCheckpointWithinLimit(input, metadata, maxBytes)
expect(serialized).not.toBe(expected)
expect(Buffer.byteLength(serialized, 'utf8')).toBeLessThanOrEqual(maxBytes)
})
it('does not materialize and rescan a passing candidate', async () => {
let reads = 0
const input = snapshot()
Object.defineProperty(input, 'snapshotAnsi', {
enumerable: true,
get: () => {
reads += 1
return 'visible'
}
})
const stringify = vi.spyOn(JSON, 'stringify')
const byteLength = vi.spyOn(Buffer, 'byteLength')
try {
await serializeTerminalCheckpointWithinLimit(input, metadata, 20 * 1024)
expect({
fullCandidateStringifies: stringify.mock.calls.filter(
([value]) => typeof value === 'object' && value !== null
).length,
byteLengthCalls: byteLength.mock.calls.length,
snapshotReads: reads
}).toEqual({ fullCandidateStringifies: 0, byteLengthCalls: 0, snapshotReads: 1 })
} finally {
stringify.mockRestore()
byteLength.mockRestore()
}
})
it('rejects an oversized escaped candidate without materializing it', async () => {
const oversized = String.fromCharCode(0).repeat(100_000)
const stringify = vi.spyOn(JSON, 'stringify')
try {
await serializeTerminalCheckpointWithinLimit(
snapshot({ snapshotAnsi: oversized }),
metadata,
512
)
const materializedOversizedCandidate = stringify.mock.calls.some(([value]) => {
return (value as { snapshotAnsi?: unknown })?.snapshotAnsi === oversized
})
expect(materializedOversizedCandidate).toBe(false)
} finally {
stringify.mockRestore()
}
})
})
@@ -1,7 +1,6 @@
import type { TerminalCheckpointFile, TerminalSnapshot } from './types'
import { ColdRestoreReplayWriter } from './cold-restore-replay-writer'
import { HeadlessEmulator } from './headless-emulator'
import { jsonUtf8ByteLength } from './json-utf8-byte-length'
type CheckpointMetadata = {
cwd: string | null
@@ -32,15 +31,187 @@ function checkpointFile(
}
}
class BoundedJsonWriter {
private output = ''
private chunk = ''
private bytes = 0
private exceeded = false
constructor(private readonly maxBytes: number) {}
append(value: string, bytes: number): boolean {
if (this.bytes + bytes > this.maxBytes) {
this.exceeded = true
this.output = ''
this.chunk = ''
return false
}
this.bytes += bytes
this.chunk += value
if (this.chunk.length >= 16 * 1024) {
this.output += this.chunk
this.chunk = ''
}
return true
}
result(): string | null {
return this.exceeded ? null : this.output + this.chunk
}
}
function escapedCodeUnit(codeUnit: number): string | null {
switch (codeUnit) {
case 0x08:
return '\\b'
case 0x09:
return '\\t'
case 0x0a:
return '\\n'
case 0x0c:
return '\\f'
case 0x0d:
return '\\r'
case 0x22:
return '\\"'
case 0x5c:
return '\\\\'
default:
return codeUnit < 0x20 ? `\\u${codeUnit.toString(16).padStart(4, '0')}` : null
}
}
function appendJsonString(writer: BoundedJsonWriter, value: string): boolean {
if (!writer.append('"', 1)) {
return false
}
let spanStart = 0
let spanBytes = 0
for (let index = 0; index < value.length; index += 1) {
const codeUnit = value.charCodeAt(index)
let escaped = escapedCodeUnit(codeUnit)
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
spanBytes += 4
index += 1
} else {
escaped = `\\u${codeUnit.toString(16)}`
}
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
escaped = `\\u${codeUnit.toString(16)}`
} else if (escaped === null) {
spanBytes += codeUnit < 0x80 ? 1 : codeUnit < 0x800 ? 2 : 3
}
if (escaped !== null) {
if (
(index > spanStart && !writer.append(value.slice(spanStart, index), spanBytes)) ||
!writer.append(escaped, escaped.length)
) {
return false
}
spanStart = index + 1
spanBytes = 0
} else if (index + 1 - spanStart >= 16 * 1024) {
if (!writer.append(value.slice(spanStart, index + 1), spanBytes)) {
return false
}
spanStart = index + 1
spanBytes = 0
}
}
if (spanStart < value.length && !writer.append(value.slice(spanStart), spanBytes)) {
return false
}
return writer.append('"', 1)
}
function omittedByJson(value: unknown): boolean {
return value === undefined || typeof value === 'function' || typeof value === 'symbol'
}
function appendJsonValue(
writer: BoundedJsonWriter,
value: unknown,
activeObjects: Set<object>
): boolean {
if (value === null) {
return writer.append('null', 4)
}
switch (typeof value) {
case 'string':
return appendJsonString(writer, value)
case 'boolean':
return writer.append(value ? 'true' : 'false', value ? 4 : 5)
case 'number': {
const json = Number.isFinite(value) ? JSON.stringify(value) : 'null'
return writer.append(json, json.length)
}
case 'bigint':
throw new TypeError('Do not know how to serialize a BigInt')
case 'undefined':
case 'function':
case 'symbol':
return false
case 'object':
break
}
if (activeObjects.has(value)) {
throw new TypeError('Converting circular structure to JSON')
}
activeObjects.add(value)
try {
if (Array.isArray(value)) {
if (!writer.append('[', 1)) {
return false
}
for (let index = 0; index < value.length; index += 1) {
if (index > 0 && !writer.append(',', 1)) {
return false
}
const entry = value[index]
if (omittedByJson(entry)) {
if (!writer.append('null', 4)) {
return false
}
} else if (!appendJsonValue(writer, entry, activeObjects)) {
return false
}
}
return writer.append(']', 1)
}
if (!writer.append('{', 1)) {
return false
}
let entries = 0
for (const key of Object.keys(value)) {
const entry = (value as Record<string, unknown>)[key]
if (omittedByJson(entry)) {
continue
}
if (
(entries > 0 && !writer.append(',', 1)) ||
!appendJsonString(writer, key) ||
!writer.append(':', 1) ||
!appendJsonValue(writer, entry, activeObjects)
) {
return false
}
entries += 1
}
return writer.append('}', 1)
} finally {
activeObjects.delete(value)
}
}
function stringifyWithinLimit(checkpoint: TerminalCheckpointFile, maxBytes: number): string | null {
if (jsonUtf8ByteLength(checkpoint) > maxBytes) {
return null
}
const json = JSON.stringify(checkpoint)
if (Buffer.byteLength(json, 'utf8') > maxBytes) {
throw new Error('Terminal checkpoint size estimator mismatch')
}
return json
const writer = new BoundedJsonWriter(maxBytes)
appendJsonValue(writer, checkpoint, new Set())
return writer.result()
}
async function replaySnapshot(snapshot: TerminalSnapshot): Promise<HeadlessEmulator> {
@@ -0,0 +1,248 @@
import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as FsPromises from 'node:fs/promises'
const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoisted(() => ({
copyFileMock: vi.fn(),
handleMock: vi.fn(),
lstatMock: vi.fn(),
realpathMock: vi.fn(),
renameMock: vi.fn()
}))
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
}))
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
return {
...actual,
copyFile: copyFileMock,
lstat: lstatMock,
realpath: realpathMock,
rename: renameMock
}
})
import { renameLocalPathSerializedByDestination } from './destination-serialized-local-rename'
import { registerFilesystemMutationHandlers } from './ipc/filesystem-mutations'
import { RuntimeFileCommands } from './runtime/orca-runtime-files'
const REPO_PATH = path.resolve('/workspace/repo')
const store = {
getRepos: () => [
{ id: 'repo-1', path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 }
],
getSettings: () => ({ workspaceDir: path.resolve('/workspace') })
}
function enoent(): Error {
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
function mockStats(dev: number, ino: number) {
return { dev, ino, isDirectory: () => false }
}
function createRuntimeCommands(): RuntimeFileCommands {
return new RuntimeFileCommands({
requireStore: () => store,
resolveRuntimeFileTarget: async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH }
})
} as never)
}
function createGate(): { promise: Promise<void>; release: () => void } {
let release!: () => void
const promise = new Promise<void>((resolve) => {
release = resolve
})
return { promise, release }
}
async function reachNextMacrotask(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
describe('renameLocalPathSerializedByDestination', () => {
beforeEach(() => {
handlers.clear()
copyFileMock.mockReset()
handleMock.mockReset()
lstatMock.mockReset()
realpathMock.mockReset()
renameMock.mockReset()
handleMock.mockImplementation((channel: string, handler: never) => {
handlers.set(channel, handler)
})
lstatMock.mockRejectedValue(enoent())
realpathMock.mockImplementation(async (filePath: string) => filePath)
renameMock.mockResolvedValue(undefined)
registerFilesystemMutationHandlers(store as never)
})
it('serializes exact Unicode aliases across IPC and runtime entry points', async () => {
const ipcSource = path.join(REPO_PATH, 'ipc-source.md')
const runtimeSource = path.join(REPO_PATH, 'runtime-source.md')
const sharpSDestination = path.join(REPO_PATH, 'Straße.md')
const expandedDestination = path.join(REPO_PATH, 'STRASSE.md')
const firstRenameGate = createGate()
let destinationExists = false
lstatMock.mockImplementation(async (filePath: string) => {
if (
destinationExists &&
(filePath === sharpSDestination || filePath === expandedDestination)
) {
return mockStats(1, 30)
}
if (filePath === ipcSource) {
return mockStats(1, 10)
}
if (filePath === runtimeSource) {
return mockStats(1, 20)
}
throw enoent()
})
renameMock.mockImplementation(async () => {
if (renameMock.mock.calls.length === 1) {
await firstRenameGate.promise
}
destinationExists = true
})
const ipcRename = handlers.get('fs:rename')!(null, {
oldPath: ipcSource,
newPath: sharpSDestination
})
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
const runtimeRename = createRuntimeCommands().renameFileExplorerPath(
'id:wt-1',
'runtime-source.md',
'STRASSE.md',
undefined,
undefined,
'local'
)
await reachNextMacrotask()
const callsWhileFirstBlocked = renameMock.mock.calls.length
firstRenameGate.release()
const results = await Promise.allSettled([ipcRename, runtimeRename])
expect(callsWhileFirstBlocked).toBe(1)
expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected'])
expect(renameMock).toHaveBeenCalledTimes(1)
})
it('releases an alias queue after the leading rename errors', async () => {
const firstRenameGate = createGate()
renameMock.mockImplementation(async () => {
if (renameMock.mock.calls.length === 1) {
await firstRenameGate.promise
throw new Error('EACCES')
}
})
const destination = path.join(REPO_PATH, 'destination.md')
const firstRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'first.md'),
destination
)
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
const secondRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'second.md'),
destination
)
await reachNextMacrotask()
expect(renameMock).toHaveBeenCalledTimes(1)
firstRenameGate.release()
await expect(firstRename).rejects.toThrow('EACCES')
await expect(secondRename).resolves.toBeUndefined()
expect(renameMock).toHaveBeenCalledTimes(2)
})
it('does not serialize destinations in unrelated parent directories', async () => {
const firstRenameGate = createGate()
renameMock.mockImplementation(async () => {
if (renameMock.mock.calls.length === 1) {
await firstRenameGate.promise
}
})
const firstRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'first', 'source.md'),
path.join(REPO_PATH, 'first', 'alpha.md')
)
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
const secondRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'second', 'source.md'),
path.join(REPO_PATH, 'second', 'beta.md')
)
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(2))
firstRenameGate.release()
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
})
it('serializes symlink aliases of one destination parent', async () => {
const firstRenameGate = createGate()
renameMock.mockImplementation(async () => {
if (renameMock.mock.calls.length === 1) {
await firstRenameGate.promise
}
})
const firstParent = path.join(REPO_PATH, 'parent-link-a')
const secondParent = path.join(REPO_PATH, 'parent-link-b')
const canonicalParent = path.join(REPO_PATH, 'canonical-parent')
realpathMock.mockImplementation(async (filePath: string) => {
if (filePath === firstParent || filePath === secondParent) {
return canonicalParent
}
return filePath
})
const firstRename = renameLocalPathSerializedByDestination(
path.join(firstParent, 'source.md'),
path.join(firstParent, 'alpha.md')
)
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
const secondRename = renameLocalPathSerializedByDestination(
path.join(secondParent, 'source.md'),
path.join(secondParent, 'beta.md')
)
await reachNextMacrotask()
expect(renameMock).toHaveBeenCalledTimes(1)
firstRenameGate.release()
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
expect(renameMock).toHaveBeenCalledTimes(2)
})
it('scopes conservative serialization to one destination parent', async () => {
const firstRenameGate = createGate()
renameMock.mockImplementation(async () => {
if (renameMock.mock.calls.length === 1) {
await firstRenameGate.promise
}
})
const firstRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'first.md'),
path.join(REPO_PATH, 'dotless-ı.md')
)
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
const secondRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'second.md'),
path.join(REPO_PATH, 'dotless-I.md')
)
await reachNextMacrotask()
expect(renameMock).toHaveBeenCalledTimes(1)
firstRenameGate.release()
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
expect(renameMock).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,50 @@
import { realpath, rename } from 'node:fs/promises'
import { dirname, normalize } from 'node:path'
import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
// Why: parent scope covers native Unicode aliases without guessing each filesystem's collation.
const pendingRenamesByParent = new Map<string, Promise<void>>()
function isENOENT(error: unknown): boolean {
return (
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
)
}
async function destinationParentKey(filePath: string): Promise<string> {
const parentPath = dirname(filePath)
try {
return normalize(await realpath(parentPath))
} catch (error) {
// A missing parent will fail the rename; retain its path so that failure
// does not prevent unrelated destinations from progressing.
if (isENOENT(error)) {
return normalize(parentPath)
}
throw error
}
}
export async function renameLocalPathSerializedByDestination(
oldPath: string,
newPath: string
): Promise<void> {
const key = await destinationParentKey(newPath)
const previous = pendingRenamesByParent.get(key) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>((resolve) => {
release = resolve
})
pendingRenamesByParent.set(key, current)
await previous
try {
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
await rename(oldPath, newPath)
} finally {
release()
if (pendingRenamesByParent.get(key) === current) {
pendingRenamesByParent.delete(key)
}
}
}
+67
View File
@@ -171,6 +171,36 @@ describe('registerFilesystemMutationHandlers', () => {
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
})
it('serializes concurrent local renames targeting the same destination', async () => {
const firstPath = path.resolve('/workspace/repo/first.ts')
const secondPath = path.resolve('/workspace/repo/second.ts')
const destinationPath = path.resolve('/workspace/repo/destination.ts')
let destinationExists = false
lstatMock.mockImplementation(async (filePath: string) => {
if (filePath === destinationPath && destinationExists) {
return mockStats(1, 30)
}
if (filePath === firstPath) {
return mockStats(1, 10)
}
if (filePath === secondPath) {
return mockStats(1, 20)
}
throw enoent()
})
renameMock.mockImplementation(async () => {
destinationExists = true
})
const results = await Promise.allSettled([
handlers.get('fs:rename')!(null, { oldPath: firstPath, newPath: destinationPath }),
handlers.get('fs:rename')!(null, { oldPath: secondPath, newPath: destinationPath })
])
expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected'])
expect(renameMock).toHaveBeenCalledTimes(1)
})
it('rejects rename when destination already exists as a true collision', async () => {
const oldPath = path.resolve('/workspace/repo/old.ts')
const resolvedNewPath = path.resolve('/workspace/repo/new.ts')
@@ -197,12 +227,30 @@ describe('registerFilesystemMutationHandlers', () => {
it('allows case-only rename when destination is the same entry in the same parent', async () => {
const oldPath = path.resolve('/workspace/repo/README.md')
const newPath = path.resolve('/workspace/repo/readme.md')
const canonicalPath = path.resolve('/workspace/repo/README.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(2, 20)
}
throw enoent()
})
mockRealpath({ [oldPath]: canonicalPath, [newPath]: canonicalPath })
await handlers.get('fs:rename')!(null, { oldPath, newPath })
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
})
it('allows a native Unicode alias rename for the same directory entry', async () => {
const oldPath = path.resolve('/workspace/repo/Straße.md')
const newPath = path.resolve('/workspace/repo/STRASSE.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(2, 21)
}
throw enoent()
})
mockRealpath({ [oldPath]: oldPath, [newPath]: oldPath })
await handlers.get('fs:rename')!(null, { oldPath, newPath })
@@ -226,6 +274,25 @@ describe('registerFilesystemMutationHandlers', () => {
expect(renameMock).not.toHaveBeenCalled()
})
it('fails closed when same-entry realpath encounters a permission error', async () => {
const oldPath = path.resolve('/workspace/repo/README.md')
const newPath = path.resolve('/workspace/repo/readme.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(3, 30)
}
throw enoent()
})
realpathMock.mockRejectedValue(
Object.assign(new Error('permission denied'), { code: 'EACCES' })
)
await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toMatchObject({
code: 'EACCES'
})
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects cross-parent case-only rename collisions even when dev and ino match', async () => {
const oldPath = path.resolve('/workspace/repo/src/README.md')
const newPath = path.resolve('/workspace/repo/docs/readme.md')
+2 -4
View File
@@ -9,7 +9,6 @@ import {
open,
readdir,
realpath,
rename,
rm,
unlink,
writeFile
@@ -21,9 +20,9 @@ import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesy
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution'
import { importExternalPathsSsh } from './filesystem-import-ssh'
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
import type { SshMutationExpectation } from '../../shared/ssh-types'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename'
/**
* Re-throw filesystem errors with user-friendly messages.
@@ -146,8 +145,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
// accidentally write into a symlinked destination name.
const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true })
const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true })
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
await rename(oldPath, newPath)
await renameLocalPathSerializedByDestination(oldPath, newPath)
}
)
+26 -1
View File
@@ -151,7 +151,6 @@ describe('registerMobileHandlers', () => {
pairingUrl: 'orca://pair#mobile',
endpoint: 'ws://100.102.47.57:6768',
deviceId: 'mobile-1',
// The encoded mode passes through so the UI can flag a degraded mint.
connectionMode: 'automatic'
})
@@ -163,6 +162,32 @@ describe('registerMobileHandlers', () => {
})
})
it('forwards structured Relay mint failures to the renderer', async () => {
networkInterfacesMock.mockReturnValue({
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }]
})
const relayFailure = {
code: 'relay_mint_failed',
stage: 'create_pairing_relay',
message: 'Relay pairing invite request failed'
}
const createMobilePairingOffer = vi.fn().mockResolvedValue({
available: false,
reason: 'relay_mint_failed',
guidance: 'Use LAN or retry Relay.',
relayFailure
})
registerMobileHandlers({ createMobilePairingOffer } as never)
await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toEqual({
available: false,
reason: 'relay_mint_failed',
guidance: 'Use LAN or retry Relay.',
relayFailure
})
})
it('forwards an explicit local-only pairing choice', async () => {
networkInterfacesMock.mockReturnValue({
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }]
+14 -5
View File
@@ -126,7 +126,12 @@ export function registerMobileHandlers(
// ZeroTier) where the default LAN IP isn't reachable from the phone.
const ip = args?.address ?? getDefaultPairingAddress()
if (!ip) {
return { available: false as const }
return {
available: false as const,
reason: 'invalid_advertised_endpoint',
guidance:
'No reachable network address is available for pairing. Connect to Wi‑Fi or Tailscale, or pick an address manually.'
}
}
// Why: coalesce repeated QR regenerations onto a single never-scanned
@@ -143,7 +148,14 @@ export function registerMobileHandlers(
name: `Mobile ${new Date().toLocaleDateString()}`
})
if (!offer.available) {
return { available: false as const }
// Why: surface Relay mint failures (and other pairing unavailability)
// so the UI can refuse a silent LAN QR under the Relay label.
return {
available: false as const,
reason: offer.reason,
guidance: offer.guidance,
...(offer.relayFailure ? { relayFailure: offer.relayFailure } : {})
}
}
const qr = await (dependencies.encodePairingQr ?? encodeMobilePairingQr)(offer.pairingUrl)
@@ -155,9 +167,6 @@ export function registerMobileHandlers(
pairingUrl: offer.pairingUrl,
endpoint: offer.endpoint,
deviceId: offer.deviceId,
// Why: an automatic request can degrade to a local-only offer when
// Relay provisioning fails; the UI needs the encoded mode to avoid
// labeling a LAN-only code as Relay.
connectionMode: offer.connectionMode
}
}
@@ -12,6 +12,7 @@ import {
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { Store } from '../persistence'
import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification'
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
import {
callRuntimeEnvironment,
@@ -64,6 +65,16 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
return { environment: redactRuntimeEnvironment(environment) }
}
)
ipcMain.handle(
'runtimeEnvironments:verifyAndAddFromPairingCode',
async (_event, args: { name: string; pairingCode: string; allowLoopback?: boolean }) => {
const result = await verifyAndAddRuntimeEnvironmentFromPairingCode(getUserDataPath(), args)
if (result.ok) {
manuallyDisconnectedEnvironmentIds.delete(result.environment.id)
}
return result
}
)
ipcMain.handle('runtimeEnvironments:resolve', (_event, args: { selector: string }) =>
redactRuntimeEnvironment(resolveEnvironment(getUserDataPath(), args.selector))
)
@@ -0,0 +1,13 @@
export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:verifyAndAddFromPairingCode',
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
'runtimeEnvironments:connect',
'runtimeEnvironments:getStatus',
'runtimeEnvironments:call',
'runtimeEnvironments:subscribe',
'runtimeEnvironments:unsubscribe'
] as const
@@ -0,0 +1,140 @@
import {
addEnvironmentFromPairingCode,
RuntimeEnvironmentStoreError
} from '../../shared/runtime-environment-store'
import { parseHostAccessLink } from '../../shared/remote-pairing-address'
import {
verifyRemotePairingRuntimeStatus,
type VerifyAndAddRuntimeEnvironmentResult
} from '../../shared/remote-pairing-verification'
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client'
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
import type { RuntimeStatus } from '../../shared/runtime-types'
type VerifyAndAddRuntimeEnvironmentArgs = {
name: string
pairingCode: string
allowLoopback?: boolean
}
export async function verifyAndAddRuntimeEnvironmentFromPairingCode(
userDataPath: string,
args: VerifyAndAddRuntimeEnvironmentArgs
): Promise<VerifyAndAddRuntimeEnvironmentResult> {
const parsed = parseHostAccessLink(args.pairingCode)
if (!parsed.ok) {
return { ok: false, kind: 'access-link-invalid', message: parsed.message }
}
if (parsed.value.endpointKind === 'loopback' && !args.allowLoopback) {
return {
ok: false,
kind: 'host-unreachable',
message: 'This access link points back to this device.'
}
}
let runtimeStatus: RuntimeStatus
try {
const response = await sendRemoteRuntimeRequest<RuntimeStatus>(
parsed.value.pairing,
'status.get',
undefined,
15_000
)
if (!response.ok) {
return {
ok: false,
kind: 'connection-interrupted',
message: response.error.message
}
}
const statusVerification = verifyRemotePairingRuntimeStatus(response.result)
if (!statusVerification.ok) {
return statusVerification
}
runtimeStatus = statusVerification.runtimeStatus
} catch (error) {
return classifyPairingVerificationError(error, parsed.value.displayEndpoint)
}
const usesSshTunnel = parsed.value.endpointKind === 'loopback' && args.allowLoopback === true
let environment: ReturnType<typeof addEnvironmentFromPairingCode>
try {
environment = addEnvironmentFromPairingCode(userDataPath, {
...args,
...(usesSshTunnel ? { connectionDependency: 'ssh-tunnel' as const } : {})
})
} catch (error) {
return {
ok: false,
kind: 'environment-save-failed',
message:
error instanceof RuntimeEnvironmentStoreError && error.code === 'invalid_argument'
? error.message
: 'Orca verified the host but could not save it. Check local settings storage and try again.'
}
}
return {
ok: true,
environment: redactRuntimeEnvironment(environment),
runtimeStatus
}
}
function classifyPairingVerificationError(
error: unknown,
endpoint: string
): VerifyAndAddRuntimeEnvironmentResult {
if (error instanceof RemoteRuntimeClientError) {
if (error.code === 'invalid_argument') {
return invalidAccessLinkResult()
}
if (error.code === 'unauthorized' || error.pairingStage === 'access-grant') {
return {
ok: false,
kind: 'access-link-invalid',
message: 'This access link is no longer valid. Generate a new link on the other host.'
}
}
if (error.pairingStage === 'host-identity') {
return {
ok: false,
kind: 'host-identity-mismatch',
message: `Orca reached ${endpoint}, but that host does not match this access link.`
}
}
if (error.pairingStage === 'runtime') {
return {
ok: false,
kind: 'connection-interrupted',
message: `The connection to ${endpoint} was interrupted during verification.`
}
}
if (error.pairingStage === 'connect') {
return unreachableHostResult(endpoint)
}
}
if (error instanceof Error) {
return error.message.startsWith('Invalid public key')
? invalidAccessLinkResult()
: { ok: false, kind: 'connection-interrupted', message: error.message }
}
return unreachableHostResult(endpoint)
}
function invalidAccessLinkResult(): VerifyAndAddRuntimeEnvironmentResult {
return {
ok: false,
kind: 'access-link-invalid',
message: 'This access link contains invalid connection details.'
}
}
function unreachableHostResult(endpoint: string): VerifyAndAddRuntimeEnvironmentResult {
return {
ok: false,
kind: 'host-unreachable',
message: `Cannot reach Orca at ${endpoint}. Confirm the other host is running and reachable.`
}
}
+186 -1
View File
@@ -5,8 +5,12 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { encodePairingOffer } from '../../shared/pairing'
import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version'
import {
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY
} from '../../shared/protocol-version'
import * as environmentStore from '../../shared/runtime-environment-store'
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
const {
handleMock,
@@ -79,6 +83,18 @@ function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
})
}
function runtimeStatus(): Record<string, unknown> {
return {
runtimeId: 'runtime-a',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 0,
liveLeafCount: 0,
protocolVersion: 999_999
}
}
function handler<TArgs, TResult>(
channel: string
): (_event: unknown, args: TArgs) => TResult | Promise<TResult> {
@@ -132,6 +148,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(handleMock.mock.calls.map((call) => call[0])).toEqual([
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:verifyAndAddFromPairingCode',
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
@@ -153,6 +170,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(removeHandlerMock.mock.calls.map((call) => call[0])).toEqual([
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:verifyAndAddFromPairingCode',
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
@@ -211,6 +229,173 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(await list(null, undefined)).toEqual([])
})
it('blocks loopback before verification unless an SSH tunnel is declared', async () => {
registerRuntimeEnvironmentHandlers(store as never)
const verifyAndAdd = handler<
{ name: string; pairingCode: string; allowLoopback?: boolean },
{ ok: boolean; kind?: string }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
await expect(
verifyAndAdd(null, { name: 'desk', pairingCode: pairingCode() })
).resolves.toMatchObject({
ok: false,
kind: 'host-unreachable'
})
expect(sendRemoteRuntimeRequestMock).not.toHaveBeenCalled()
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
})
it('verifies identity, access, status, and compatibility before saving', async () => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
result: runtimeStatus(),
_meta: { runtimeId: 'runtime-a' }
})
const verifyAndAdd = handler<
{ name: string; pairingCode: string; allowLoopback?: boolean },
{ ok: boolean; environment?: { name: string; connectionDependency?: string } }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
const result = await verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode(),
allowLoopback: true
})
expect(result).toMatchObject({
ok: true,
environment: { name: 'desk', connectionDependency: 'ssh-tunnel' }
})
expect(sendRemoteRuntimeRequestMock).toHaveBeenCalledWith(
expect.objectContaining({ endpoint: 'ws://127.0.0.1:6768' }),
'status.get',
undefined,
15_000
)
expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1)
})
it('does not mark non-loopback hosts as SSH-tunnel dependent', async () => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
result: runtimeStatus(),
_meta: { runtimeId: 'runtime-a' }
})
const verifyAndAdd = handler<
{ name: string; pairingCode: string; allowLoopback?: boolean },
{ ok: boolean; environment?: { connectionDependency?: string } }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
const result = await verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768'),
allowLoopback: true
})
expect(result).toMatchObject({ ok: true })
expect(result.environment).not.toHaveProperty('connectionDependency')
})
it.each([
[{ protocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION - 1 }, 'protocol-incompatible'],
[{ protocolVersion: 999_999, deviceScope: 'mobile' }, 'access-link-invalid'],
[null, 'connection-interrupted']
])('does not save a host with rejected status %o', async (status, expectedKind) => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
result: status,
_meta: { runtimeId: 'runtime-a' }
})
const verifyAndAdd = handler<
{ name: string; pairingCode: string },
{ ok: boolean; kind?: string }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
await expect(
verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768')
})
).resolves.toMatchObject({ ok: false, kind: expectedKind })
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
})
it.each([
[
new RemoteRuntimeClientError('remote_runtime_unavailable', 'closed', {
pairingStage: 'host-identity',
closeCode: 4001
}),
'host-identity-mismatch'
],
[
new RemoteRuntimeClientError('unauthorized', 'rejected', {
pairingStage: 'access-grant'
}),
'access-link-invalid'
],
[
new RemoteRuntimeClientError('remote_runtime_unavailable', 'offline', {
pairingStage: 'connect'
}),
'host-unreachable'
],
[new Error('Invalid public key: expected 32 bytes, got 3'), 'access-link-invalid']
] as const)('returns a structured pairing failure for %s', async (error, expectedKind) => {
registerRuntimeEnvironmentHandlers(store as never)
sendRemoteRuntimeRequestMock.mockRejectedValue(error)
const verifyAndAdd = handler<
{ name: string; pairingCode: string },
{ ok: boolean; kind?: string; message?: string }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
const result = await verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768')
})
expect(result).toMatchObject({ ok: false, kind: expectedKind })
expect(result.message).not.toContain('4001')
expect(environmentStore.listEnvironments(userDataPath)).toEqual([])
})
it('returns a structured failure when a verified host cannot be persisted', async () => {
registerRuntimeEnvironmentHandlers(store as never)
environmentStore.addEnvironmentFromPairingCode(userDataPath, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768')
})
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'status',
ok: true,
result: runtimeStatus(),
_meta: { runtimeId: 'runtime-a' }
})
const verifyAndAdd = handler<
{ name: string; pairingCode: string },
{ ok: boolean; kind?: string; message?: string }
>('runtimeEnvironments:verifyAndAddFromPairingCode')
await expect(
verifyAndAdd(null, {
name: 'desk',
pairingCode: pairingCode('ws://100.76.32.125:6768')
})
).resolves.toMatchObject({
ok: false,
kind: 'environment-save-failed',
message: 'A server named "desk" already exists.'
})
expect(environmentStore.listEnvironments(userDataPath)).toHaveLength(1)
})
it('requires an explicit Advanced selection before removing the Active Server', async () => {
registerRuntimeEnvironmentHandlers(store as never)
const add = handler<
+1 -13
View File
@@ -19,19 +19,7 @@ import {
resetSharedControlSupport,
subscribeRuntimeEnvironment
} from './runtime-environment-transport-routing'
const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
'runtimeEnvironments:list',
'runtimeEnvironments:addFromPairingCode',
'runtimeEnvironments:resolve',
'runtimeEnvironments:remove',
'runtimeEnvironments:disconnect',
'runtimeEnvironments:connect',
'runtimeEnvironments:getStatus',
'runtimeEnvironments:call',
'runtimeEnvironments:subscribe',
'runtimeEnvironments:unsubscribe'
] as const
import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels'
type RetainedRemoteRuntimeSubscription = RemoteRuntimeSubscription & {
environmentId: string
+36 -19
View File
@@ -102,13 +102,15 @@ export class DeviceRegistry {
}
removeDevice(deviceId: string): boolean {
const before = this.devices.length
this.devices = this.devices.filter((d) => d.deviceId !== deviceId)
if (this.devices.length < before) {
this.save()
return true
const nextDevices = this.devices.filter((d) => d.deviceId !== deviceId)
if (nextDevices.length === this.devices.length) {
return false
}
return false
// Why: persist before memory swap so a failed write does not drop a device
// only in-process while disk still lists it (and vice versa on reload).
this.save(nextDevices)
this.devices = nextDevices
return true
}
getDevice(deviceId: string): DeviceEntry | null {
@@ -120,22 +122,30 @@ export class DeviceRegistry {
}
setRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean {
const device = this.devices.find((candidate) => candidate.deviceId === deviceId)
if (!device || binding.relayDeviceId !== deviceId) {
const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId)
if (index < 0 || binding.relayDeviceId !== deviceId) {
return false
}
device.relayBinding = binding
this.save()
const nextDevices = this.devices.map((device, candidateIndex) =>
candidateIndex === index ? { ...device, relayBinding: binding } : device
)
this.save(nextDevices)
this.devices = nextDevices
return true
}
setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean {
const device = this.devices.find((candidate) => candidate.deviceId === deviceId)
if (!device || device.scope !== 'mobile') {
const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId)
if (index < 0 || this.devices[index]?.scope !== 'mobile') {
return false
}
device.mobilePairingConnectionMode = mode
this.save()
// Why: persist before swapping memory so a failed write does not leave a
// mode the UI/runtime believe was stored.
const nextDevices = this.devices.map((device, candidateIndex) =>
candidateIndex === index ? { ...device, mobilePairingConnectionMode: mode } : device
)
this.save(nextDevices)
this.devices = nextDevices
return true
}
@@ -158,11 +168,18 @@ export class DeviceRegistry {
}
updateLastSeen(deviceId: string): void {
const device = this.devices.find((d) => d.deviceId === deviceId)
if (device) {
device.lastSeenAt = Date.now()
this.save()
const index = this.devices.findIndex((d) => d.deviceId === deviceId)
if (index < 0) {
return
}
// Why: persist before memory swap so a failed write cannot leave a scanned
// device looking never-scanned on disk, where rotation would drop it.
const seenAt = Date.now()
const nextDevices = this.devices.map((device, candidateIndex) =>
candidateIndex === index ? { ...device, lastSeenAt: seenAt } : device
)
this.save(nextDevices)
this.devices = nextDevices
}
private load(): void {
@@ -187,7 +204,7 @@ export class DeviceRegistry {
}
}
private save(devices: DeviceEntry[] = this.devices): void {
private save(devices: DeviceEntry[]): void {
writeSecureJsonFile(this.registryPath, devices)
}
}
@@ -0,0 +1,210 @@
import { describe, expect, it, vi } from 'vitest'
import type { IPtyProvider } from '../providers/types'
import type { Repo } from '../../shared/types'
import type { OrcaRuntimeService } from './orca-runtime'
import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation'
function createProvider(sessionIds: string[]): IPtyProvider {
return {
listProcesses: vi.fn(async () =>
sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' }))
),
shutdown: vi.fn(async () => {})
} as unknown as IPtyProvider
}
function createRuntime(): OrcaRuntimeService {
return {
stopTerminalsForWorktree: vi.fn(async () => ({ stopped: 0 }))
} as unknown as OrcaRuntimeService
}
const localRepo: Repo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 1
}
describe('stopMissingWorktreeTerminals', () => {
it('stops only locally owned worktrees absent from the authoritative scan', async () => {
const deletedId = 'repo-1::/workspace/deleted'
const survivingId = 'repo-1::/workspace/surviving'
const provider = createProvider([
`${deletedId}@@deleted-session`,
`${survivingId}@@surviving-session`
])
const runtime = createRuntime()
const result = await stopMissingWorktreeTerminals(
localRepo,
[deletedId, survivingId, 'repo-2::/workspace/other'],
[survivingId],
{
runtime,
getLocalProvider: () => provider,
getSshProvider: () => undefined
}
)
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
expect(provider.shutdown).toHaveBeenCalledWith(
`${deletedId}@@deleted-session`,
expect.objectContaining({ immediate: true })
)
expect(provider.shutdown).not.toHaveBeenCalledWith(
`${survivingId}@@surviving-session`,
expect.anything()
)
})
it('uses the owning SSH provider without consulting the local provider', async () => {
const deletedId = 'repo-1::/workspace/deleted'
const localProvider = createProvider([`${deletedId}@@local-session`])
const sshProvider = createProvider([`${deletedId}@@ssh-session`])
const getSshProvider = vi.fn(() => sshProvider)
await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, [deletedId], [], {
runtime: createRuntime(),
getLocalProvider: () => localProvider,
getSshProvider
})
expect(getSshProvider).toHaveBeenCalledWith('ssh-1')
expect(sshProvider.shutdown).toHaveBeenCalledWith(
`${deletedId}@@ssh-session`,
expect.objectContaining({ immediate: true })
)
expect(localProvider.listProcesses).not.toHaveBeenCalled()
})
it('still stops graph-visible sessions when the owning provider is unavailable', async () => {
const deletedId = 'repo-1::/workspace/deleted'
const runtime = createRuntime()
const result = await stopMissingWorktreeTerminals(
{ ...localRepo, connectionId: 'ssh-1' },
[deletedId],
[],
{
runtime,
getLocalProvider: () => null,
getSshProvider: () => undefined
}
)
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith(deletedId)
})
// Why: an agent cleaning up workspaces deletes many at once. Enumerating the
// host once per missing worktree is O(N) relay round-trips carrying O(N^2)
// rows — on SSH that stalls teardown for minutes.
it('enumerates the host once for the whole sweep', async () => {
const ids = Array.from({ length: 25 }, (_, index) => `repo-1::/workspace/wt-${index}`)
const provider = createProvider(ids.map((id) => `${id}@@session`))
const result = await stopMissingWorktreeTerminals(
{ ...localRepo, connectionId: 'ssh-1' },
ids,
[],
{ runtime: createRuntime(), getLocalProvider: () => null, getSshProvider: () => provider }
)
expect(result.stoppedWorktreeIds).toHaveLength(ids.length)
expect(provider.listProcesses).toHaveBeenCalledTimes(1)
expect(provider.shutdown).toHaveBeenCalledTimes(ids.length)
})
// Why: real providers put listProcesses/shutdown on the prototype and use `this`;
// batching them behind a wrapper must not break that binding.
it('keeps provider method binding intact while batching', async () => {
class PrototypeProvider {
listCalls = 0
shutdownCalls: string[] = []
private readonly sessions: { id: string; cwd: string; title: string }[]
constructor(sessionIds: string[]) {
this.sessions = sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' }))
}
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
this.listCalls += 1
return this.sessions
}
async shutdown(sessionId: string): Promise<void> {
this.shutdownCalls.push(sessionId)
}
}
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b', 'repo-1::/workspace/c']
const provider = new PrototypeProvider(ids.map((id) => `${id}@@session`))
const result = await stopMissingWorktreeTerminals(
{ ...localRepo, connectionId: 'ssh-1' },
ids,
[],
{
runtime: createRuntime(),
getLocalProvider: () => null,
getSshProvider: () => provider as unknown as IPtyProvider
}
)
expect(result.stoppedWorktreeIds).toHaveLength(ids.length)
expect(provider.listCalls).toBe(1)
expect(provider.shutdownCalls).toHaveLength(ids.length)
})
// Why: the batching must not leak past the calls it was built for. If the proxy
// were the receiver, a provider whose own method called `this.listProcesses()`
// would silently read this sweep's cached snapshot instead of the live host.
it('does not serve the shared snapshot to provider-internal listProcesses', async () => {
class SelfListingProvider {
listCalls = 0
constructor(private readonly sessions: { id: string; cwd: string; title: string }[]) {}
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
this.listCalls += 1
return this.sessions
}
async shutdown(): Promise<void> {
// A provider that re-reads live state as part of stopping.
await this.listProcesses()
}
}
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b']
const provider = new SelfListingProvider(
ids.map((id) => ({ id: `${id}@@session`, cwd: '/workspace', title: 'shell' }))
)
await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], {
runtime: createRuntime(),
getLocalProvider: () => null,
getSshProvider: () => provider as unknown as IPtyProvider
})
// One shared sweep scan, plus each shutdown's own live re-read.
expect(provider.listCalls).toBe(1 + ids.length)
})
// Why: caching a rejected scan would let one transient failure suppress
// teardown for every remaining worktree in the sweep.
it('does not reuse a failed process scan', async () => {
const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b']
const listProcesses = vi
.fn()
.mockRejectedValueOnce(new Error('relay dropped'))
.mockResolvedValue([{ id: `${ids[1]}@@session`, cwd: '/workspace', title: 'shell' }])
const provider = { listProcesses, shutdown: vi.fn(async () => {}) } as unknown as IPtyProvider
await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], {
runtime: createRuntime(),
getLocalProvider: () => null,
getSshProvider: () => provider
})
expect(listProcesses.mock.calls.length).toBeGreaterThan(1)
expect(provider.shutdown).toHaveBeenCalledWith(
`${ids[1]}@@session`,
expect.objectContaining({ immediate: true })
)
})
})
@@ -0,0 +1,122 @@
import type { IPtyProvider } from '../providers/types'
import type { Repo } from '../../shared/types'
import { splitWorktreeId } from '../../shared/worktree-id'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
import type { OrcaRuntimeService } from './orca-runtime'
import { killAllProcessesForWorktree } from './worktree-teardown'
const MISSING_WORKTREE_TEARDOWN_CONCURRENCY = 4
// Why: every worktree in one sweep is already known-missing, so they can all be
// served by a single point-in-time process list. Without this, an agent that
// deletes N workspaces costs N full host enumerations — O(N) relay round-trips
// carrying O(N^2) rows, which is minutes of stalled teardown on a remote host.
//
// Safe only because this sweep is best-effort. Do NOT reuse this wrapper on a
// `requirePhysicalStop` path: that re-lists *after* shutdown to prove a PTY
// exited, and a pre-shutdown snapshot would make the proof read stale rows.
function withSharedProcessSnapshot(provider: IPtyProvider): IPtyProvider {
let snapshot: Promise<Awaited<ReturnType<IPtyProvider['listProcesses']>>> | null = null
return new Proxy(provider, {
get(target, property) {
if (property !== 'listProcesses') {
// Why: bind other members to `target`, not the proxy. With the proxy as
// receiver, a provider whose own method called `this.listProcesses()`
// would silently read this sweep's cached snapshot instead of the live
// host — the batching must not leak past the calls it was built for.
const member: unknown = Reflect.get(target, property)
return typeof member === 'function' ? member.bind(target) : member
}
return async (opts?: { deadlineMs?: number }) => {
const pending = (snapshot ??= target.listProcesses(opts))
try {
return await pending
} catch {
// Why: a shared *rejection* would let one transient relay failure suppress
// the provider sweep for every worktree in the batch — the exact leak this
// reconciliation exists to prevent. Failures fall back to a per-caller scan,
// restoring pre-batching isolation while success still costs one round-trip.
if (snapshot === pending) {
snapshot = null
}
return target.listProcesses(opts)
}
}
}
})
}
type MissingWorktreeTerminalReconciliationDeps = {
runtime: OrcaRuntimeService
getLocalProvider: () => IPtyProvider | null
getSshProvider: (connectionId: string) => IPtyProvider | undefined
onPtyStopped?: (ptyId: string) => void
}
export async function stopMissingWorktreeTerminals(
repo: Repo,
knownWorktreeIds: readonly string[],
detectedWorktreeIds: readonly string[],
deps: MissingWorktreeTerminalReconciliationDeps
): Promise<{ stoppedWorktreeIds: string[] }> {
const detectedIds = new Set(detectedWorktreeIds)
const missingIds = [
...new Set(
knownWorktreeIds.filter(
(worktreeId) =>
splitWorktreeId(worktreeId)?.repoId === repo.id && !detectedIds.has(worktreeId)
)
)
]
if (missingIds.length === 0) {
return { stoppedWorktreeIds: [] }
}
const ownedProvider = repo.connectionId
? deps.getSshProvider(repo.connectionId)
: deps.getLocalProvider()
const provider = ownedProvider ? withSharedProcessSnapshot(ownedProvider) : ownedProvider
if (!provider) {
const stoppedWorktreeIds = (
await mapWithConcurrency(
missingIds,
MISSING_WORKTREE_TEARDOWN_CONCURRENCY,
async (worktreeId) => {
try {
await deps.runtime.stopTerminalsForWorktree(worktreeId)
return worktreeId
} catch {
return null
}
}
)
).filter((worktreeId): worktreeId is string => worktreeId !== null)
return { stoppedWorktreeIds }
}
const stoppedWorktreeIds = (
await mapWithConcurrency(
missingIds,
MISSING_WORKTREE_TEARDOWN_CONCURRENCY,
async (worktreeId) => {
try {
await killAllProcessesForWorktree(worktreeId, {
runtime: deps.runtime,
localProvider: provider,
onPtyStopped: deps.onPtyStopped,
// Why: the shared process snapshot is only valid while nothing needs a
// post-shutdown re-list, so this sweep stays explicitly best-effort.
requirePhysicalStop: false,
...(repo.connectionId ? { includeLocalRegistry: false } : {})
})
return worktreeId
} catch (error) {
console.warn(`[worktree-teardown] Failed to stop missing workspace ${worktreeId}`, error)
return null
}
}
)
).filter((worktreeId): worktreeId is string => worktreeId !== null)
return { stoppedWorktreeIds }
}
+4 -1
View File
@@ -3,6 +3,9 @@ import { encodePairingOffer } from '../../shared/pairing'
import type { PairingOffer } from '../../shared/mobile-relay-pairing-offer'
import { encodeMobilePairingQr } from './mobile-pairing-qr'
// Keep every capacity probe in the same encoded payload family.
const FIXED_INVITE_EXPIRES_AT = Date.now() + 5 * 60_000
function pairingUrl(endpointLength: number, relay: boolean): string {
const prefix = 'wss://pair.example/'
const offer: PairingOffer = {
@@ -20,7 +23,7 @@ function pairingUrl(endpointLength: number, relay: boolean): string {
assignmentEpoch: 1,
relayHostId: 'a'.repeat(16),
inviteToken: 'b'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
inviteExpiresAt: FIXED_INVITE_EXPIRES_AT,
e2eeFraming: 2
}
}
+2 -3
View File
@@ -80,7 +80,6 @@ import {
isWatcherProcessFailure,
WatcherProcessFailure
} from '../ipc/parcel-watcher-process-failure'
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths'
import {
rankRuntimeMobileFilePaths,
@@ -89,6 +88,7 @@ import {
import { beginWatcherInstall } from '../ipc/watcher-removal-gate'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename'
const MOBILE_FILE_LIST_LIMIT = 5000
const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000
@@ -1728,8 +1728,7 @@ export class RuntimeFileCommands {
const store = this.host.requireStore()
const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true })
const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true })
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
await rename(oldPath, newPath)
await renameLocalPathSerializedByDestination(oldPath, newPath)
return { ok: true }
}
+658
View File
@@ -73,6 +73,7 @@ import { HeadlessEmulator } from '../daemon/headless-emulator'
import {
HEADLESS_RUNTIME_WINDOW_ID,
type RuntimeMobileSessionTabsResult,
type RuntimeSyncWindowGraph,
type RuntimeTerminalCreate
} from '../../shared/runtime-types'
import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts'
@@ -2514,6 +2515,130 @@ describe('OrcaRuntimeService', () => {
})
})
it('routes a launch-draft resolution to the handle-owning local and remote renderers', async () => {
const runtime = new OrcaRuntimeService(store)
const nativeChatLaunchDraftResolved = vi.fn()
const events: RuntimeClientEvent[] = []
runtime.setNotifier({ nativeChatLaunchDraftResolved } as never)
runtime.onClientEvent((event) => events.push(event))
runtime.attachWindow(1)
const graph: RuntimeSyncWindowGraph = {
tabs: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
],
mobileSessionTabs: [
{
worktree: 'repo-1::/tmp/worktree-a',
publicationEpoch: 'launch-draft-epoch',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'tab-1::pane:1',
parentTabId: 'tab-1',
leafId: 'pane:1',
title: 'Claude',
launchDraft: 'seed',
launchDraftCreatedAt: 7,
isActive: true
}
]
}
]
}
runtime.syncWindowGraph(1, graph)
const listed = await runtime.listMobileSessionTabs('branch:feature/foo')
const mobileTab = listed.tabs.find((tab) => tab.type === 'terminal')
if (!mobileTab?.terminal) {
throw new Error('expected mobile terminal handle')
}
expect(mobileTab).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 7 })
runtime.notifyNativeChatLaunchDraftResolved(mobileTab.terminal, {
text: 'seed',
createdAt: 7
})
expect(nativeChatLaunchDraftResolved).toHaveBeenCalledWith('tab-1', {
text: 'seed',
createdAt: 7
})
expect(events).toContainEqual({
type: 'nativeChatLaunchDraftResolved',
tabId: 'tab-1',
text: 'seed',
createdAt: 7
})
expect(runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot()).toContainEqual({
type: 'nativeChatLaunchDraftResolved',
tabId: 'tab-1',
text: 'seed',
createdAt: 7
})
const retired = (await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find(
(tab) => tab.type === 'terminal'
)
expect(retired).not.toHaveProperty('launchDraft')
expect(retired).not.toHaveProperty('launchDraftCreatedAt')
runtime.markRendererReloading(1)
const replay = runtime.syncWindowGraph(1, {
...graph,
mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({
...snapshot,
publicationEpoch: 'launch-draft-reload',
snapshotVersion: 2
}))
})
expect(replay.nativeChatLaunchDraftResolutions).toEqual([
{ tabId: 'tab-1', text: 'seed', createdAt: 7 }
])
expect(
(await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find(
(tab) => tab.type === 'terminal'
)
).not.toHaveProperty('launchDraft')
const reconciled = runtime.syncWindowGraph(1, {
...graph,
mobileSessionTabs: graph.mobileSessionTabs?.map((snapshot) => ({
...snapshot,
publicationEpoch: 'launch-draft-reload',
snapshotVersion: 3,
tabs: snapshot.tabs.map((tab) => {
if (tab.type !== 'terminal') {
return tab
}
return { ...tab, launchDraftCreatedAt: 8 }
})
}))
})
expect(reconciled.nativeChatLaunchDraftResolutions).toBeUndefined()
expect(
(await runtime.listMobileSessionTabs('branch:feature/foo')).tabs.find(
(tab) => tab.type === 'terminal'
)
).toMatchObject({ launchDraft: 'seed', launchDraftCreatedAt: 8 })
})
it('surfaces stale terminal handles for stranded panes and recovers after same-pane wake', async () => {
const runtime = new OrcaRuntimeService(store)
const tabId = 'tab-1'
@@ -34409,6 +34534,191 @@ describe('OrcaRuntimeService', () => {
expect(removeWorktreeLineage).not.toHaveBeenCalled()
})
it('revalidates missing worktrees before stopping their local provider sessions', async () => {
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
const survivingId = `${TEST_REPO_ID}::/tmp/surviving`
const localProvider = {
listProcesses: vi.fn(async () => [
{ id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' },
{ id: `${survivingId}@@surviving-session`, cwd: '/tmp/surviving', title: 'shell' }
]),
shutdown: vi.fn(async () => {})
}
const runtime = new OrcaRuntimeService(store, undefined, {
getLocalProvider: () => localProvider as never
})
vi.spyOn(
runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
'listDetectedWorktreesForResolvedRepo'
).mockResolvedValue({
repoId: TEST_REPO_ID,
authoritative: true,
source: 'git',
worktrees: [{ id: survivingId }] as never
})
const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
deletedId,
survivingId
])
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
expect(localProvider.shutdown).toHaveBeenCalledWith(
`${deletedId}@@deleted-session`,
expect.objectContaining({ immediate: true })
)
expect(localProvider.shutdown).not.toHaveBeenCalledWith(
`${survivingId}@@surviving-session`,
expect.anything()
)
})
// Why (#10562): the renderer purges its state regardless of the sweep result, so
// revalidating against a cached scan (30s TTL) that still lists an already-deleted
// directory would strand those PTYs permanently — nothing asks a second time.
it('revalidates against a fresh scan instead of a warm worktree-scan cache', async () => {
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
const localProvider = {
listProcesses: vi.fn(async () => [
{ id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' }
]),
shutdown: vi.fn(async () => {})
}
const runtime = new OrcaRuntimeService(store, undefined, {
getLocalProvider: () => localProvider as never
})
// Warm the scan cache while the worktree still exists.
vi.mocked(listWorktrees).mockResolvedValueOnce([
{ path: '/tmp/deleted', head: 'abc', branch: 'd', isBare: false, isMainWorktree: false }
])
await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [deletedId])
expect(localProvider.shutdown).not.toHaveBeenCalled()
// The worktree is now gone; the cached scan must not mask that.
vi.mocked(listWorktrees).mockResolvedValue([])
const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
deletedId
])
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
expect(localProvider.shutdown).toHaveBeenCalledWith(
`${deletedId}@@deleted-session`,
expect.objectContaining({ immediate: true })
)
})
it('does not stop sessions after a non-authoritative revalidation', async () => {
const localProvider = {
listProcesses: vi.fn(async () => []),
shutdown: vi.fn(async () => {})
}
const runtime = new OrcaRuntimeService(store, undefined, {
getLocalProvider: () => localProvider as never
})
vi.spyOn(
runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
'listDetectedWorktreesForResolvedRepo'
).mockResolvedValue({
repoId: TEST_REPO_ID,
authoritative: false,
source: 'metadata-fallback',
worktrees: []
})
await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
`${TEST_REPO_ID}::/tmp/deleted`
])
expect(localProvider.listProcesses).not.toHaveBeenCalled()
})
// Why: an explicit connection identity only narrows the selector, it must not
// change the grammar. Treating the selector as a bare repo id made every
// `path:`/`name:` selector fail repo_not_found on this path alone.
it('resolves non-id selectors when a connection identity is supplied', async () => {
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
const localProvider = {
listProcesses: vi.fn(async () => [
{ id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' }
]),
shutdown: vi.fn(async () => {})
}
const localRepo = store.getRepos()[0]
const runtime = new OrcaRuntimeService(
{ ...store, getRepos: () => [localRepo, { ...localRepo, connectionId: 'ssh-1' }] } as never,
undefined,
{ getLocalProvider: () => localProvider as never }
)
vi.spyOn(
runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
'listDetectedWorktreesForResolvedRepo'
).mockResolvedValue({
repoId: TEST_REPO_ID,
authoritative: true,
source: 'git',
worktrees: []
})
// `path:` is ambiguous across the two rows; connectionId null selects the local one.
const result = await runtime.teardownMissingManagedWorktreeTerminals(
`path:${localRepo.path}`,
[deletedId],
null
)
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
expect(localProvider.shutdown).toHaveBeenCalledWith(
`${deletedId}@@deleted-session`,
expect.objectContaining({ immediate: true })
)
})
it('uses the connection-scoped repo when local and SSH repo ids collide', async () => {
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
const localProvider = {
listProcesses: vi.fn(async () => []),
shutdown: vi.fn(async () => {})
}
const sshProvider = {
listProcesses: vi.fn(async () => [
{ id: `${deletedId}@@ssh-session`, cwd: '/tmp/deleted', title: 'shell' }
]),
shutdown: vi.fn(async () => {})
}
const localRepo = store.getRepos()[0]
const sshRepo = { ...localRepo, connectionId: 'ssh-1' }
const runtime = new OrcaRuntimeService(
{
...store,
getRepos: () => [localRepo, sshRepo]
} as never,
undefined,
{
getLocalProvider: () => localProvider as never,
getSshProvider: (connectionId) =>
connectionId === 'ssh-1' ? (sshProvider as never) : undefined
}
)
vi.spyOn(
runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown },
'listDetectedWorktreesForResolvedRepo'
).mockResolvedValue({
repoId: TEST_REPO_ID,
authoritative: true,
source: 'git',
worktrees: []
})
await runtime.teardownMissingManagedWorktreeTerminals(TEST_REPO_ID, [deletedId], 'ssh-1')
expect(sshProvider.shutdown).toHaveBeenCalledWith(
`${deletedId}@@ssh-session`,
expect.objectContaining({ immediate: true })
)
expect(localProvider.listProcesses).not.toHaveBeenCalled()
})
it('hydrates runtime detected lists with instance-validated legacy lineage', async () => {
const parentPath = join(tmpdir(), 'worktree-parent')
const childPath = join(tmpdir(), 'worktree-child')
@@ -36064,10 +36374,13 @@ describe('OrcaRuntimeService', () => {
(spawn.mock.calls[1]?.[0] as { env?: Record<string, string> } | undefined)?.env ?? {}
expectStablePaneKeyEnv(setupSpawnEnv)
const setupLeafId = setupSpawnEnv.ORCA_PANE_KEY.slice(`${setupSpawnEnv.ORCA_TAB_ID}:`.length)
// Why: a background CLI create adopts its tabs silently — surfaceOwner:false
// keeps the sidebar from scrolling to a workspace the user never asked for.
expect(revealTerminalSession).toHaveBeenLastCalledWith(result.worktree.id, {
ptyId: 'pty-setup',
title: 'Setup',
activate: false,
surfaceOwner: false,
tabId: setupSpawnEnv.ORCA_TAB_ID,
leafId: setupLeafId
})
@@ -36303,15 +36616,194 @@ describe('OrcaRuntimeService', () => {
const initialLeafId = initialSpawnEnv.ORCA_PANE_KEY.slice(
`${initialSpawnEnv.ORCA_TAB_ID}:`.length
)
// Why: the renderer treats a missing surfaceOwner as "reveal the owner", which
// scrolled the sidebar to background CLI creates.
expect(revealTerminalSession).toHaveBeenCalledWith(result.worktree.id, {
ptyId: 'pty-created-worktree',
title: null,
activate: false,
surfaceOwner: false,
tabId: initialSpawnEnv.ORCA_TAB_ID,
leafId: initialLeafId
})
})
it('does not surface the new workspace when a background CLI create launches its agent', async () => {
const runtime = new OrcaRuntimeService(store)
const activateWorktree = vi.fn()
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-background-agent' })
const spawn = vi.fn().mockResolvedValue({ id: 'pty-background-agent' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree,
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-background-agent')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-background-agent')
vi.mocked(getEffectiveHooks).mockReturnValue(null)
vi.mocked(listWorktrees).mockResolvedValue([
{
path: '/tmp/workspaces/runtime-background-agent',
head: 'def',
branch: 'runtime-background-agent',
isBare: false,
isMainWorktree: false
}
])
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-background-agent',
createdWithAgent: 'claude',
startup: { command: 'claude' }
})
// `orca worktree create --agent claude` without --activate: the agent tab is
// adopted, but the sidebar must stay on whatever the user was reading.
expect(activateWorktree).not.toHaveBeenCalled()
expect(revealTerminalSession).toHaveBeenCalledWith(
result.worktree.id,
expect.objectContaining({
ptyId: 'pty-background-agent',
activate: false,
surfaceOwner: false
})
)
})
it('still surfaces the new workspace when the caller explicitly activates it', async () => {
const runtime = new OrcaRuntimeService(store)
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-activated-agent' })
const spawn = vi.fn().mockResolvedValue({ id: 'pty-activated-agent' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-activated-agent')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-activated-agent')
vi.mocked(getEffectiveHooks).mockReturnValue(null)
vi.mocked(listWorktrees).mockResolvedValue([
{
path: '/tmp/workspaces/runtime-activated-agent',
head: 'def',
branch: 'runtime-activated-agent',
isBare: false,
isMainWorktree: false
}
])
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-activated-agent',
activate: true,
createdWithAgent: 'claude',
startup: { command: 'claude' }
})
// No-regression guard: an explicit --activate still reveals the workspace, so
// surfaceOwner must stay absent.
const revealPayload = revealTerminalSession.mock.calls[0]?.[1] as
| { surfaceOwner?: boolean }
| undefined
expect(revealTerminalSession).toHaveBeenCalledWith(
result.worktree.id,
expect.objectContaining({ ptyId: 'pty-activated-agent' })
)
expect(revealPayload).not.toHaveProperty('surfaceOwner')
})
it('still surfaces the new workspace when hooks run in the foreground', async () => {
const runtime = new OrcaRuntimeService(store)
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-hooks-agent' })
const spawn = vi.fn().mockResolvedValue({ id: 'pty-hooks-agent' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hooks-agent')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hooks-agent')
vi.mocked(getEffectiveHooks).mockReturnValue(null)
vi.mocked(listWorktrees).mockResolvedValue([
{
path: '/tmp/workspaces/runtime-hooks-agent',
head: 'def',
branch: 'runtime-hooks-agent',
isBare: false,
isMainWorktree: false
}
])
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-hooks-agent',
runHooks: true,
createdWithAgent: 'claude',
startup: { command: 'claude' }
})
const revealPayload = revealTerminalSession.mock.calls[0]?.[1] as
| { surfaceOwner?: boolean }
| undefined
expect(revealTerminalSession).toHaveBeenCalledWith(
result.worktree.id,
expect.objectContaining({ ptyId: 'pty-hooks-agent' })
)
expect(revealPayload).not.toHaveProperty('surfaceOwner')
})
it('honors split setup placement for CLI-created worktrees without startup agents', async () => {
const runtimeStore = {
...store,
@@ -36400,6 +36892,172 @@ describe('OrcaRuntimeService', () => {
)
})
it('does not surface the new workspace when a background create splits its setup pane', async () => {
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
setupScriptLaunchMode: 'split-horizontal' as const
})
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
const activateWorktree = vi.fn()
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-bg-setup-split' })
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-bg-split-main' })
.mockResolvedValueOnce({ id: 'pty-bg-split-setup' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree,
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-bg-split-setup')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-bg-split-setup')
vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { setup: 'pnpm install' } })
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
vi.mocked(createSetupRunnerScript).mockReturnValue({
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-bg-split-setup'
}
})
vi.mocked(listWorktrees).mockResolvedValue([
{
path: '/tmp/workspaces/runtime-bg-split-setup',
head: 'def',
branch: 'runtime-bg-split-setup',
isBare: false,
isMainWorktree: false
}
])
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-bg-split-setup',
setupDecision: 'run'
})
// Split launch modes reach the renderer through splitTerminal, not
// createTerminal — that path must suppress owner surfacing too.
expect(activateWorktree).not.toHaveBeenCalled()
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record<string, string> }).env ?? {}
await vi.waitFor(() =>
expect(revealTerminalSession).toHaveBeenLastCalledWith(
result.worktree.id,
expect.objectContaining({
ptyId: 'pty-bg-split-setup',
tabId: mainEnv.ORCA_TAB_ID,
activate: false,
surfaceOwner: false,
splitDirection: 'horizontal'
})
)
)
})
it('still surfaces the new workspace when an activating create splits its setup pane', async () => {
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
setupScriptLaunchMode: 'split-horizontal' as const
})
}
const runtime = new OrcaRuntimeService(runtimeStore as never)
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-active-setup-split' })
const spawn = vi
.fn()
.mockResolvedValueOnce({ id: 'pty-active-split-main' })
.mockResolvedValueOnce({ id: 'pty-active-split-setup' })
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-active-split-setup')
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-active-split-setup')
vi.mocked(getEffectiveHooks).mockReturnValue({ scripts: { setup: 'pnpm install' } })
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
vi.mocked(createSetupRunnerScript).mockReturnValue({
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/tmp/repo',
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-active-split-setup'
}
})
vi.mocked(listWorktrees).mockResolvedValue([
{
path: '/tmp/workspaces/runtime-active-split-setup',
head: 'def',
branch: 'runtime-active-split-setup',
isBare: false,
isMainWorktree: false
}
])
const result = await runtime.createManagedWorktree({
repoSelector: 'id:repo-1',
name: 'runtime-active-split-setup',
activate: true,
setupDecision: 'run',
createdWithAgent: 'claude',
startup: { command: 'claude' }
})
// No-regression guard: an explicit --activate still reveals the workspace.
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
await vi.waitFor(() =>
expect(revealTerminalSession).toHaveBeenLastCalledWith(
result.worktree.id,
expect.objectContaining({
ptyId: 'pty-active-split-setup',
splitDirection: 'horizontal'
})
)
)
const splitRevealPayload = revealTerminalSession.mock.lastCall?.[1] as
| { surfaceOwner?: boolean }
| undefined
expect(splitRevealPayload).not.toHaveProperty('surfaceOwner')
})
it('does not warn when setup is explicitly skipped for CLI-created worktrees', async () => {
const metaById: Record<string, WorktreeMeta> = {}
const runtimeStore = {
+296 -37
View File
@@ -324,6 +324,7 @@ import {
type RuntimeMobileSessionTabsRemovedResult,
type RuntimeMobileSessionTabsResult,
type RuntimeMobileSessionTabsSnapshot,
type RuntimeNativeChatLaunchDraftResolution,
type RuntimeSessionTabCloseReason,
type RuntimeBrowserDriverState,
type RuntimeTerminalDriverState,
@@ -929,6 +930,7 @@ import {
registerTerminalViewAttributesApplier
} from './terminal-view-attribute-store'
import { killAllProcessesForWorktree, teardownRpcDeadline } from './worktree-teardown'
import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation'
import {
MobileNotificationReplayBuffer,
type ReplayableMobileNotification
@@ -1271,6 +1273,10 @@ type TerminalCreateOptions = {
rendererBacked?: boolean
activate?: boolean
presentation?: RuntimeTerminalPresentation
// Why: `false` adopts the terminal without pointing the user at it — no
// sidebar reveal, no tab focus. Distinct from 'background' presentation,
// which skips renderer adoption entirely.
surfaceOwner?: false
tabId?: string
leafId?: string
sessionId?: string
@@ -1654,6 +1660,12 @@ function createTerminalRevealWarning(handle: string, error?: unknown): string {
].join(' ')
}
// Why: an absent `surfaceOwner` means "default", so surfacing callers must omit
// the key rather than send `true`.
function ownerSurfacing(shouldSurface: boolean): { surfaceOwner?: false } {
return shouldSurface ? {} : { surfaceOwner: false }
}
function resolveTerminalPresentation(opts: {
presentation?: RuntimeTerminalPresentation
focus?: boolean
@@ -1702,6 +1714,7 @@ type RuntimeNotifier = {
viewMode?: 'terminal' | 'chat'
activate?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
tabId?: string
leafId?: string
splitFromLeafId?: string
@@ -1777,6 +1790,10 @@ type RuntimeNotifier = {
// and so a future write coordinator can use the same signal as scheduling
// input. See docs/mobile-presence-lock.md.
terminalDriverChanged(ptyId: string, driver: DriverState): void
nativeChatLaunchDraftResolved?(
tabId: string,
resolution: { text: string; createdAt: number }
): void
browserDriverChanged?(browserPageId: string, driver: RuntimeBrowserDriverState): void
}
@@ -2582,6 +2599,12 @@ type LayoutQueueEntry = {
}[]
}
type NativeChatLaunchDraftResolutionTombstone = RuntimeNativeChatLaunchDraftResolution & {
worktreeId: string
}
const MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES = 200
async function hasLocalWorktreeBaseRef(
repoPath: string,
baseRef: string,
@@ -2702,6 +2725,10 @@ export class OrcaRuntimeService {
private ptyController: RuntimePtyController | null = null
private notifier: RuntimeNotifier | null = null
private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>()
private nativeChatLaunchDraftResolutionByTabId = new Map<
string,
NativeChatLaunchDraftResolutionTombstone
>()
private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>()
private forkBackfillStarted = false
private agentBrowserBridge: AgentBrowserBridge | null = null
@@ -4705,12 +4732,146 @@ export class OrcaRuntimeService {
return events
}
getNativeChatLaunchDraftResolutionClientEventSnapshot(): Extract<
RuntimeClientEvent,
{ type: 'nativeChatLaunchDraftResolved' }
>[] {
return [...this.nativeChatLaunchDraftResolutionByTabId.values()]
.sort((a, b) => a.tabId.localeCompare(b.tabId))
.map(({ tabId, text, createdAt }) => ({
type: 'nativeChatLaunchDraftResolved',
tabId,
text,
createdAt
}))
}
private emitClientEvent(event: RuntimeClientEvent): void {
// Why: a throwing subscriber here once escaped acquireWorktreeTerminalSpawn after it took the
// per-worktree terminal mutation, leaking it and wedging that worktree's sleep until restart.
notifyRuntimeListeners(this.clientEventListeners, (listener) => listener(event), 'client-event')
}
notifyNativeChatLaunchDraftResolved(
handle: string,
resolution: { text: string; createdAt: number }
): void {
const owner = this.resolveNativeChatLaunchDraftOwner(handle)
if (!owner) {
return
}
const tombstone = { ...owner, ...resolution }
this.nativeChatLaunchDraftResolutionByTabId.delete(owner.tabId)
this.nativeChatLaunchDraftResolutionByTabId.set(owner.tabId, tombstone)
while (
this.nativeChatLaunchDraftResolutionByTabId.size >
MAX_NATIVE_CHAT_LAUNCH_DRAFT_RESOLUTION_TOMBSTONES
) {
const oldestTabId = this.nativeChatLaunchDraftResolutionByTabId.keys().next().value
if (typeof oldestTabId !== 'string') {
break
}
this.nativeChatLaunchDraftResolutionByTabId.delete(oldestTabId)
}
this.retireResolvedNativeChatLaunchDraftFromMobileSnapshot(tombstone)
this.notifier?.nativeChatLaunchDraftResolved?.(owner.tabId, resolution)
this.emitClientEvent({
type: 'nativeChatLaunchDraftResolved',
tabId: owner.tabId,
...resolution
})
}
private resolveNativeChatLaunchDraftOwner(
handle: string
): { tabId: string; worktreeId: string } | null {
const record = this.handles.get(handle)
if (!record) {
return null
}
if (!record.tabId.startsWith('pty:')) {
return { tabId: record.tabId, worktreeId: record.worktreeId }
}
const pty = record.ptyId ? this.ptysById.get(record.ptyId) : null
const tabId =
pty?.tabId && !pty.tabId.startsWith('pty:')
? pty.tabId
: parsePaneKey(pty?.paneKey ?? '')?.tabId
if (!pty || !tabId || tabId.startsWith('pty:')) {
return null
}
return { tabId, worktreeId: pty.worktreeId }
}
private retireResolvedNativeChatLaunchDraftFromMobileSnapshot(
resolution: NativeChatLaunchDraftResolutionTombstone
): void {
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
if (!runtimeWorktreeIdsEqual(worktreeId, resolution.worktreeId)) {
continue
}
const next = this.applyNativeChatLaunchDraftResolutionFence(snapshot)
if (next === snapshot) {
return
}
this.mobileSessionTabsByWorktree.set(worktreeId, {
...next,
snapshotVersion: snapshot.snapshotVersion + 1
})
this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId)
return
}
}
private applyNativeChatLaunchDraftResolutionFence(
snapshot: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsSnapshot {
let changed = false
const tabs = snapshot.tabs.map((tab) => {
if (tab.type !== 'terminal') {
return tab
}
const resolution = this.nativeChatLaunchDraftResolutionByTabId.get(tab.parentTabId)
if (
!resolution ||
!runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId) ||
tab.launchDraft !== resolution.text ||
tab.launchDraftCreatedAt !== resolution.createdAt
) {
return tab
}
changed = true
const next = { ...tab }
delete next.launchDraft
delete next.launchDraftCreatedAt
return next
})
return changed ? { ...snapshot, tabs } : snapshot
}
private reconcileNativeChatLaunchDraftResolutionTombstones(
snapshot: RuntimeMobileSessionTabsSnapshot
): void {
for (const [tabId, resolution] of this.nativeChatLaunchDraftResolutionByTabId) {
if (!runtimeWorktreeIdsEqual(snapshot.worktree, resolution.worktreeId)) {
continue
}
const surfaces = snapshot.tabs.filter(
(tab): tab is RuntimeMobileSessionTerminalTab =>
tab.type === 'terminal' && tab.parentTabId === tabId
)
if (
surfaces.length === 0 ||
!surfaces.some(
(tab) =>
tab.launchDraft === resolution.text && tab.launchDraftCreatedAt === resolution.createdAt
)
) {
this.nativeChatLaunchDraftResolutionByTabId.delete(tabId)
}
}
}
private notifyWorktreesChanged(repoId: string): void {
this.notifier?.worktreesChanged(repoId)
this.emitClientEvent({ type: 'worktreesChanged', repoId })
@@ -5174,9 +5335,14 @@ export class OrcaRuntimeService {
}
const agentOrchestrationByPaneKey = this.buildAgentOrchestrationByPaneKey()
const nativeChatLaunchDraftResolutions =
this.getNativeChatLaunchDraftResolutionClientEventSnapshot().map(
({ tabId, text, createdAt }) => ({ tabId, text, createdAt })
)
return {
...this.getStatus(),
...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {})
...(agentOrchestrationByPaneKey ? { agentOrchestrationByPaneKey } : {}),
...(nativeChatLaunchDraftResolutions.length > 0 ? { nativeChatLaunchDraftResolutions } : {})
}
}
@@ -19425,8 +19591,18 @@ export class OrcaRuntimeService {
}
}
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
const repo = await this.resolveRepoSelector(repoSelector)
async listDetectedManagedWorktrees(
repoSelector: string,
connectionId?: string | null
): Promise<DetectedWorktreeListResult> {
return this.listDetectedWorktreesForResolvedRepo(
await this.resolveRepoSelectorForConnection(repoSelector, connectionId)
)
}
private async listDetectedWorktreesForResolvedRepo(
repo: Repo
): Promise<DetectedWorktreeListResult> {
const store = this.requireStore()
if (isFolderRepo(repo)) {
const worktrees = listRuntimeFolderWorkspaces(store, repo)
@@ -19476,6 +19652,56 @@ export class OrcaRuntimeService {
}
}
async teardownMissingManagedWorktreeTerminals(
repoSelector: string,
knownWorktreeIds: readonly string[],
connectionId?: string | null
): Promise<{ stoppedWorktreeIds: string[] }> {
const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId)
// Why: killing PTYs must be proven against the host right now — a cached scan
// (30s TTL) can still list a directory git already dropped, and the renderer
// purges its state either way, so a stale miss strands those processes for good.
this.invalidateWorktreeScanCacheForRepo(repo.id)
// Why: rescanning by `id:` would re-resolve the already-resolved repo, and a
// duplicate id across hosts makes that second lookup throw selector_ambiguous
// even though the caller's selector was unique — losing the sweep entirely.
const detected = await this.listDetectedWorktreesForResolvedRepo(repo)
if (!detected.authoritative) {
return { stoppedWorktreeIds: [] }
}
return stopMissingWorktreeTerminals(
repo,
knownWorktreeIds,
detected.worktrees.map((worktree) => worktree.id),
{
runtime: this,
getLocalProvider: () => this.getLocalProvider(),
getSshProvider: (connectionId) => this.getSshProviderFn?.(connectionId),
onPtyStopped: this.onPtyStopped ?? undefined
}
)
}
private resolveRepoSelectorForConnection(
repoSelector: string,
connectionId?: string | null
): Promise<Repo> {
if (connectionId === undefined) {
return this.resolveRepoSelector(repoSelector)
}
// Why: an explicit connection identity only *narrows* the selector; it must not
// change the grammar. Matching the selector as a bare repo id would make
// `path:`/`name:` selectors resolve to repo_not_found on this path alone.
const wanted = connectionId?.trim() || null
const matches = this.selectReposBySelector(repoSelector).filter(
(repo) => (repo.connectionId?.trim() || null) === wanted
)
if (matches.length !== 1) {
throw new Error(matches.length > 1 ? 'selector_ambiguous' : 'repo_not_found')
}
return Promise.resolve(matches[0])
}
private isRuntimeWorktreeVisible(
worktree: Worktree,
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
@@ -19942,7 +20168,8 @@ export class OrcaRuntimeService {
private async createDefaultTabTerminals(
worktreeSelector: string,
worktreeId: string,
defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined
defaultTabs: CreateWorktreeResult['defaultTabs'] | undefined,
surfacing: { surfaceOwner?: false } = {}
): Promise<string[]> {
if (!defaultTabs || defaultTabs.tabs.length === 0 || !this.ptyController?.spawn) {
return []
@@ -19953,7 +20180,8 @@ export class OrcaRuntimeService {
const command = template.command?.trim()
const terminal = await this.createTerminal(worktreeSelector, {
...(template.title ? { title: template.title } : {}),
...(command && defaultTabs.runCommands ? { command } : {})
...(command && defaultTabs.runCommands ? { command } : {}),
...surfacing
})
handles.push(terminal.handle)
if (template.color && terminal.tabId) {
@@ -19984,17 +20212,22 @@ export class OrcaRuntimeService {
// the setup command. Pass that wrapped command through so the Setup tab runs
// the same script the agent is waiting on instead of a bare runner.
wrappedSetupCommand?: string
// Why: a workspace provisioned in the background must not pull the sidebar
// to itself; the user never asked to look at these tabs.
surfaceOwner?: false
}): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> {
if (!this.ptyController?.spawn) {
return { setupSpawned: false, setupTerminalHandle: null }
}
const surfacing = ownerSurfacing(args.surfaceOwner !== false)
let setupSpawned = false
let setupTerminalHandle: string | null = null
try {
const defaultTabHandles = await this.createDefaultTabTerminals(
args.worktreeSelector,
args.worktreeId,
args.defaultTabs
args.defaultTabs,
surfacing
)
let primaryTerminalHandle = args.primaryTerminalHandle ?? defaultTabHandles[0] ?? null
const setupLaunchMode =
@@ -20004,7 +20237,7 @@ export class OrcaRuntimeService {
>
).setupScriptLaunchMode ?? 'new-tab'
if (!args.hasStartupTerminal && !primaryTerminalHandle) {
const terminal = await this.createTerminal(args.worktreeSelector)
const terminal = await this.createTerminal(args.worktreeSelector, surfacing)
primaryTerminalHandle = terminal.handle
}
if (args.setup) {
@@ -20030,12 +20263,14 @@ export class OrcaRuntimeService {
direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
command: setupCommand,
env: setupEnv,
activate: false
activate: false,
...surfacing
})
: this.createTerminal(args.worktreeSelector, {
title: 'Setup',
command: setupCommand,
env: setupEnv
env: setupEnv,
...surfacing
}))
setupTerminalHandle = setupTerminal.handle
setupSpawned = true
@@ -20314,7 +20549,8 @@ export class OrcaRuntimeService {
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
...(effectiveStartup.viewMode ? { viewMode: effectiveStartup.viewMode } : {}),
startupCommandDelivery: effectiveStartup.startupCommandDelivery,
telemetry: effectiveStartup.telemetry
telemetry: effectiveStartup.telemetry,
...ownerSurfacing(shouldActivate)
})
if (effectiveDraftPaste) {
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
@@ -20345,7 +20581,7 @@ export class OrcaRuntimeService {
}
} else if (this.ptyController?.spawn && !didSpawnStartup) {
try {
await this.createTerminal(`id:${worktree.id}`)
await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warning = warning
@@ -21054,7 +21290,8 @@ export class OrcaRuntimeService {
...(effectiveCreatedWithAgent ? { launchAgent: effectiveCreatedWithAgent } : {}),
...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}),
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
telemetry: sequencedStartup.telemetry
telemetry: sequencedStartup.telemetry,
...ownerSurfacing(shouldActivate)
})
if (effectiveDraftPaste) {
this.pasteStartupDraftWhenReady(terminal.handle, effectiveDraftPaste)
@@ -21155,7 +21392,8 @@ export class OrcaRuntimeService {
: 'posix'
: 'posix',
observeSetupCompletion: args.observeSetupCompletion,
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}),
surfaceOwner: false
})
// Why: runtime owns setup spawning here, so the RPC result must omit setup
// to keep the headless/mobile caller from launching it a second time.
@@ -21171,7 +21409,7 @@ export class OrcaRuntimeService {
}
} else if (this.ptyController?.spawn) {
try {
await this.createTerminal(`id:${worktree.id}`)
await this.createTerminal(`id:${worktree.id}`, { surfaceOwner: false })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warning = warning
@@ -21355,6 +21593,7 @@ export class OrcaRuntimeService {
this.invalidateWorktreeScanCacheForRepo(repo.id)
this.notifyWorktreesChanged(repo.id)
const shouldActivate = args.activate === true || args.runHooks === true
let warning = result.warning
let didSpawnStartup = false
// Why: same no-double-spawn contract as the local path — once runtime
@@ -21403,7 +21642,8 @@ export class OrcaRuntimeService {
...(args.createdWithAgent ? { launchAgent: args.createdWithAgent } : {}),
...(sequencedStartup.viewMode ? { viewMode: sequencedStartup.viewMode } : {}),
startupCommandDelivery: sequencedStartup.startupCommandDelivery,
telemetry: sequencedStartup.telemetry
telemetry: sequencedStartup.telemetry,
...ownerSurfacing(shouldActivate)
})
if (args.startupDraftPaste) {
this.pasteStartupDraftWhenReady(terminal.handle, args.startupDraftPaste)
@@ -21424,7 +21664,6 @@ export class OrcaRuntimeService {
}
}
const shouldActivate = args.activate === true || args.runHooks === true
if (shouldActivate) {
const runtimeWillProvisionTerminals =
didSpawnStartup && Boolean(result.setup || result.defaultTabs)
@@ -21506,7 +21745,8 @@ export class OrcaRuntimeService {
: 'posix'
: 'posix',
observeSetupCompletion: args.observeSetupCompletion,
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {})
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}),
surfaceOwner: false
})
// Why: runtime owns setup spawning here, so omit setup from the RPC result
// to keep the headless/mobile caller from launching it a second time.
@@ -21522,7 +21762,7 @@ export class OrcaRuntimeService {
}
} else if (!shouldActivate && this.ptyController?.spawn) {
try {
await this.createTerminal(`path:${result.worktree.path}`)
await this.createTerminal(`path:${result.worktree.path}`, { surfaceOwner: false })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
warning = warning
@@ -23948,6 +24188,7 @@ export class OrcaRuntimeService {
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...ownerSurfacing(opts.surfaceOwner !== false),
tabId,
leafId
})
@@ -24032,7 +24273,8 @@ export class OrcaRuntimeService {
startupCommandDelivery: launchOpts.startupCommandDelivery,
title: launchOpts.title,
activate: presentation === 'focused',
...(presentation ? { presentation } : {})
...(presentation ? { presentation } : {}),
...ownerSurfacing(opts.surfaceOwner !== false)
})
})
@@ -25172,6 +25414,9 @@ export class OrcaRuntimeService {
env?: Record<string, string>
envToDelete?: string[]
activate?: boolean
// Why: same split as createTerminal — adopt the pane without revealing its
// workspace, for splits the user never asked to see.
surfaceOwner?: false
telemetrySource?: TerminalPaneSplitSource
} = {}
): Promise<RuntimeTerminalSplit> {
@@ -25209,6 +25454,9 @@ export class OrcaRuntimeService {
env?: Record<string, string>
envToDelete?: string[]
activate?: boolean
// Why: same split as createTerminal — adopt the pane without revealing its
// workspace, for splits the user never asked to see.
surfaceOwner?: false
telemetrySource?: TerminalPaneSplitSource
} = {}
): Promise<RuntimeTerminalSplit> {
@@ -25259,6 +25507,7 @@ export class OrcaRuntimeService {
ptyId: result.id,
title: null,
activate: opts.activate !== false,
...ownerSurfacing(opts.surfaceOwner !== false),
tabId: parentTabId,
leafId,
splitFromLeafId: parsedPaneKey.leafId,
@@ -26684,27 +26933,32 @@ export class OrcaRuntimeService {
return this.store?.getAllWorkspaceLineage?.() ?? {}
}
// Why: one selector grammar, so connection-scoped resolution can narrow the same
// candidate set instead of reimplementing (and diverging from) the matching rules.
private selectReposBySelector(selector: string): Repo[] {
const repos = this.store?.getRepos() ?? []
if (selector.startsWith('id:')) {
return repos.filter((repo) => repo.id === selector.slice(3))
}
if (selector.startsWith('path:')) {
return repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5)))
}
if (selector.startsWith('name:')) {
return repos.filter((repo) => repo.displayName === selector.slice(5))
}
return repos.filter(
(repo) =>
repo.id === selector ||
runtimePathsEqual(repo.path, selector) ||
repo.displayName === selector
)
}
private async resolveRepoSelector(selector: string): Promise<Repo> {
if (!this.store) {
throw new Error('repo_not_found')
}
const repos = this.store.getRepos()
let candidates: Repo[]
if (selector.startsWith('id:')) {
candidates = repos.filter((repo) => repo.id === selector.slice(3))
} else if (selector.startsWith('path:')) {
candidates = repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5)))
} else if (selector.startsWith('name:')) {
candidates = repos.filter((repo) => repo.displayName === selector.slice(5))
} else {
candidates = repos.filter(
(repo) =>
repo.id === selector ||
runtimePathsEqual(repo.path, selector) ||
repo.displayName === selector
)
}
const candidates = this.selectReposBySelector(selector)
if (candidates.length === 1) {
return candidates[0]
@@ -27790,7 +28044,9 @@ export class OrcaRuntimeService {
) {
continue
}
const fencedSnapshot = this.applyMobileSessionRetirementFences(snapshot)
this.reconcileNativeChatLaunchDraftResolutionTombstones(snapshot)
const launchDraftFencedSnapshot = this.applyNativeChatLaunchDraftResolutionFence(snapshot)
const fencedSnapshot = this.applyMobileSessionRetirementFences(launchDraftFencedSnapshot)
const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(fencedSnapshot, existing)
// Why: clients drop same-epoch frames whose version isn't strictly newer,
// and main-local touches may already have emitted a higher version than
@@ -28405,6 +28661,9 @@ export class OrcaRuntimeService {
...(tab.isPinned ? { isPinned: true } : {}),
...(tab.viewMode ? { viewMode: tab.viewMode } : {}),
...(tab.launchDraft ? { launchDraft: tab.launchDraft } : {}),
...(tab.launchDraftCreatedAt !== undefined
? { launchDraftCreatedAt: tab.launchDraftCreatedAt }
: {}),
isActive: tab.isActive,
...(terminalHandle
? { status: 'ready' as const, terminal: terminalHandle }
+117 -5
View File
@@ -42,6 +42,7 @@ import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-c
import { parsePaneKey } from '../../../shared/stable-pane-id'
import { OrchestrationError } from './orchestration-error'
import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew'
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../shared/orchestration-run-pagination'
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version'
// Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys.
@@ -158,6 +159,28 @@ function exposeRunTimestamps(run: RunRow): RunRow {
}
}
function encodeRunListCursor(run: RunRow): string {
const cursor: RunListCursor = { createdAt: run.created_at, id: run.id }
return Buffer.from(JSON.stringify(cursor)).toString('base64url')
}
function decodeRunListCursor(value: string): RunListCursor {
try {
const parsed: unknown = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))
if (
!parsed ||
typeof parsed !== 'object' ||
typeof (parsed as RunListCursor).createdAt !== 'string' ||
typeof (parsed as RunListCursor).id !== 'string'
) {
throw new Error('invalid cursor shape')
}
return parsed as RunListCursor
} catch {
throw new OrchestrationError('cursor_invalid', 'The Run list cursor is invalid.')
}
}
function exposeDeliveryTimestamps(delivery: DeliveryRow): DeliveryRow {
return {
...delivery,
@@ -213,8 +236,21 @@ export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID
export const LEGACY_CONTRACT_VERSION = 0
export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance.
const SCHEMA_VERSION = 21
const MUTATION_RECEIPT_MAX_ROWS = 10_000
const MUTATION_RECEIPT_MAX_AGE_DAYS = 30
export type RunListPage = {
runs: RunRow[]
nextCursor: string | null
}
type RunListCursor = {
createdAt: string
id: string
}
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup.
const SCHEMA_VERSION = 22
function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void {
if (dbPath === ':memory:' || process.platform === 'win32') {
@@ -457,6 +493,7 @@ export class OrchestrationDb {
CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id);
CREATE INDEX IF NOT EXISTS idx_dispatch_status ON dispatch_contexts(status);
CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle ON dispatch_contexts(assignee_handle);
CREATE TABLE IF NOT EXISTS decision_gates (
id TEXT PRIMARY KEY,
@@ -835,6 +872,12 @@ export class OrchestrationDb {
if (current < 21) {
this.migrateLegacySchedulerLossProvenance()
}
if (current < 22) {
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_handle
ON dispatch_contexts(assignee_handle);
`)
}
this.createUndeliveredInboxIndexIfPossible()
this.db.pragma(`user_version = ${SCHEMA_VERSION}`)
@@ -1245,6 +1288,44 @@ export class OrchestrationDb {
// ── Durable mutation receipts ──
private ensureMutationReceiptCapacity(): void {
this.db
.prepare(
`DELETE FROM mutation_receipts
WHERE state = 'completed'
AND updated_at < datetime('now', ?)`
)
.run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`)
const row = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as {
count: number
}
const completedToRemove = row.count - MUTATION_RECEIPT_MAX_ROWS + 1
if (completedToRemove > 0) {
this.db
.prepare(
`DELETE FROM mutation_receipts
WHERE rowid IN (
SELECT rowid FROM mutation_receipts
WHERE state = 'completed'
ORDER BY updated_at ASC, rowid ASC
LIMIT ?
)`
)
.run(completedToRemove)
}
const retained = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as {
count: number
}
if (retained.count >= MUTATION_RECEIPT_MAX_ROWS) {
throw new OrchestrationError(
'mutation_ledger_full',
'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.'
)
}
}
beginMutationReceipt(params: {
callerFingerprint: string
requestId: string
@@ -1267,6 +1348,7 @@ export class OrchestrationDb {
this.db.exec('COMMIT')
return { disposition: existing.state, row: existing }
}
this.ensureMutationReceiptCapacity()
this.db
.prepare(
`INSERT INTO mutation_receipts (
@@ -2221,10 +2303,38 @@ export class OrchestrationDb {
return run ? exposeRunTimestamps(run) : undefined
}
listRuns(): RunRow[] {
return (this.db.prepare('SELECT * FROM runs ORDER BY created_at DESC').all() as RunRow[]).map(
exposeRunTimestamps
listRuns(params: { limit?: number; cursor?: string } = {}): RunListPage {
if (params.limit === undefined && params.cursor === undefined) {
const rows = this.db
.prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC')
.all() as RunRow[]
return { runs: rows.map(exposeRunTimestamps), nextCursor: null }
}
const limit = Math.min(
Math.max(1, params.limit ?? ORCHESTRATION_RUN_PAGE_LIMIT),
ORCHESTRATION_RUN_PAGE_LIMIT
)
const cursor = params.cursor ? decodeRunListCursor(params.cursor) : undefined
const rows = (
cursor
? this.db
.prepare(
`SELECT * FROM runs
WHERE created_at < ? OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT ?`
)
.all(cursor.createdAt, cursor.createdAt, cursor.id, limit + 1)
: this.db
.prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?')
.all(limit + 1)
) as RunRow[]
const hasMore = rows.length > limit
const pageRows = hasMore ? rows.slice(0, limit) : rows
return {
runs: pageRows.map(exposeRunTimestamps),
nextCursor: hasMore ? encodeRunListCursor(pageRows.at(-1) as RunRow) : null
}
}
getCurrentRunForPane(paneKey: string): RunRow | undefined {
@@ -3748,6 +3858,7 @@ export class OrchestrationDb {
`Mutation ${receipt.requestId} already has a durable acceptance record.`
)
}
this.ensureMutationReceiptCapacity()
this.db
.prepare(
`INSERT INTO mutation_receipts (
@@ -4335,6 +4446,7 @@ export class OrchestrationDb {
`Remote attachment request ${params.mutationReceipt.requestId} already exists.`
)
}
this.ensureMutationReceiptCapacity()
this.db
.prepare(
`INSERT INTO mutation_receipts (
@@ -0,0 +1,260 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import Database from '../../sqlite/sync-database'
import { OrchestrationDb } from './db'
const MUTATION_RECEIPT_MAX_ROWS = 10_000
function sqliteFor(db: OrchestrationDb): Database.Database {
return (db as unknown as { db: Database.Database }).db
}
function insertMutationReceipts(
db: OrchestrationDb,
count: number,
state: 'pending' | 'completed'
): void {
sqliteFor(db)
.prepare(
`WITH RECURSIVE receipt_numbers(value) AS (
VALUES (1)
UNION ALL
SELECT value + 1 FROM receipt_numbers WHERE value < ?
)
INSERT INTO mutation_receipts (
caller_fingerprint, request_id, method, payload_hash, state
)
SELECT 'caller', printf('request_%05d', value), 'orchestration.send',
printf('hash_%05d', value), ?
FROM receipt_numbers`
)
.run(count, state)
}
describe('OrchestrationDb bounded mutation receipts', () => {
let db: OrchestrationDb | undefined
afterEach(() => db?.close())
it('prunes expired completed receipts but preserves unresolved receipts', () => {
db = new OrchestrationDb(':memory:')
const sqlite = sqliteFor(db)
sqlite.exec(`
INSERT INTO mutation_receipts (
caller_fingerprint, request_id, method, payload_hash, state, updated_at
) VALUES
('caller', 'expired', 'orchestration.send', 'hash_expired', 'completed', '2000-01-01 00:00:00'),
('caller', 'unresolved', 'orchestration.send', 'hash_unresolved', 'pending', '2000-01-01 00:00:00');
`)
db.beginMutationReceipt({
callerFingerprint: 'caller',
requestId: 'new',
method: 'orchestration.send',
payloadHash: 'hash_new'
})
expect(db.getMutationReceipt('caller', 'expired')).toBeUndefined()
expect(db.getMutationReceipt('caller', 'unresolved')).toMatchObject({ state: 'pending' })
})
it('caps completed receipt count while retaining the newest replay records', () => {
db = new OrchestrationDb(':memory:')
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'completed')
db.beginMutationReceipt({
callerFingerprint: 'caller',
requestId: 'new',
method: 'orchestration.send',
payloadHash: 'hash_new'
})
const count = sqliteFor(db)
.prepare('SELECT COUNT(*) AS count FROM mutation_receipts')
.get() as { count: number }
expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS)
expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined()
expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' })
expect(db.getMutationReceipt('caller', 'new')).toMatchObject({ state: 'pending' })
})
it('fails closed when unresolved receipts alone fill the ledger', () => {
db = new OrchestrationDb(':memory:')
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending')
expect(() =>
db!.beginMutationReceipt({
callerFingerprint: 'caller',
requestId: 'overflow',
method: 'orchestration.send',
payloadHash: 'hash_overflow'
})
).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' }))
expect(db.getMutationReceipt('caller', 'request_00001')).toMatchObject({ state: 'pending' })
expect(db.getMutationReceipt('caller', 'overflow')).toBeUndefined()
})
it('prunes completed receipts before accepting a remote attachment', () => {
db = new OrchestrationDb(':memory:')
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'completed')
db.createRemoteDispatchAttachment({
dispatchId: 'ctx_remote_pruned',
taskId: 'task_remote_pruned',
homePeerFingerprint: 'caller',
protocolVersion: 1,
runtimeEpoch: 'worker_epoch',
mutationReceipt: {
callerFingerprint: 'caller',
requestId: 'remote_pruned',
method: 'orchestration.federationAttachStart',
payloadHash: 'hash_remote_pruned'
}
})
const count = sqliteFor(db)
.prepare('SELECT COUNT(*) AS count FROM mutation_receipts')
.get() as { count: number }
expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS)
expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined()
expect(db.getMutationReceipt('caller', 'remote_pruned')).toMatchObject({ state: 'pending' })
expect(db.getRemoteDispatchAttachment('ctx_remote_pruned')).toBeDefined()
})
it('rejects a remote attachment when pending receipts fill the ledger', () => {
db = new OrchestrationDb(':memory:')
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending')
expect(() =>
db!.createRemoteDispatchAttachment({
dispatchId: 'ctx_remote_overflow',
taskId: 'task_remote_overflow',
homePeerFingerprint: 'caller',
protocolVersion: 1,
runtimeEpoch: 'worker_epoch',
mutationReceipt: {
callerFingerprint: 'caller',
requestId: 'remote_overflow',
method: 'orchestration.federationAttachStart',
payloadHash: 'hash_remote_overflow'
}
})
).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' }))
expect(db.getMutationReceipt('caller', 'request_00001')).toMatchObject({ state: 'pending' })
expect(db.getMutationReceipt('caller', 'remote_overflow')).toBeUndefined()
expect(db.getRemoteDispatchAttachment('ctx_remote_overflow')).toBeUndefined()
})
it('guards atomic worker acceptance without changing task state', () => {
db = new OrchestrationDb(':memory:')
const task = db.createTask({ spec: 'capacity check' })
insertMutationReceipts(db, MUTATION_RECEIPT_MAX_ROWS, 'pending')
expect(() =>
db!.createStartingWorkerDispatch({
taskId: task.id,
startOptions: {},
mutationReceipt: {
callerFingerprint: 'caller',
requestId: 'worker_overflow',
method: 'orchestration.workerStart',
payloadHash: 'hash_worker_overflow'
}
})
).toThrowError(expect.objectContaining({ code: 'mutation_ledger_full' }))
expect(db.getTask(task.id)).toMatchObject({ status: 'ready' })
expect(db.getMutationReceipt('caller', 'worker_overflow')).toBeUndefined()
})
})
describe('OrchestrationDb Run pagination', () => {
let db: OrchestrationDb | undefined
afterEach(() => db?.close())
it('returns stable bounded pages without skipping Runs sharing a timestamp', () => {
db = new OrchestrationDb(':memory:')
const createdIds = Array.from({ length: 5 }, (_, index) => `run_page_${index}`)
const insertRun = sqliteFor(db).prepare(
`INSERT INTO runs (
id, objective, coordinator_handle, coordinator_pane_key,
consumer_generation, legacy, created_at
) VALUES (?, ?, ?, ?, 1, 0, '2025-01-01 00:00:00')`
)
for (const [index, id] of createdIds.entries()) {
insertRun.run(
id,
`Run ${index}`,
`term_coord_${index}`,
`tab_coord_${index}:11111111-1111-4111-8111-111111111111`
)
}
const seen: string[] = []
let cursor: string | undefined
do {
const page = db.listRuns({ limit: 2, cursor })
expect(page.runs.length).toBeLessThanOrEqual(2)
expect(page.runs.every((run) => !seen.includes(run.id))).toBe(true)
seen.push(...page.runs.map((run) => run.id))
cursor = page.nextCursor ?? undefined
} while (cursor)
expect(seen).toEqual(expect.arrayContaining(createdIds))
expect(new Set(seen).size).toBe(createdIds.length + 1)
})
})
describe('OrchestrationDb dispatch assignee index migration', () => {
let db: OrchestrationDb | undefined
let tempDir: string | undefined
afterEach(() => {
db?.close()
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
}
})
it('migrates a populated v21 database idempotently', () => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-dispatch-index-migration-'))
const dbPath = join(tempDir, 'orchestration.db')
db = new OrchestrationDb(dbPath)
const task = db.createTask({ spec: 'indexed lookup' })
const dispatch = db.createDispatchContext(task.id, 'term_worker')
db.close()
db = undefined
const oldDb = new Database(dbPath)
oldDb.exec('DROP INDEX IF EXISTS idx_dispatch_assignee_handle')
oldDb.pragma('user_version = 21')
oldDb.close()
db = new OrchestrationDb(dbPath)
const sqlite = sqliteFor(db)
expect(sqlite.pragma('user_version', { simple: true })).toBe(22)
expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' })
expect(
sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?")
.get('idx_dispatch_assignee_handle')
).toBeDefined()
const plan = sqlite
.prepare(
`EXPLAIN QUERY PLAN
SELECT * FROM dispatch_contexts
WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1`
)
.all('term_worker') as { detail: string }[]
expect(plan.map((row) => row.detail).join('\n')).toContain(
'USING INDEX idx_dispatch_assignee_handle'
)
db.close()
db = new OrchestrationDb(dbPath)
expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(22)
expect(db.getDispatchContextById(dispatch.id)).toBeDefined()
})
})
@@ -151,7 +151,7 @@ describe('OrchestrationDb legacy contract storage', () => {
expect(db.getLegacyAdoption()).toBeUndefined()
expect(db.listLegacyCompatibilityPrincipals(LEGACY_RUN_ID)).toEqual([])
expect(db.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
expect(db.listRuns().runs).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
})
it('keeps current Delivery disjoint from adopted direct and audit-only mail', () => {
@@ -53,7 +53,7 @@ describe('OrchestrationDb reset scopes', () => {
db!.resetAll()
expect(db!.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
expect(db!.listRuns().runs).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })])
expect(db!.getTask(state.task.id)).toBeUndefined()
expect(db!.getWorkerDispatch(state.started.dispatch.id)).toBeUndefined()
expect(db!.getFederatedDispatch(state.started.dispatch.id)).toBeUndefined()
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it } from 'vitest'
import type Database from '../../sqlite/sync-database'
import { OrchestrationDb } from './db'
import { ORCHESTRATION_RUN_METHODS } from '../rpc/methods/orchestration-runs'
function sqliteFor(db: OrchestrationDb): Database.Database {
return (db as unknown as { db: Database.Database }).db
}
describe('orchestration Run list compatibility', () => {
let db: OrchestrationDb | undefined
afterEach(() => db?.close())
it('rejects malformed pagination cursors', () => {
const method = ORCHESTRATION_RUN_METHODS.find(
(candidate) => candidate.name === 'orchestration.runList'
)!
expect(() => method.params!.parse({ cursor: {} })).toThrow()
expect(() => method.params!.parse({ cursor: '' })).toThrow()
})
it('preserves the unpaginated RPC result for an old client request', async () => {
db = new OrchestrationDb(':memory:')
const runCount = 101
sqliteFor(db)
.prepare(
`WITH RECURSIVE run_numbers(value) AS (
VALUES (1)
UNION ALL
SELECT value + 1 FROM run_numbers WHERE value < ?
)
INSERT INTO runs (id, objective)
SELECT printf('run_compat_%03d', value), printf('Run %03d', value)
FROM run_numbers`
)
.run(runCount)
const method = ORCHESTRATION_RUN_METHODS.find(
(candidate) => candidate.name === 'orchestration.runList'
)!
const params = method.params!.parse({})
const result = (await method.handler(params, {
runtime: { getOrchestrationDb: () => db }
} as never)) as { runs: unknown[] }
expect(result.runs).toHaveLength(runCount + 1)
})
})
@@ -1,12 +1,29 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as SecureFileModule from '../../../shared/secure-file'
import { RelayRevokeOutbox } from './relay-revoke-outbox'
const secureFileMocks = vi.hoisted(() => ({ failWrites: false }))
vi.mock('../../../shared/secure-file', async (importOriginal) => {
const actual = await importOriginal<typeof SecureFileModule>()
return {
...actual,
writeSecureJsonFile: (targetPath: string, value: unknown) => {
if (secureFileMocks.failWrites) {
throw new Error('disk full')
}
actual.writeSecureJsonFile(targetPath, value)
}
}
})
describe('RelayRevokeOutbox', () => {
const paths: string[] = []
afterEach(() => {
secureFileMocks.failWrites = false
for (const path of paths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
@@ -29,4 +46,40 @@ describe('RelayRevokeOutbox', () => {
new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId)
).toEqual([])
})
it('does not retain an enqueue that failed to reach disk', () => {
const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-'))
paths.push(path)
const binding = {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId: 'device-1',
ownerIdentityKey: 'user-1\0profile-1\0org-1'
}
const outbox = new RelayRevokeOutbox(path)
secureFileMocks.failWrites = true
expect(() => outbox.enqueue(binding)).toThrow('disk full')
secureFileMocks.failWrites = false
const persisted = outbox.enqueue(binding)
expect(
new RelayRevokeOutbox(path).pendingFor(binding.ownerIdentityKey, binding.relayHostId)
).toEqual([persisted])
})
it('does not remove an item in memory when the durable removal fails', () => {
const path = mkdtempSync(join(tmpdir(), 'orca-relay-revoke-'))
paths.push(path)
const binding = {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId: 'device-1',
ownerIdentityKey: 'user-1\0profile-1\0org-1'
}
const outbox = new RelayRevokeOutbox(path)
const item = outbox.enqueue(binding)
secureFileMocks.failWrites = true
expect(() => outbox.remove(item.reqId)).toThrow('disk full')
secureFileMocks.failWrites = false
expect(outbox.pendingFor(binding.ownerIdentityKey, binding.relayHostId)).toEqual([item])
})
})
@@ -54,8 +54,9 @@ export class RelayRevokeOutbox {
return existing
}
const item = { ...binding, reqId: randomUUID(), createdAt: Date.now() }
this.items.push(item)
this.save()
const next = [...this.items, item]
this.save(next)
this.items = next
return item
}
@@ -70,8 +71,8 @@ export class RelayRevokeOutbox {
if (next.length === this.items.length) {
return
}
this.save(next)
this.items = next
this.save()
}
private load(): RelayRevokeOutboxItem[] {
@@ -87,7 +88,7 @@ export class RelayRevokeOutbox {
}
}
private save(): void {
writeSecureJsonFile(this.path, this.items)
private save(items: readonly RelayRevokeOutboxItem[]): void {
writeSecureJsonFile(this.path, items)
}
}
@@ -332,6 +332,14 @@ describe('remote runtime request connection integration', () => {
const worktreeId = 'repo-1::C:\\repo\\feature'
const ptyId = `${worktreeId}@@pty-1`
let sleepSnapshot: RuntimeClientEvent[] = []
const launchDraftResolutionSnapshot: RuntimeClientEvent[] = [
{
type: 'nativeChatLaunchDraftResolved',
tabId: 'tab-1',
text: 'seed',
createdAt: 7
}
]
const emit = (event: RuntimeClientEvent): void => {
for (const listener of clientEventListeners) {
listener(event)
@@ -362,6 +370,7 @@ describe('remote runtime request connection integration', () => {
return () => clientEventListeners.delete(listener)
},
getTerminalSleepClientEventSnapshot: () => sleepSnapshot,
getNativeChatLaunchDraftResolutionClientEventSnapshot: () => launchDraftResolutionSnapshot,
sleepTerminalsForWorktree: async () => {
emit({
type: 'worktreeTerminalSleepState',
@@ -440,6 +449,9 @@ describe('remote runtime request connection integration', () => {
await waitFor(() =>
clientEvents.every((events) => events.some((e) => e.type === 'ready'))
)
for (const events of clientEvents) {
expect(events).toContainEqual(launchDraftResolutionSnapshot[0])
}
await expect(
requester.request(
'terminal.sleep',
@@ -487,6 +499,7 @@ describe('remote runtime request connection integration', () => {
.filter((event) => event.type === 'worktreeTerminalSleepState')
.map((event) => event.phase)
).toEqual(['committed'])
expect(reconnectedEvents).toContainEqual(launchDraftResolutionSnapshot[0])
sleepSnapshot = []
emit({
+1
View File
@@ -88,6 +88,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
'agent_unconfigured',
'terminal_worktree_mismatch',
'request_mismatch',
'mutation_ledger_full',
'legacy_read_only',
'orchestration_migration_required',
'operation_unknown',
@@ -38,6 +38,10 @@ export const CLIENT_EVENT_METHODS: readonly RpcAnyMethod[] = [
for (const event of runtime.getTerminalSleepClientEventSnapshot?.() ?? []) {
emit(event)
}
for (const event of runtime.getNativeChatLaunchDraftResolutionClientEventSnapshot?.() ??
[]) {
emit(event)
}
const sshStates = listRegisteredSshTargets().flatMap((target) => {
const state = getPublicSshState(getRegisteredSshState(target.id) ?? null)
return state ? [{ targetId: target.id, state }] : []
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalBoolean, OptionalString, requiredString } from '../schemas'
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../shared/orchestration-run-pagination'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { OrchestrationError } from '../../orchestration/orchestration-error'
@@ -16,7 +17,10 @@ const RunUseParams = z.object({
})
const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') })
const RunListParams = z.object({})
const RunListParams = z.object({
limit: z.number().int().min(1).max(ORCHESTRATION_RUN_PAGE_LIMIT).optional(),
cursor: z.string().min(1).optional()
})
const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString })
function requireCallerPane(runtime: OrcaRuntimeService, handle: string): string {
@@ -104,7 +108,7 @@ export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.runList',
params: RunListParams,
handler: (_params, { runtime }) => ({ runs: runtime.getOrchestrationDb().listRuns() })
handler: (params, { runtime }) => runtime.getOrchestrationDb().listRuns(params)
}),
defineMethod({
name: 'orchestration.runShow',
@@ -60,7 +60,10 @@ export async function createExistingWorktreeWorkerTerminal(args: {
}): Promise<{ handle: string; warning?: string }> {
const terminal = await args.runtime.createTerminal(`id:${args.worktreeId}`, {
command: args.agent,
title: `worker-${args.taskId}`
title: `worker-${args.taskId}`,
// Why: dispatching a worker is background work; it must not pull the sidebar
// to the worker's workspace while the user is reading somewhere else.
surfaceOwner: false
})
args.effects.push({
kind: 'terminal',
@@ -164,7 +164,7 @@ describe('orchestration RPC methods', () => {
await expect(
call('orchestration.runCreate', { objective: 'No pane', from: 'term_stale' })
).rejects.toMatchObject({ code: 'stable_pane_required' })
expect(db.listRuns().filter((run) => run.legacy === 0)).toHaveLength(0)
expect(db.listRuns().runs.filter((run) => run.legacy === 0)).toHaveLength(0)
})
it('rebinds explicitly, lists Runs, and keeps the legacy Run inspect-only', async () => {
@@ -2176,9 +2176,12 @@ describe('orchestration RPC methods', () => {
)
expect(db.getTask(task.id)?.status).toBe('dispatched')
expect(db.getWorkerDispatch(result.dispatchId)?.state).toBe('ready')
// Why: dispatching a worker is background work — surfaceOwner:false adopts
// the tab without scrolling the sidebar to the worker's workspace.
expect(runtime.createTerminal).toHaveBeenCalledWith('id:repo::worktree', {
command: 'codex',
title: `worker-${task.id}`
title: `worker-${task.id}`,
surfaceOwner: false
})
expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith(
'term_worker',
@@ -2277,7 +2280,9 @@ describe('orchestration RPC methods', () => {
)
expect(runtime.createTerminal).toHaveBeenCalledWith(
'id:repo::other',
expect.objectContaining({ command: 'codex' })
// Why: starting a worker in an existing worktree must not pull the sidebar
// away from whatever the user is looking at.
expect.objectContaining({ command: 'codex', surfaceOwner: false })
)
expect(createWorktree).not.toHaveBeenCalled()
})
+15
View File
@@ -863,6 +863,12 @@ const TerminalSend = TerminalHandle.extend({
text: OptionalString,
enter: z.unknown().optional(),
interrupt: z.unknown().optional(),
resolvedLaunchDraft: z
.object({
text: z.string(),
createdAt: z.number().finite()
})
.optional(),
requireAgentStatus: z.enum(['sendable']).optional(),
// Why: terminal-generated replies are valid input but must not transfer the shared terminal floor.
inputKind: z.enum(['query-reply']).optional(),
@@ -1183,6 +1189,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
params: TerminalSend,
handler: async (params, { runtime, clientId }) => {
await assertTerminalSendTextWithinLimit(params.text)
await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text)
const queryReplyClientId = clientId ?? params.client?.id
if (
params.inputKind === 'query-reply' &&
@@ -1361,6 +1368,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
if (result.accepted !== true) {
mobileFloorClaim.current?.rollback()
}
if (
result.accepted === true &&
params.enter === true &&
params.client?.type === 'mobile' &&
params.resolvedLaunchDraft
) {
runtime.notifyNativeChatLaunchDraftResolved(params.terminal, params.resolvedLaunchDraft)
}
// Why: deliberate mobile input takes the floor (drives `* → mobile{clientId}`); clientless sends fall back to the current mobile driver.
return { send: result }
}
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { RpcDispatcher } from '../dispatcher'
import { WORKTREE_METHODS } from './worktree'
describe('worktree missing-terminal teardown RPC', () => {
it('routes the connection-scoped request through the runtime owner', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
teardownMissingManagedWorktreeTerminals: vi
.fn()
.mockResolvedValue({ stoppedWorktreeIds: ['repo-1::/workspace/deleted'] })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch({
id: 'request-1',
authToken: 'token',
method: 'worktree.teardownMissingTerminals',
params: {
repo: 'repo-1',
worktreeIds: ['repo-1::/workspace/deleted'],
connectionId: 'ssh-1'
}
})
expect(runtime.teardownMissingManagedWorktreeTerminals).toHaveBeenCalledWith(
'repo-1',
['repo-1::/workspace/deleted'],
'ssh-1'
)
expect(response).toMatchObject({ ok: true })
})
})
@@ -51,6 +51,11 @@ export const WorktreeDetectedListParams = z.object({
.pipe(z.string().min(1, 'Missing repo selector'))
})
export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({
worktreeIds: z.array(z.string().min(1)).max(10_000),
connectionId: z.string().nullable().optional()
})
export const WorktreePsParams = z.object({
limit: OptionalFiniteNumber
})
+12 -1
View File
@@ -19,7 +19,8 @@ import {
WorktreeResolvePrBase,
WorktreeSelector,
WorktreeSet,
WorktreeSortOrder
WorktreeSortOrder,
WorktreeTeardownMissingTerminalsParams
} from './worktree-schemas'
export const WORKTREE_METHODS: RpcMethod[] = [
@@ -38,6 +39,16 @@ export const WORKTREE_METHODS: RpcMethod[] = [
params: WorktreeDetectedListParams,
handler: async (params, { runtime }) => runtime.listDetectedManagedWorktrees(params.repo)
}),
defineMethod({
name: 'worktree.teardownMissingTerminals',
params: WorktreeTeardownMissingTerminalsParams,
handler: async (params, { runtime }) =>
runtime.teardownMissingManagedWorktreeTerminals(
params.repo,
params.worktreeIds,
params.connectionId
)
}),
defineMethod({
name: 'worktree.lineageList',
params: null,
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../orca-runtime'
import type { RpcRequest } from './core'
import { RpcDispatcher } from './dispatcher'
import { TERMINAL_METHODS } from './methods/terminal'
const RESOLUTION = { text: 'seed', createdAt: 1 }
function makeRequest(params: unknown): RpcRequest {
return { id: 'request', authToken: 'token', method: 'terminal.send', params }
}
function makeRuntime(accepted: boolean): OrcaRuntimeService {
return {
getRuntimeId: () => 'runtime',
resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }),
getDriver: vi.fn().mockReturnValue({ kind: 'idle' }),
beginMobileInputFloor: vi.fn().mockReturnValue({ commit: vi.fn(), rollback: vi.fn() }),
sendTerminal: vi.fn().mockResolvedValue({
handle: 'terminal-1',
accepted,
bytesWritten: accepted ? 1 : 0
}),
notifyNativeChatLaunchDraftResolved: vi.fn()
} as unknown as OrcaRuntimeService
}
async function send(
runtime: OrcaRuntimeService,
options: { enter: boolean; clientType: 'mobile' | 'desktop' }
): Promise<void> {
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
await dispatcher.dispatch(
makeRequest({
terminal: 'terminal-1',
text: 'hello',
enter: options.enter,
resolvedLaunchDraft: RESOLUTION,
client: { id: 'client-1', type: options.clientType }
})
)
}
describe('terminal.send launch-draft resolution', () => {
it('notifies after an accepted mobile submit', async () => {
const runtime = makeRuntime(true)
await send(runtime, { enter: true, clientType: 'mobile' })
expect(runtime.notifyNativeChatLaunchDraftResolved).toHaveBeenCalledWith(
'terminal-1',
RESOLUTION
)
})
it.each([
['rejected submit', false, true, 'mobile'],
['clear-only write', true, false, 'mobile'],
['desktop submit', true, true, 'desktop']
] as const)('does not notify after a %s', async (_case, accepted, enter, clientType) => {
const runtime = makeRuntime(accepted)
await send(runtime, { enter, clientType })
expect(runtime.notifyNativeChatLaunchDraftResolved).not.toHaveBeenCalled()
})
})
+658 -10
View File
@@ -746,7 +746,74 @@ describe('OrcaRuntimeRpcServer', () => {
}
})
it('falls back to a valid direct-only GUI offer when relay invite minting fails', async () => {
it('queues the old Relay binding when a stable provider changes accounts', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
let relayHostId = 'AbCdEf0123_-xyZ9'
let ownerIdentityKey = 'user-a\0profile-a\0org'
const onDeviceRevokeQueued = vi.fn()
server.setMobileRelayPairingProvider({
createPairingRelay: async (relayDeviceId) => ({
relay: {
v: 1,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId,
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2
},
binding: {
relayHostId,
relayDeviceId,
ownerIdentityKey
}
}),
onDeviceRevokeQueued,
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
const first = await server.createMobilePairingOffer({ address: '100.64.1.20' })
expect(first.available).toBe(true)
if (!first.available) {
throw new Error('WebSocket pairing unavailable')
}
const firstBinding = server.getDeviceRegistry()?.getDevice(first.deviceId)?.relayBinding
expect(firstBinding).toBeTruthy()
if (!firstBinding) {
throw new Error('Relay binding unavailable')
}
relayHostId = 'ZyXwVu9876_-abcD'
ownerIdentityKey = 'user-b\0profile-b\0org'
const second = await server.createMobilePairingOffer({ address: '100.64.1.20' })
expect(second.available).toBe(true)
if (!second.available) {
throw new Error('WebSocket pairing unavailable')
}
expect(second.deviceId).toBe(first.deviceId)
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
expect(onDeviceRevokeQueued).toHaveBeenCalledWith(expect.objectContaining(firstBinding))
expect(server.getDeviceRegistry()?.getDevice(second.deviceId)?.relayBinding).toEqual({
relayHostId,
relayDeviceId: second.deviceId,
ownerIdentityKey
})
} finally {
await server.stop()
}
})
it('refuses a silent LAN QR when relay invite minting fails under Anywhere', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
@@ -764,18 +831,576 @@ describe('OrcaRuntimeRpcServer', () => {
await server.start()
try {
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
expect(offer.available).toBe(true)
if (!offer.available) {
// Why: Anywhere must not ship a scannable local-only code under the Relay label.
expect(offer.available).toBe(false)
if (offer.available) {
throw new Error('expected relay mint failure')
}
expect(offer.reason).toBe('relay_mint_failed')
expect(offer.relayFailure).toMatchObject({
code: 'relay_mint_failed',
stage: 'create_pairing_relay'
})
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
} finally {
await server.stop()
}
})
it('reports a missing Relay provider without creating a fallback QR', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
expect(offer).toMatchObject({
available: false,
reason: 'relay_mint_failed',
relayFailure: {
code: 'relay_provider_unavailable',
stage: 'provider_missing',
message: 'Orca Relay is not available on this desktop'
}
})
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
} finally {
await server.stop()
}
})
it('preserves an existing Relay QR when a same-mode remint fails', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
const createPairingRelay = vi
.fn()
.mockImplementationOnce(async (relayDeviceId: string) => ({
relay: {
v: 1 as const,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2 as const
},
binding: {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId,
ownerIdentityKey: 'user\0profile\0org'
}
}))
.mockRejectedValueOnce(new Error('relay offline'))
server.setMobileRelayPairingProvider({
createPairingRelay,
onDeviceRevokeQueued: vi.fn(),
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
const first = await server.createMobilePairingOffer({ address: '100.64.1.20' })
expect(first.available).toBe(true)
if (!first.available) {
throw new Error('WebSocket pairing unavailable')
}
expect(parsePairingCode(offer.pairingUrl)).toMatchObject({
endpoint: offer.endpoint,
scope: 'mobile'
const second = await server.createMobilePairingOffer({ address: '100.64.1.20' })
expect(second.available).toBe(false)
expect(server.getDeviceRegistry()?.getDevice(first.deviceId)?.relayBinding).toBeTruthy()
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')?.deviceId).toBe(first.deviceId)
} finally {
await server.stop()
}
})
it('coalesces concurrent mobile Relay mints for the shared pending credential', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
let resolveFirst: (() => void) | undefined
const firstMint = new Promise<void>((resolve) => {
resolveFirst = resolve
})
const createPairingRelay = vi.fn(async (relayDeviceId: string) => {
await firstMint
return {
relay: {
v: 1 as const,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2 as const
},
binding: {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId,
ownerIdentityKey: 'user\0profile\0org'
}
}
})
server.setMobileRelayPairingProvider({
createPairingRelay,
onDeviceRevokeQueued: vi.fn(),
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
const first = server.createMobilePairingOffer({ address: '100.64.1.20', rotate: true })
const second = server.createMobilePairingOffer({ address: '100.64.1.20', rotate: true })
await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledTimes(1))
resolveFirst?.()
const [firstOffer, secondOffer] = await Promise.all([first, second])
expect(firstOffer.available).toBe(true)
expect(secondOffer.available).toBe(true)
if (!firstOffer.available || !secondOffer.available) {
throw new Error('WebSocket pairing unavailable')
}
expect(secondOffer.deviceId).toBe(firstOffer.deviceId)
expect(secondOffer.pairingUrl).toBe(firstOffer.pairingUrl)
expect(createPairingRelay).toHaveBeenCalledTimes(1)
} finally {
await server.stop()
}
})
it('supersedes an older concurrent Relay rotation for a different address', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
let resolveFirst: (() => void) | undefined
const firstMint = new Promise<void>((resolve) => {
resolveFirst = resolve
})
const createPairingRelay = vi.fn(async (relayDeviceId: string) => {
if (createPairingRelay.mock.calls.length === 1) {
await firstMint
}
return {
relay: {
v: 1 as const,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2 as const
},
binding: {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId,
ownerIdentityKey: 'user\0profile\0org'
}
}
})
const onDeviceRevokeQueued = vi.fn()
server.setMobileRelayPairingProvider({
createPairingRelay,
onDeviceRevokeQueued,
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
const first = server.createMobilePairingOffer({
address: '100.64.1.20',
rotate: true
})
expect(parsePairingCode(offer.pairingUrl)).not.toHaveProperty('relay')
// Why: the result reports what the offer actually encodes so the UI can
// flag the degraded mint instead of labeling it as Relay.
expect(offer.connectionMode).toBe('local-only')
await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledOnce())
const second = server.createMobilePairingOffer({
address: '100.64.1.21',
rotate: true
})
resolveFirst?.()
await expect(first).resolves.toMatchObject({
available: false,
relayFailure: { code: 'relay_request_superseded' }
})
const secondOffer = await second
expect(secondOffer.available).toBe(true)
if (!secondOffer.available) {
throw new Error('WebSocket pairing unavailable')
}
expect(secondOffer.endpoint).toContain('100.64.1.21')
expect(createPairingRelay).toHaveBeenCalledTimes(2)
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
} finally {
await server.stop()
}
})
it('supersedes an older concurrent Relay mint for a different address without rotate', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
let resolveFirst: (() => void) | undefined
const firstMint = new Promise<void>((resolve) => {
resolveFirst = resolve
})
const createPairingRelay = vi.fn(async (relayDeviceId: string) => {
if (createPairingRelay.mock.calls.length === 1) {
await firstMint
}
return {
relay: {
v: 1 as const,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2 as const
},
binding: {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId,
ownerIdentityKey: 'user\0profile\0org'
}
}
})
const onDeviceRevokeQueued = vi.fn()
server.setMobileRelayPairingProvider({
createPairingRelay,
onDeviceRevokeQueued,
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
const first = server.createMobilePairingOffer({ address: '100.64.1.20' })
await vi.waitFor(() => expect(createPairingRelay).toHaveBeenCalledOnce())
const second = server.createMobilePairingOffer({ address: '100.64.1.21' })
resolveFirst?.()
await expect(first).resolves.toMatchObject({
available: false,
relayFailure: { code: 'relay_request_superseded' }
})
const secondOffer = await second
expect(secondOffer.available).toBe(true)
if (!secondOffer.available) {
throw new Error('WebSocket pairing unavailable')
}
expect(secondOffer.endpoint).toContain('100.64.1.21')
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
} finally {
await server.stop()
}
})
it('lets LAN supersede a pending Relay mint without waiting for it', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
let resolveRelay: (() => void) | undefined
const relayGate = new Promise<void>((resolve) => {
resolveRelay = resolve
})
const onDeviceRevokeQueued = vi.fn()
server.setMobileRelayPairingProvider({
createPairingRelay: async (relayDeviceId) => {
await relayGate
return {
relay: {
v: 1,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2
},
binding: {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId,
ownerIdentityKey: 'user\0profile\0org'
}
}
},
onDeviceRevokeQueued,
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
const relayOffer = server.createMobilePairingOffer({ address: '100.64.1.20' })
await vi.waitFor(() =>
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).not.toBeNull()
)
const localOffer = await server.createMobilePairingOffer({
address: '100.64.1.20',
connectionMode: 'local-only'
})
expect(localOffer.available).toBe(true)
if (!localOffer.available) {
throw new Error('LAN pairing unavailable')
}
expect(localOffer.connectionMode).toBe('local-only')
resolveRelay?.()
const staleRelayOffer = await relayOffer
expect(staleRelayOffer.available).toBe(false)
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')?.deviceId).toBe(
localOffer.deviceId
)
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
} finally {
await server.stop()
}
})
it('revokes a Relay invite when binding persistence throws', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
const onDeviceRevokeQueued = vi.fn()
server.setMobileRelayPairingProvider({
createPairingRelay: async (relayDeviceId) => ({
relay: {
v: 1,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2
},
binding: {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId,
ownerIdentityKey: 'user\0profile\0org'
}
}),
onDeviceRevokeQueued,
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
const registry = server.getDeviceRegistry()
if (!registry) {
throw new Error('Device registry unavailable')
}
vi.spyOn(registry, 'setRelayBinding').mockImplementation(() => {
throw new Error('disk full')
})
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
expect(offer.available).toBe(false)
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
expect(registry.getPendingDevice('mobile')).toBeNull()
} finally {
await server.stop()
}
})
it('revokes a Relay result from a provider replaced during minting', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
let resolveRelay: (() => void) | undefined
const relayGate = new Promise<void>((resolve) => {
resolveRelay = resolve
})
server.setMobileRelayPairingProvider({
createPairingRelay: async (relayDeviceId) => {
await relayGate
return {
relay: {
v: 1,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2
},
binding: {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId,
ownerIdentityKey: 'user\0profile\0org'
}
}
},
onDeviceRevokeQueued: vi.fn(),
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
const offerPromise = server.createMobilePairingOffer({ address: '100.64.1.20' })
await vi.waitFor(() =>
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).not.toBeNull()
)
const onDeviceRevokeQueued = vi.fn()
server.setMobileRelayPairingProvider({
createPairingRelay: vi.fn(),
onDeviceRevokeQueued,
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
resolveRelay?.()
await expect(offerPromise).resolves.toMatchObject({ available: false })
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
} finally {
await server.stop()
}
})
it('retains a minted Relay binding on the device when cleanup cannot be queued', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
let resolveRelay: (() => void) | undefined
const relayGate = new Promise<void>((resolve) => {
resolveRelay = resolve
})
server.setMobileRelayPairingProvider({
createPairingRelay: async (relayDeviceId) => {
await relayGate
return {
relay: {
v: 1,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2
},
binding: {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId,
ownerIdentityKey: 'user\0profile\0org'
}
}
},
onDeviceRevokeQueued: vi.fn(),
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
const registry = server.getDeviceRegistry()
if (!registry) {
throw new Error('Device registry unavailable')
}
const offerPromise = server.createMobilePairingOffer({ address: '100.64.1.20' })
await vi.waitFor(() => expect(registry.getPendingDevice('mobile')).not.toBeNull())
const deviceId = registry.getPendingDevice('mobile')?.deviceId
vi.spyOn(server.getRelayRevokeOutbox(), 'enqueue').mockImplementation(() => {
throw new Error('disk full')
})
// Why: swapping the provider supersedes the in-flight mint.
server.setMobileRelayPairingProvider({
createPairingRelay: vi.fn(),
onDeviceRevokeQueued: vi.fn(),
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
resolveRelay?.()
await expect(offerPromise).resolves.toMatchObject({ available: false })
expect(registry.getDevice(deviceId ?? '')?.relayBinding).toMatchObject({
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId: deviceId
})
} finally {
await server.stop()
}
})
it('queues cloud cleanup when a minted Relay binding cannot be persisted', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
const onDeviceRevokeQueued = vi.fn()
server.setMobileRelayPairingProvider({
createPairingRelay: async () => ({
relay: {
v: 1,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2
},
binding: {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId: 'wrong-device',
ownerIdentityKey: 'user\0profile\0org'
}
}),
onDeviceRevokeQueued,
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
const offer = await server.createMobilePairingOffer({ address: '100.64.1.20' })
expect(offer.available).toBe(false)
expect(onDeviceRevokeQueued).toHaveBeenCalledOnce()
expect(server.getDeviceRegistry()?.getPendingDevice('mobile')).toBeNull()
} finally {
await server.stop()
}
@@ -833,6 +1458,28 @@ describe('OrcaRuntimeRpcServer', () => {
enableWebSocket: true,
wsPort: 0
})
server.setMobileRelayPairingProvider({
createPairingRelay: async (relayDeviceId) => ({
relay: {
v: 1,
directorUrl: 'https://relay.example.com',
cellUrl: 'https://cell.example.com',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
inviteToken: 'A'.repeat(43),
inviteExpiresAt: Date.now() + 60_000,
e2eeFraming: 2
},
binding: {
relayHostId: 'AbCdEf0123_-xyZ9',
relayDeviceId,
ownerIdentityKey: 'user\0profile\0org'
}
}),
onDeviceRevokeQueued: vi.fn(),
getEndpoints: vi.fn(),
provisionRelay: vi.fn()
})
await server.start()
try {
@@ -843,6 +1490,7 @@ describe('OrcaRuntimeRpcServer', () => {
if (!offer.available) {
throw new Error('WebSocket pairing unavailable')
}
expect(offer.connectionMode).toBe('automatic')
expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe(
'automatic'
)
+249 -38
View File
@@ -29,6 +29,10 @@ import {
} from './rpc/mobile-socket-wiring'
import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer'
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
import {
mobileRelayMintFailureFromUnknown,
type MobileRelayMintFailure
} from '../../shared/mobile-relay-mint-failure'
import {
RelayRevokeOutbox,
type RelayDeviceBinding,
@@ -71,13 +75,28 @@ export type PairingOfferUnavailableReason =
| 'device_registry_unavailable'
| 'e2ee_key_unavailable'
| 'invalid_advertised_endpoint'
| 'relay_mint_failed'
export type PairingOfferUnavailable = {
available: false
reason: PairingOfferUnavailableReason
guidance: string
/** Present when an Anywhere mint refused to silently fall back to LAN-only. */
relayFailure?: MobileRelayMintFailure
}
type MobilePairingOfferAvailable = {
available: true
pairingUrl: string
endpoint: string
deviceId: string
webClientUrl: string | null
/** Mode the offer actually encodes. */
connectionMode: MobilePairingConnectionMode
}
type MobilePairingOffer = PairingOfferUnavailable | MobilePairingOfferAvailable
type PairingIdentityInitialization =
| { ok: true; deviceRegistry: DeviceRegistry; e2eeKeypair: E2EEKeypair }
| { ok: false; failure: PairingOfferUnavailable }
@@ -469,6 +488,14 @@ export class OrcaRuntimeRpcServer {
private metadataOwnershipWatch: RuntimeMetadataOwnershipWatch | null = null
private mobileSocketWiring: MobileSocketWiring | null = null
private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null
private mobileRelayPairingOfferQueue: Promise<void> = Promise.resolve()
private mobileRelayPairingOfferInFlight: {
generation: number
address: string | null
rotate: boolean
request: Promise<MobilePairingOffer>
} | null = null
private mobilePairingOfferGeneration = 0
private onUnpairedDeviceAuthFailure: (() => void) | null = null
private unpairedDeviceAuthThrottle: UnpairedDeviceAuthThrottle | null = null
private readonly binaryStreamHandlers = new Map<
@@ -552,7 +579,9 @@ export class OrcaRuntimeRpcServer {
current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey)
) {
// Why: switching the owning account/host must not strand the old cloud credential family, even if that account is offline.
this.queueRelayDeviceRevoke(current.relayBinding)
if (!this.queueRelayDeviceRevoke(current.relayBinding)) {
return false
}
}
const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false
if (updated) {
@@ -576,7 +605,9 @@ export class OrcaRuntimeRpcServer {
return false
}
if (device.relayBinding) {
this.queueRelayDeviceRevoke(device.relayBinding)
if (!this.queueRelayDeviceRevoke(device.relayBinding)) {
return false
}
}
if (!this.deviceRegistry?.removeDevice(deviceId)) {
return false
@@ -672,18 +703,57 @@ export class OrcaRuntimeRpcServer {
connectionMode?: MobilePairingConnectionMode
name?: string
rotate?: boolean
}): Promise<
| PairingOfferUnavailable
| {
available: true
pairingUrl: string
endpoint: string
deviceId: string
webClientUrl: string | null
/** Mode the offer actually encodes — 'local-only' when an automatic request degraded (Relay couldn't attach). */
connectionMode: MobilePairingConnectionMode
}): Promise<MobilePairingOffer> {
if (args.connectionMode === 'local-only') {
this.mobilePairingOfferGeneration += 1
return this.createMobilePairingOfferSerial(args, this.mobilePairingOfferGeneration)
}
const address = args.address ?? null
const rotate = args.rotate === true
const inFlight = this.mobileRelayPairingOfferInFlight
if (
inFlight?.generation === this.mobilePairingOfferGeneration &&
inFlight.address === address &&
(inFlight.rotate || !rotate)
) {
return inFlight.request
}
// Why: every request that is not coalesced above supersedes the older one, rotating or not.
const generation = ++this.mobilePairingOfferGeneration
const request = this.mobileRelayPairingOfferQueue.then(() =>
generation === this.mobilePairingOfferGeneration
? this.createMobilePairingOfferSerial(args, generation)
: this.relayPairingRequestSuperseded()
)
this.mobileRelayPairingOfferQueue = request.then(
() => undefined,
() => undefined
)
this.mobileRelayPairingOfferInFlight = { generation, address, rotate, request }
void request.then(
() => {
if (this.mobileRelayPairingOfferInFlight?.request === request) {
this.mobileRelayPairingOfferInFlight = null
}
},
() => {
if (this.mobileRelayPairingOfferInFlight?.request === request) {
this.mobileRelayPairingOfferInFlight = null
}
}
> {
)
return request
}
private async createMobilePairingOfferSerial(
args: {
address?: string | null
connectionMode?: MobilePairingConnectionMode
name?: string
rotate?: boolean
},
generation: number
): Promise<MobilePairingOffer> {
// Why: the renderer is outside the trust boundary, so only an explicit local-only value may suppress Relay provisioning.
const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic'
const pending = this.deviceRegistry?.getPendingDevice('mobile')
@@ -694,7 +764,12 @@ export class OrcaRuntimeRpcServer {
if (args.rotate || switchingPendingMode) {
if (pending?.relayBinding) {
// Why: record the durable cloud revoke before rotating the local token so an old relay invite can't outlive the QR.
this.queueRelayDeviceRevoke(pending.relayBinding)
if (!this.queueRelayDeviceRevoke(pending.relayBinding)) {
return pairingUnavailable(
'device_registry_unavailable',
'Could not persist Relay cleanup before rotating the pairing code.'
)
}
}
}
const direct = this.createPairingOffer({
@@ -705,42 +780,178 @@ export class OrcaRuntimeRpcServer {
if (!direct.available) {
return direct
}
this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode)
if (connectionMode === 'local-only' || !this.mobileRelayPairingProvider) {
const createdNewPendingDevice = pending?.deviceId !== direct.deviceId
let connectionModeStored = false
try {
connectionModeStored =
this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) ??
false
} catch (error) {
console.error('[runtime] Failed to persist the pairing connection mode:', error)
}
// Why: the mode is part of the credential — a QR whose policy was never stored must not pair under the default one.
if (!connectionModeStored) {
if (createdNewPendingDevice) {
this.discardPendingMobilePairingDevice(direct.deviceId)
}
return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE)
}
// Why: explicit LAN path never needs Relay; mint the direct-only offer as selected.
if (connectionMode === 'local-only') {
return { ...direct, connectionMode: 'local-only' }
}
// Why: Anywhere must not silently ship a LAN-only QR under the Relay label.
// Fail closed, drop the unused pending credential, and let the UI offer Use LAN.
const refuseAutomaticWithoutRelay = (
relayFailure: MobileRelayMintFailure
): PairingOfferUnavailable => {
if (createdNewPendingDevice) {
this.discardPendingMobilePairingDevice(direct.deviceId)
}
return {
available: false,
reason: 'relay_mint_failed',
guidance:
'Orca Relay could not create a pairing invite. Use LAN (Tailscale or same Wi‑Fi) or retry Relay.',
relayFailure
}
}
const relayProvider = this.mobileRelayPairingProvider
if (!relayProvider) {
return refuseAutomaticWithoutRelay({
code: 'relay_provider_unavailable',
stage: 'provider_missing',
message: 'Orca Relay is not available on this desktop'
})
}
const device = this.deviceRegistry?.getDevice(direct.deviceId)
const publicKeyB64 = this.getE2EEPublicKey()
if (!device || !publicKeyB64) {
return { ...direct, connectionMode: 'local-only' }
return refuseAutomaticWithoutRelay({
code: 'e2ee_key_unavailable',
stage: 'e2ee_missing',
message: 'E2EE public key unavailable for Relay pairing'
})
}
let relayPairing: Awaited<ReturnType<MobileRelayPairingProvider['createPairingRelay']>>
try {
relayPairing = await relayProvider.createPairingRelay(device.deviceId)
} catch (error) {
// Why: the raw provider error can carry request metadata or credentials — log only the validated code.
const relayFailure = mobileRelayMintFailureFromUnknown({
stage: 'create_pairing_relay',
error,
fallbackCode: 'relay_mint_failed',
fallbackMessage: 'Relay pairing invite request failed'
})
console.warn(`[runtime] Failed to create Relay pairing invite: ${relayFailure.code}`)
return refuseAutomaticWithoutRelay(relayFailure)
}
const currentDevice = this.deviceRegistry?.getDevice(device.deviceId)
if (
generation !== this.mobilePairingOfferGeneration ||
relayProvider !== this.mobileRelayPairingProvider ||
currentDevice?.token !== device.token ||
this.deviceRegistry?.getMobilePairingConnectionMode(device.deviceId) !== 'automatic'
) {
this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding)
if (createdNewPendingDevice) {
this.discardPendingMobilePairingDevice(direct.deviceId)
}
return this.relayPairingRequestSuperseded()
}
try {
const relayPairing = await this.mobileRelayPairingProvider.createPairingRelay(device.deviceId)
if (!this.deviceRegistry?.setRelayBinding(device.deviceId, relayPairing.binding)) {
return { ...direct, connectionMode: 'local-only' }
}
this.mobileRelayPairingProvider.onDemandStateChanged?.()
return {
...direct,
connectionMode: 'automatic',
pairingUrl: encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint: direct.endpoint,
deviceToken: device.token,
publicKeyB64,
scope: 'mobile',
relay: relayPairing.relay
if (!this.setMobileRelayBinding(device.deviceId, relayPairing.binding)) {
this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding)
return refuseAutomaticWithoutRelay({
code: 'relay_binding_failed',
stage: 'binding_failed',
message: 'Could not store Relay binding for the pairing device'
})
}
} catch {
// Why: relay is additive — a transient outage must still yield the valid LAN/Tailscale pairing offer.
return { ...direct, connectionMode: 'local-only' }
} catch (error) {
console.warn('[runtime] Failed to persist Relay pairing binding:', error)
this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding)
return refuseAutomaticWithoutRelay({
code: 'relay_binding_failed',
stage: 'binding_failed',
message: 'Could not store Relay binding for the pairing device'
})
}
return {
...direct,
connectionMode: 'automatic',
pairingUrl: encodePairingOffer({
v: PAIRING_OFFER_VERSION,
endpoint: direct.endpoint,
deviceToken: device.token,
publicKeyB64,
scope: 'mobile',
relay: relayPairing.relay
})
}
}
private queueRelayDeviceRevoke(binding: RelayDeviceBinding): void {
const item = this.relayRevokeOutbox.enqueue(binding)
this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item)
private relayPairingRequestSuperseded(): PairingOfferUnavailable {
return {
available: false,
reason: 'relay_mint_failed',
guidance: 'The Relay pairing request was replaced by a newer connection choice.',
relayFailure: {
code: 'relay_request_superseded',
stage: 'binding_failed',
message: 'Relay pairing request superseded'
}
}
}
/** Drop a never-scanned mobile pending credential after a failed Anywhere mint. */
private discardPendingMobilePairingDevice(deviceId: string): void {
const device = this.deviceRegistry?.getDevice(deviceId)
if (!device || device.scope !== 'mobile' || device.lastSeenAt !== 0) {
return
}
if (device.relayBinding) {
if (!this.queueRelayDeviceRevoke(device.relayBinding)) {
return
}
}
try {
this.deviceRegistry?.removeDevice(deviceId)
} catch (error) {
console.error('[runtime] Failed to drop an unused mobile pairing credential:', error)
}
}
/**
* Why: the outbox is the only durable cleanup record for a minted invite. When it can't be
* written, keep the binding on the device so cleanup keeps a reference instead of orphaning it.
*/
private queueOrRetainRelayDeviceRevoke(deviceId: string, binding: RelayDeviceBinding): void {
if (this.queueRelayDeviceRevoke(binding)) {
return
}
try {
this.deviceRegistry?.setRelayBinding(deviceId, binding)
} catch (error) {
console.error('[runtime] Failed to retain an unrevoked Relay binding:', error)
}
}
private queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean {
let item: RelayRevokeOutboxItem
try {
item = this.relayRevokeOutbox.enqueue(binding)
} catch (error) {
console.error('[runtime] Failed to persist Relay device cleanup:', error)
return false
}
try {
this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item)
} catch (error) {
console.warn('[runtime] Failed to notify Relay cleanup worker:', error)
}
return true
}
private registerBinaryStreamHandler(
+56 -4
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Stats } from 'node:fs'
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
@@ -20,6 +20,10 @@ describe('isServerDriveListRequest', () => {
})
describe('listWindowsDrives', () => {
afterEach(() => {
vi.restoreAllMocks()
})
const statOnly = (mounted: string[]) => async (p: string) => {
if (!mounted.includes(p)) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
@@ -51,14 +55,62 @@ describe('listWindowsDrives', () => {
expect(result.entries.map((e) => e.name)).toEqual(['C:\\'])
})
it.each(['EIO', 'EMFILE'])('surfaces systemic %s failures', async (code) => {
it('skips an unreadable drive with an unexpected errno and logs it', async () => {
const error = Object.assign(new Error('EIO'), { code: 'EIO' })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const statPath = async (p: string): Promise<Stats> => {
if (p === 'D:\\') {
throw Object.assign(new Error(code), { code })
throw error
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
await expect(listWindowsDrives(statPath)).rejects.toMatchObject({ code })
const result = await listWindowsDrives(statPath)
expect(result.entries).toEqual([])
expect(warn).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledWith('[windows-drive-listing] Failed to stat drive', {
root: 'D:\\',
error
})
})
it('surfaces programming errors from isDirectory', async () => {
const error = new TypeError('programmer bug')
const statPath = async (p: string): Promise<Stats> => {
if (p === 'D:\\') {
return {
isDirectory: () => {
throw error
}
} as unknown as Stats
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
await expect(listWindowsDrives(statPath)).rejects.toBe(error)
})
it('returns healthy drives among tolerated and unexpected stat failures', async () => {
const error = Object.assign(new Error('EIO'), { code: 'EIO' })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const statPath = async (p: string): Promise<Stats> => {
if (p === 'C:\\' || p === 'F:\\') {
return { isDirectory: () => true } as Stats
}
if (p === 'E:\\') {
throw error
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
const result = await listWindowsDrives(statPath)
expect(result.entries.map((entry) => entry.name)).toEqual(['C:\\', 'F:\\'])
expect(warn).toHaveBeenCalledOnce()
expect(warn).toHaveBeenCalledWith('[windows-drive-listing] Failed to stat drive', {
root: 'E:\\',
error
})
})
})
+6 -5
View File
@@ -26,15 +26,16 @@ export async function listWindowsDrives(
const roots = await Promise.all(
[...DRIVE_LETTERS].map(async (letter) => {
const root = `${letter}:\\`
let stats: Stats
try {
const stats = await statPath(root)
return stats.isDirectory() ? root : null
stats = await statPath(root)
} catch (error) {
if (isExpectedUnavailableDriveError(error)) {
return null
if (!isExpectedUnavailableDriveError(error)) {
console.warn('[windows-drive-listing] Failed to stat drive', { root, error })
}
throw error
return null
}
return stats.isDirectory() ? root : null
})
)
return {
@@ -402,6 +402,7 @@ function registerRuntimeWindowLifecycle(
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
activate: opts.activate !== false,
...(opts.presentation ? { presentation: opts.presentation } : {}),
...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}),
// Why: pre-minted tabId aligns the renderer tab id with the paneKey baked into the PTY env, so hook events route right.
...(opts.tabId !== undefined ? { tabId: opts.tabId } : {}),
...(opts.leafId !== undefined ? { leafId: opts.leafId } : {}),
@@ -472,6 +473,8 @@ function registerRuntimeWindowLifecycle(
send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }),
terminalDriverChanged: (ptyId, driver) =>
send('runtime:terminalDriverChanged', { ptyId, driver }),
nativeChatLaunchDraftResolved: (tabId, resolution) =>
send('runtime:nativeChatLaunchDraftResolved', { tabId, ...resolution }),
browserDriverChanged: (browserPageId, driver) =>
send('runtime:browserDriverChanged', { browserPageId, driver })
})
+18 -2
View File
@@ -47,6 +47,8 @@ import type {
} from '../shared/terminal-render-desync-evidence'
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure'
import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification'
import type {
SshMutationExpectation,
SshConnectionState,
@@ -3156,6 +3158,7 @@ export type PreloadApi = {
activate?: boolean
focus?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
tabId?: string
leafId?: string
splitFromLeafId?: string
@@ -3304,6 +3307,9 @@ export type PreloadApi = {
onTerminalDriverChanged: (
callback: (event: { ptyId: string; driver: RuntimeTerminalDriverState }) => void
) => () => void
onNativeChatLaunchDraftResolved?: (
callback: (event: { tabId: string; text: string; createdAt: number }) => void
) => () => void
onBrowserDriverChanged: (
callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void
) => () => void
@@ -3314,6 +3320,11 @@ export type PreloadApi = {
name: string
pairingCode: string
}) => Promise<{ environment: PublicKnownRuntimeEnvironment }>
verifyAndAddFromPairingCode: (args: {
name: string
pairingCode: string
allowLoopback?: boolean
}) => Promise<VerifyAndAddRuntimeEnvironmentResult>
resolve: (args: { selector: string }) => Promise<PublicKnownRuntimeEnvironment>
remove: (args: { selector: string }) => Promise<{ removed: PublicKnownRuntimeEnvironment }>
disconnect: (args: {
@@ -3566,7 +3577,12 @@ export type PreloadApi = {
connectionMode?: MobilePairingConnectionMode
rotate?: boolean
}) => Promise<
| { available: false }
| {
available: false
reason?: string
guidance?: string
relayFailure?: MobileRelayMintFailure
}
| {
available: true
qrDataUrl: string | null
@@ -3574,7 +3590,7 @@ export type PreloadApi = {
pairingUrl: string
endpoint: string
deviceId: string
/** Mode the QR actually encodes; 'local-only' when Relay could not be attached. */
/** Mode the QR actually encodes. */
connectionMode: MobilePairingConnectionMode
}
>
+26 -1
View File
@@ -22,6 +22,8 @@ import type {
} from '../shared/agent-session-resume'
import type { MobileRelayStatus } from '../shared/mobile-relay-status'
import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode'
import type { MobileRelayMintFailure } from '../shared/mobile-relay-mint-failure'
import type { VerifyAndAddRuntimeEnvironmentResult } from '../shared/remote-pairing-verification'
import type {
SshMutationExpectation,
SshConnectionState,
@@ -3721,6 +3723,7 @@ const api = {
activate?: boolean
focus?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
tabId?: string
leafId?: string
splitFromLeafId?: string
@@ -3746,6 +3749,7 @@ const api = {
activate?: boolean
focus?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
tabId?: string
leafId?: string
splitFromLeafId?: string
@@ -4250,6 +4254,16 @@ const api = {
ipcRenderer.on('runtime:terminalDriverChanged', listener)
return () => ipcRenderer.removeListener('runtime:terminalDriverChanged', listener)
},
onNativeChatLaunchDraftResolved: (
callback: (event: { tabId: string; text: string; createdAt: number }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { tabId: string; text: string; createdAt: number }
) => callback(data)
ipcRenderer.on('runtime:nativeChatLaunchDraftResolved', listener)
return () => ipcRenderer.removeListener('runtime:nativeChatLaunchDraftResolved', listener)
},
onBrowserDriverChanged: (
callback: (event: { browserPageId: string; driver: RuntimeBrowserDriverState }) => void
): (() => void) => {
@@ -4273,6 +4287,12 @@ const api = {
pairingCode: string
}): Promise<{ environment: PublicKnownRuntimeEnvironment }> =>
ipcRenderer.invoke('runtimeEnvironments:addFromPairingCode', args),
verifyAndAddFromPairingCode: (args: {
name: string
pairingCode: string
allowLoopback?: boolean
}): Promise<VerifyAndAddRuntimeEnvironmentResult> =>
ipcRenderer.invoke('runtimeEnvironments:verifyAndAddFromPairingCode', args),
resolve: (args: { selector: string }): Promise<PublicKnownRuntimeEnvironment> =>
ipcRenderer.invoke('runtimeEnvironments:resolve', args),
remove: (args: { selector: string }): Promise<{ removed: PublicKnownRuntimeEnvironment }> =>
@@ -4567,7 +4587,12 @@ const api = {
connectionMode?: MobilePairingConnectionMode
rotate?: boolean
}): Promise<
| { available: false }
| {
available: false
reason?: string
guidance?: string
relayFailure?: MobileRelayMintFailure
}
| {
available: true
qrDataUrl: string | null
+60 -3
View File
@@ -52,10 +52,12 @@
--m-mono: 'Menlo', 'SF Mono', monospace;
position: relative;
container-type: inline-size;
display: block;
width: 100%;
height: 100%;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
color: var(--foreground);
}
@@ -72,8 +74,7 @@
gap: clamp(56px, 7vw, 112px);
padding: 56px;
width: 100%;
height: 100%;
overflow: hidden;
min-height: 100%;
background-color: transparent;
/* Why: the page should feel branded without putting copy over decorative
blobs; a token-based dot texture stays quiet in both themes. */
@@ -612,6 +613,20 @@
font-weight: 600;
}
/* Why: empty QR frames need readable copy without looking like a stuck loader overlay. */
.mobile-page-root .mp-qr-empty {
position: absolute;
inset: 10px;
display: grid;
place-items: center;
border-radius: 4px;
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-wrap: balance;
}
.mobile-page-root .mp-qr-stack {
display: flex;
flex-direction: column;
@@ -681,6 +696,14 @@
align-items: start;
}
.mobile-page-root .mp-pairing-layout.has-failure {
grid-template-areas:
'copy qr'
'relay qr'
'failure failure'
'controls controls';
}
.mobile-page-root .mp-pairing-copy {
grid-area: copy;
min-width: 0;
@@ -703,6 +726,10 @@
min-width: 0;
}
.mobile-page-root .mp-pairing-failure {
grid-area: failure;
}
/* Why: align the QR's top with the body copy ("Scan the QR…") instead
of the eyebrow row above it. The eyebrow row is ~22px tall + 22px
margin-bottom, and the h2 is ~38px line-height with no top margin —
@@ -2102,3 +2129,33 @@
font-size: 17px;
}
}
/* Why: the app sidebar narrows this page without changing viewport media
queries; keep this after viewport rules so real page width wins. */
@container (max-width: 1080px) {
.mobile-page-root .mp-hero {
--mp-flow-card-padding: 20px;
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
align-items: start;
gap: 24px;
padding: 52px 28px 28px;
}
.mobile-page-root .mp-hero-copy {
min-height: 0;
}
.mobile-page-root .mp-h1 {
font-size: 44px;
}
.mobile-page-root .mp-stage {
height: min(420px, 42vh);
}
.mobile-page-root .mp-phone-frame {
width: min(290px, 100%);
}
}
@@ -1,7 +1,7 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { Editor } from '@tiptap/react'
import type { DocLinkMenuState } from './rich-markdown-commands'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import {
createRichMarkdownEditorConfig,
@@ -88,6 +88,10 @@ function createConfigParams(overrides: Partial<EditorConfigParams> = {}): Editor
}
describe('createRichMarkdownEditorConfig', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('disables browser spellcheck when the rich Markdown setting is off', () => {
const config = createRichMarkdownEditorConfig(
createConfigParams({ richMarkdownSpellcheckEnabled: false })
@@ -101,4 +105,22 @@ describe('createRichMarkdownEditorConfig', () => {
expect(getSpellcheckAttribute(config)).toBe('true')
})
it('flushes pending serialization when the rich editor blurs', () => {
const setMarkdownEditorFocused = vi.fn()
vi.stubGlobal('window', {
api: { ui: { setMarkdownEditorFocused } }
})
const flushPendingSerialization = vi.fn()
const clearAnnotationTarget = vi.fn()
const config = createRichMarkdownEditorConfig(
createConfigParams({ clearAnnotationTarget, flushPendingSerialization })
)
config.onBlur?.({} as never)
expect(setMarkdownEditorFocused).toHaveBeenCalledWith(false)
expect(clearAnnotationTarget).toHaveBeenCalledOnce()
expect(flushPendingSerialization).toHaveBeenCalledOnce()
})
})
@@ -204,6 +204,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
onBlur: () => {
window.api.ui.setMarkdownEditorFocused(false)
clearAnnotationTarget()
params.flushPendingSerialization()
},
onCreate: ({ editor: nextEditor }) => {
// Why: normalizeEmptyListItems (not normalizeSoftBreaks) so hard-wrapped
@@ -2,7 +2,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n/i18n', () => ({
@@ -27,6 +27,7 @@ vi.mock('./WindowsFirewallNotice', () => ({
}))
import { HeroFlow, type StepIndex } from './MobileHero'
import { MobileHeroPairingStep } from './MobileHeroPairingStep'
class MockResizeObserver {
observe = vi.fn()
@@ -75,7 +76,10 @@ describe('HeroFlow height', () => {
pairQrDataUrl={null}
pairingUrl={null}
pairingQrError={false}
relayDegraded={false}
relayMintFailure={null}
onUseLan={vi.fn()}
onRetryRelay={vi.fn()}
onCopyRelayDiagnostics={vi.fn()}
pairLoading={false}
connectionMode="automatic"
onConnectionModeChange={vi.fn()}
@@ -115,7 +119,10 @@ describe('HeroFlow height', () => {
pairQrDataUrl={null}
pairingUrl={null}
pairingQrError={false}
relayDegraded={false}
relayMintFailure={null}
onUseLan={vi.fn()}
onRetryRelay={vi.fn()}
onCopyRelayDiagnostics={vi.fn()}
pairLoading={false}
connectionMode="automatic"
onConnectionModeChange={vi.fn()}
@@ -137,23 +144,69 @@ describe('HeroFlow height', () => {
expect(screen.getByText('Step 1 of 2').closest('.mp-flow-screen')).toHaveAttribute('inert')
})
it('flags a degraded Anywhere code and always shows the Relay beta note', () => {
it('shows Relay mint failure with no QR and the beta note', () => {
renderFlow(1, {
pairQrDataUrl: 'data:image/png;base64,qr',
relayDegraded: true
pairQrDataUrl: null,
relayMintFailure: {
code: 'relay offline',
stage: 'create_pairing_relay',
message: 'relay offline'
}
})
const notice = screen.getByTestId('relay-degraded-notice')
expect(notice).toHaveTextContent('only works on your LAN or Tailscale')
// Why: wrap-capable text item inside the fixed QR track (#9700); bare text
// nodes in a flex row cannot shrink below max-content and overflow the track.
expect(notice.querySelector('.min-w-0')).not.toBeNull()
expect(notice.className).toMatch(/\bmin-w-0\b/)
const notice = screen.getByTestId('relay-mint-failure-notice')
expect(notice).toHaveTextContent('Couldn’t create a Relay pairing code')
expect(notice).toHaveTextContent('Use LAN')
expect(screen.getByText('No pairing code available')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Generate code' })).not.toBeInTheDocument()
expect(screen.getByText('Orca Relay is in beta.')).toBeInTheDocument()
})
it('hides the degradation notice when the code encodes what was selected', () => {
it('explains an empty QR frame when no code has been generated yet', () => {
renderFlow(1, { pairQrDataUrl: null, canGeneratePairing: true })
expect(screen.getByText('Generate a pairing code to continue')).toBeInTheDocument()
})
it('explains an empty QR frame when Relay sign-in is required', () => {
renderFlow(1, {
pairQrDataUrl: null,
canGeneratePairing: false,
connectionMode: 'automatic'
})
expect(screen.getByText('Sign in to create a Relay pairing code')).toBeInTheDocument()
})
it('disables Relay recovery immediately and delays visible retry feedback', async () => {
renderFlow(1, {
pairLoading: true,
relayMintFailure: {
code: 'relay_mint_failed',
stage: 'create_pairing_relay',
message: 'Relay pairing invite request failed'
}
})
expect(screen.getByRole('button', { name: 'Retry Relay' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled()
await waitFor(() => expect(screen.getByText(/Creating a new pairing code/)).toBeVisible())
})
it('does not offer a futile retry when Relay is unavailable on the desktop', () => {
renderFlow(1, {
relayMintFailure: {
code: 'relay_provider_unavailable',
stage: 'provider_missing',
message: 'Orca Relay is not available on this desktop'
}
})
expect(screen.getByRole('alert')).toHaveTextContent(
'Orca Relay isn’t available on this desktop'
)
expect(screen.queryByRole('button', { name: 'Retry Relay' })).toBeNull()
expect(screen.getByRole('button', { name: 'Use LAN' })).toBeEnabled()
})
it('hides the mint-failure notice when a Relay QR is shown', () => {
renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' })
expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument()
expect(screen.queryByTestId('relay-mint-failure-notice')).not.toBeInTheDocument()
})
it('shows an encoder error while keeping the copy fallback enabled', () => {
@@ -165,4 +218,83 @@ describe('HeroFlow height', () => {
expect(screen.getByRole('alert')).toHaveTextContent('couldn’t be rendered as a QR code')
expect(screen.getByRole('button', { name: /Copy pairing code/ })).toBeEnabled()
})
it('moves focus to the pairing-code action after recovery succeeds', () => {
const props: React.ComponentProps<typeof MobileHeroPairingStep> = {
pairQrDataUrl: null,
pairingUrl: null,
pairingQrError: false,
relayMintFailure: {
code: 'relay_mint_failed',
stage: 'create_pairing_relay',
message: 'Relay pairing invite request failed'
},
onUseLan: vi.fn(),
onRetryRelay: vi.fn(),
onCopyRelayDiagnostics: vi.fn(),
pairLoading: false,
connectionMode: 'automatic',
onConnectionModeChange: vi.fn(),
onRegeneratePairing: vi.fn(),
canGeneratePairing: true,
onCopyPairingCode: vi.fn(),
networkInterfaces: [],
selectedAddress: undefined,
onSelectedAddressChange: vi.fn(),
beforeCustomAddressChange: vi.fn().mockResolvedValue(true),
onRefreshNetworkInterfaces: vi.fn(),
refreshingNetworkInterfaces: false
}
const { rerender } = render(<MobileHeroPairingStep {...props} />)
screen.getByRole('button', { name: 'Retry Relay' }).focus()
rerender(
<MobileHeroPairingStep
{...props}
pairQrDataUrl="data:image/png;base64,qr"
pairingUrl="orca://pair#ready"
relayMintFailure={null}
/>
)
expect(screen.getByRole('button', { name: 'Copy pairing code' })).toHaveFocus()
})
it('keeps focus on a persistent control when an ordinary remint finishes', () => {
const props: React.ComponentProps<typeof MobileHeroPairingStep> = {
pairQrDataUrl: null,
pairingUrl: null,
pairingQrError: false,
relayMintFailure: null,
onUseLan: vi.fn(),
onRetryRelay: vi.fn(),
onCopyRelayDiagnostics: vi.fn(),
pairLoading: true,
connectionMode: 'local-only',
onConnectionModeChange: vi.fn(),
onRegeneratePairing: vi.fn(),
canGeneratePairing: true,
onCopyPairingCode: vi.fn(),
networkInterfaces: [],
selectedAddress: undefined,
onSelectedAddressChange: vi.fn(),
beforeCustomAddressChange: vi.fn().mockResolvedValue(true),
onRefreshNetworkInterfaces: vi.fn(),
refreshingNetworkInterfaces: false
}
const { rerender } = render(<MobileHeroPairingStep {...props} />)
const refresh = screen.getByRole('button', { name: 'Refresh network interfaces' })
refresh.focus()
rerender(
<MobileHeroPairingStep
{...props}
pairQrDataUrl="data:image/png;base64,qr"
pairingUrl="orca://pair#ready"
pairLoading={false}
/>
)
expect(refresh).toHaveFocus()
})
})
+33 -176
View File
@@ -1,14 +1,12 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { ArrowLeft, ArrowRight, CircleAlert, Copy, RefreshCw } from 'lucide-react'
import { ArrowLeft, ArrowRight, Copy } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons'
import { NetworkInterfacePicker } from './NetworkInterfacePicker'
import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions'
import { MobileRelayBetaNotice } from '../settings/MobileRelayBetaNotice'
import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy'
import { WindowsFirewallNotice } from './WindowsFirewallNotice'
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
import { MobileHeroPairingStep } from './MobileHeroPairingStep'
export { HeroIntro } from './MobileHeroIntro'
export { HeroPaired, type PairedDevice } from './MobileHeroPairedDevices'
import { translate } from '@/i18n/i18n'
@@ -16,18 +14,6 @@ import { translate } from '@/i18n/i18n'
export type Platform = 'ios' | 'android'
export type StepIndex = 0 | 1
// Why: header copy needs to refer to the *user's* device by its native name.
function getDeviceLabel(): string {
const ua = navigator.userAgent
if (ua.includes('Mac')) {
return 'Mac'
}
if (ua.includes('Windows')) {
return 'PC'
}
return 'computer'
}
type HeroFlowProps = {
stepIdx: StepIndex
platform: Platform
@@ -41,8 +27,10 @@ type HeroFlowProps = {
pairQrDataUrl: string | null
pairingUrl: string | null
pairingQrError: boolean
/** True when the shown QR degraded to local-only under an Anywhere selection. */
relayDegraded: boolean
relayMintFailure: MobileRelayMintFailure | null
onUseLan: () => void
onRetryRelay: () => void
onCopyRelayDiagnostics: () => void
pairLoading: boolean
connectionMode: MobilePairingConnectionMode
onConnectionModeChange: (mode: MobilePairingConnectionMode) => void
@@ -73,7 +61,10 @@ export function HeroFlow({
pairQrDataUrl,
pairingUrl,
pairingQrError,
relayDegraded,
relayMintFailure,
onUseLan,
onRetryRelay,
onCopyRelayDiagnostics,
pairLoading,
connectionMode,
onConnectionModeChange,
@@ -202,13 +193,7 @@ export function HeroFlow({
</button>
</div>
</div>
<div
className="mp-qr mp-qr-large"
aria-label={translate(
'auto.components.mobile.MobileHero.7af266b80d',
'Install QR code'
)}
>
<div className="mp-qr mp-qr-large">
{installQrUrl ? (
<img
src={installQrUrl}
@@ -227,155 +212,27 @@ export function HeroFlow({
aria-hidden={stepIdx !== 1}
inert={stepIdx !== 1}
>
<div className="mp-pairing-layout">
<div className="mp-step2-copy mp-pairing-copy">
<div className="mp-eyebrow-row">
<div className="mp-step-num">2</div>
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')}
</span>
</div>
<h2 className="mp-h2">
{translate('auto.components.mobile.MobileHero.901c98bb93', 'Pair this')}{' '}
{getDeviceLabel()}.
</h2>
<p className="mp-lead-sm">
{translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '}
<strong>
{translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')}
</strong>
{translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}
</p>
</div>
<div className="mp-pairing-relay">
<MobilePairingConnectionOptions
value={connectionMode}
onChange={onConnectionModeChange}
compact
/>
<MobileRelayBetaNotice className="mt-1.5" />
</div>
<div className="mp-qr-stack mp-pairing-qr">
<div
className="mp-qr mp-qr-large"
aria-label={translate(
'auto.components.mobile.MobileHero.bb0074ce11',
'Pairing QR code'
)}
aria-busy={pairLoading}
>
{pairQrDataUrl ? (
<img
src={pairQrDataUrl}
alt={translate('auto.components.mobile.MobileHero.27735e5f4e', 'Pairing QR')}
className={cn(pairLoading && 'mp-qr-refreshing')}
/>
) : null}
{pairLoading ? (
<span className="mp-qr-loading">
{translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')}
</span>
) : null}
</div>
<button
type="button"
className="mp-link-under"
onClick={onRegeneratePairing}
// Why: signed-out Anywhere can't serve Relay; disabling avoids
// minting a local-only QR under the Relay label. Sign in or pick
// LAN (shown in the path options above) to enable it.
disabled={pairLoading || !canGeneratePairing}
>
{pairLoading
? translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')
: pairQrDataUrl
? translate('auto.components.mobile.MobileHero.e59a252eca', 'Regenerate code')
: translate('auto.components.mobile.MobileHero.a6cffbbb0b', 'Generate code')}
</button>
{relayDegraded ? (
<p
className="flex w-full min-w-0 items-start gap-1.5 text-xs text-muted-foreground"
data-testid="relay-degraded-notice"
>
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
{/* Why: min-w-0 so the flex text item can wrap inside the fixed QR track (#9700). */}
<span className="min-w-0">
{translate(
'auto.components.mobile.MobileHero.relayDegradedNotice',
'Relay couldn’t be reached — this code only works on your LAN or Tailscale.'
)}
</span>
</p>
) : null}
{pairingQrError ? (
<p
className="flex w-full min-w-0 items-start gap-1.5 text-xs text-destructive"
role="alert"
>
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0">
{translate(
'auto.components.mobile.MobileHero.pairingQrError',
'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.'
)}
</span>
</p>
) : null}
</div>
<div className="mp-pairing-controls">
<div className="mp-network-row">
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
beforeCustomAddressChange={beforeCustomAddressChange}
// Why: direct-first and local-only pairing both advertise a
// local route; keeping it visible also prevents mode shifts.
disabled={false}
className="mp-network-select"
/>
<button
type="button"
className={cn('mp-network-refresh', refreshingNetworkInterfaces && 'is-spinning')}
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
title={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
>
<RefreshCw className="size-3.5" />
</button>
</div>
<div className="mp-inline-actions">
<span className="mp-action-divider">
{translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")}
</span>
<button
type="button"
className="mp-text-link"
onClick={onCopyPairingCode}
disabled={!pairingUrl || pairLoading}
>
<Copy className="size-3.5" />
{translate('auto.components.mobile.MobileHero.010dddcf27', 'Copy pairing code')}
</button>
</div>
<WindowsFirewallNotice
pairingReady={pairQrDataUrl != null}
address={selectedAddress}
className="mt-3"
/>
</div>
</div>
<MobileHeroPairingStep
pairQrDataUrl={pairQrDataUrl}
pairingUrl={pairingUrl}
pairingQrError={pairingQrError}
relayMintFailure={relayMintFailure}
onUseLan={onUseLan}
onRetryRelay={onRetryRelay}
onCopyRelayDiagnostics={onCopyRelayDiagnostics}
pairLoading={pairLoading}
connectionMode={connectionMode}
onConnectionModeChange={onConnectionModeChange}
onRegeneratePairing={onRegeneratePairing}
canGeneratePairing={canGeneratePairing}
onCopyPairingCode={onCopyPairingCode}
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
beforeCustomAddressChange={beforeCustomAddressChange}
onRefreshNetworkInterfaces={onRefreshNetworkInterfaces}
refreshingNetworkInterfaces={refreshingNetworkInterfaces}
/>
</div>
</div>
@@ -0,0 +1,268 @@
import { useEffect, useRef } from 'react'
import { CircleAlert, Copy, RefreshCw } from 'lucide-react'
import { cn } from '../../lib/utils'
import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection'
import { NetworkInterfacePicker } from './NetworkInterfacePicker'
import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions'
import { MobileRelayBetaNotice } from '../settings/MobileRelayBetaNotice'
import { MobileRelayMintFailureNotice } from './mobile-relay-mint-failure-notice'
import { WindowsFirewallNotice } from './WindowsFirewallNotice'
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
import { translate } from '@/i18n/i18n'
/** Why: one full sentence per device kind so translators own word order and punctuation. */
function pairDeviceHeading(): string {
const ua = navigator.userAgent
if (ua.includes('Mac')) {
return translate('auto.components.mobile.MobileHero.pairThisMac', 'Pair this Mac.')
}
if (ua.includes('Windows')) {
return translate('auto.components.mobile.MobileHero.pairThisPc', 'Pair this PC.')
}
return translate('auto.components.mobile.MobileHero.pairThisComputer', 'Pair this computer.')
}
/** Short copy for the QR frame when no image can be shown. */
function emptyPairingQrMessage(args: {
relayMintFailure: MobileRelayMintFailure | null
canGeneratePairing: boolean
connectionMode: MobilePairingConnectionMode
pairingQrError: boolean
pairingUrl: string | null
}): string {
if (args.relayMintFailure != null) {
return translate('auto.components.mobile.MobileHero.noRelayCode', 'No pairing code available')
}
if (!args.canGeneratePairing && args.connectionMode === 'automatic') {
return translate(
'auto.components.mobile.MobileHero.qrSignInRequired',
'Sign in to create a Relay pairing code'
)
}
if (args.pairingQrError && args.pairingUrl != null) {
return translate(
'auto.components.mobile.MobileHero.qrRenderFailed',
'QR couldn’t be rendered — copy the code below'
)
}
if (!args.canGeneratePairing) {
return translate('auto.components.mobile.MobileHero.noPairingCode', 'No pairing code available')
}
return translate(
'auto.components.mobile.MobileHero.qrGeneratePrompt',
'Generate a pairing code to continue'
)
}
export function MobileHeroPairingStep({
pairQrDataUrl,
pairingUrl,
pairingQrError,
relayMintFailure,
onUseLan,
onRetryRelay,
onCopyRelayDiagnostics,
pairLoading,
connectionMode,
onConnectionModeChange,
onRegeneratePairing,
canGeneratePairing,
onCopyPairingCode,
networkInterfaces,
selectedAddress,
onSelectedAddressChange,
beforeCustomAddressChange,
onRefreshNetworkInterfaces,
refreshingNetworkInterfaces
}: {
pairQrDataUrl: string | null
pairingUrl: string | null
pairingQrError: boolean
relayMintFailure: MobileRelayMintFailure | null
onUseLan: () => void
onRetryRelay: () => void
onCopyRelayDiagnostics: () => void
pairLoading: boolean
connectionMode: MobilePairingConnectionMode
onConnectionModeChange: (mode: MobilePairingConnectionMode) => void
onRegeneratePairing: () => void
canGeneratePairing: boolean
onCopyPairingCode: () => void
networkInterfaces: readonly MobileNetworkInterface[]
selectedAddress: string | undefined
onSelectedAddressChange: (address: string) => void
beforeCustomAddressChange: (address: string) => Promise<boolean>
onRefreshNetworkInterfaces: () => void
refreshingNetworkInterfaces: boolean
}): React.JSX.Element {
const copyPairingCodeRef = useRef<HTMLButtonElement | null>(null)
const pairingWasReadyRef = useRef(pairingUrl != null && !pairLoading)
const emptyQrMessage =
!pairLoading && pairQrDataUrl == null
? emptyPairingQrMessage({
relayMintFailure,
canGeneratePairing,
connectionMode,
pairingQrError,
pairingUrl
})
: null
useEffect(() => {
const pairingReady = pairingUrl != null && !pairLoading
const becameReady = !pairingWasReadyRef.current && pairingReady
pairingWasReadyRef.current = pairingReady
if (becameReady && document.activeElement === document.body) {
copyPairingCodeRef.current?.focus()
}
}, [pairLoading, pairingUrl])
return (
<div className={cn('mp-pairing-layout', relayMintFailure != null && 'has-failure')}>
<div className="mp-step2-copy mp-pairing-copy">
<div className="mp-eyebrow-row">
<div className="mp-step-num">2</div>
<span className="mp-eyebrow">
{translate('auto.components.mobile.MobileHero.3960f5c339', 'Step 2 of 2')}
</span>
</div>
<h2 className="mp-h2">{pairDeviceHeading()}</h2>
<p className="mp-lead-sm">
{translate('auto.components.mobile.MobileHero.d1495e5e64', 'Open Orca Mobile, tap')}{' '}
<strong>
{translate('auto.components.mobile.MobileHero.3aa7bb2d8b', 'Pair Desktop')}
</strong>
{translate('auto.components.mobile.MobileHero.2f077ef4eb', ', and scan the code.')}
</p>
</div>
<div className="mp-pairing-relay">
<MobilePairingConnectionOptions
value={connectionMode}
onChange={onConnectionModeChange}
compact
relayMintFailed={relayMintFailure != null}
relayMintRetrying={relayMintFailure != null && pairLoading}
/>
<MobileRelayBetaNotice className="mt-1.5" />
</div>
{relayMintFailure != null ? (
<MobileRelayMintFailureNotice
className="mp-pairing-failure"
failure={relayMintFailure}
onUseLan={onUseLan}
onRetry={onRetryRelay}
onCopyDiagnostics={onCopyRelayDiagnostics}
compact
busy={pairLoading}
/>
) : null}
<div className="mp-qr-stack mp-pairing-qr">
<div className="mp-qr mp-qr-large" aria-busy={pairLoading}>
{pairQrDataUrl ? (
<img
src={pairQrDataUrl}
alt={translate('auto.components.mobile.MobileHero.27735e5f4e', 'Pairing QR')}
className={cn(pairLoading && 'mp-qr-refreshing')}
/>
) : null}
{pairLoading ? (
<span className="mp-qr-loading">
{translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')}
</span>
) : null}
{emptyQrMessage != null ? (
<span className="mp-qr-empty text-center text-xs text-muted-foreground px-3">
{emptyQrMessage}
</span>
) : null}
</div>
<span className="sr-only" role="status" aria-live="polite">
{pairQrDataUrl != null && !pairLoading
? translate('auto.components.mobile.MobileHero.pairingCodeReady', 'Pairing code ready')
: ''}
</span>
{relayMintFailure == null ? (
<button
type="button"
className="mp-link-under"
onClick={onRegeneratePairing}
disabled={pairLoading || !canGeneratePairing}
>
{pairLoading
? translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…')
: pairQrDataUrl
? translate('auto.components.mobile.MobileHero.e59a252eca', 'Regenerate code')
: translate('auto.components.mobile.MobileHero.a6cffbbb0b', 'Generate code')}
</button>
) : null}
{pairingQrError ? (
<p
className="flex w-full min-w-0 items-start gap-1.5 text-xs text-destructive"
role="alert"
>
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<span className="min-w-0">
{translate(
'auto.components.mobile.MobileHero.pairingQrError',
'This pairing code couldn’t be rendered as a QR code. Copy it into Orca Mobile instead.'
)}
</span>
</p>
) : null}
</div>
<div className="mp-pairing-controls">
<div className="mp-network-row">
<span className="mp-network-label">
{translate('auto.components.mobile.MobileHero.dfd2aa9d5d', 'Network')}
</span>
<NetworkInterfacePicker
networkInterfaces={networkInterfaces}
selectedAddress={selectedAddress}
onSelectedAddressChange={onSelectedAddressChange}
beforeCustomAddressChange={beforeCustomAddressChange}
disabled={false}
className="mp-network-select"
/>
<button
type="button"
className={cn('mp-network-refresh', refreshingNetworkInterfaces && 'is-spinning')}
onClick={onRefreshNetworkInterfaces}
disabled={refreshingNetworkInterfaces}
aria-label={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
title={translate(
'auto.components.mobile.MobileHero.85067b9e06',
'Refresh network interfaces'
)}
>
<RefreshCw className="size-3.5" />
</button>
</div>
<div className="mp-inline-actions">
<span className="mp-action-divider">
{translate('auto.components.mobile.MobileHero.4c1df4eba7', "Can't scan?")}
</span>
<button
ref={copyPairingCodeRef}
type="button"
className="mp-text-link"
onClick={onCopyPairingCode}
disabled={!pairingUrl || pairLoading}
>
<Copy className="size-3.5" />
{translate('auto.components.mobile.MobileHero.010dddcf27', 'Copy pairing code')}
</button>
</div>
<WindowsFirewallNotice
pairingReady={pairQrDataUrl != null}
address={selectedAddress}
className="mt-3"
/>
</div>
</div>
)
}
@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
type StoreState = {
closeMobilePage: () => void
@@ -48,6 +49,8 @@ vi.mock('./MobilePageContent', () => ({
pairQrDataUrl: string | null
pairingUrl: string | null
pairingQrError: boolean
relayMintFailure: MobileRelayMintFailure | null
onRetryRelay: () => void
stage: string | null
stepIdx: number
}) => (
@@ -59,6 +62,7 @@ vi.mock('./MobilePageContent', () => ({
<span data-testid="pairing-qr">{props.pairQrDataUrl ?? 'none'}</span>
<span data-testid="pairing-url">{props.pairingUrl ?? 'none'}</span>
<span data-testid="pairing-qr-error">{String(props.pairingQrError)}</span>
<span data-testid="relay-failure">{props.relayMintFailure?.stage ?? 'none'}</span>
<button type="button" onClick={props.enterFlow}>
Enter flow
</button>
@@ -71,6 +75,9 @@ vi.mock('./MobilePageContent', () => ({
<button type="button" onClick={() => props.handleConnectionModeChange('local-only')}>
LAN
</button>
<button type="button" onClick={props.onRetryRelay}>
Retry Relay
</button>
<button type="button" onClick={() => props.handleAddressChange('10.0.0.2')}>
Change address
</button>
@@ -310,7 +317,90 @@ describe('MobilePage pairing connection mode', () => {
expect(screen.getByTestId('pairing-url')).toHaveTextContent('copy-fallback')
})
it('does not commit a custom address when its real QR preflight fails', async () => {
it('surfaces Relay failure and retries with a rotated credential', async () => {
getPairingQR.mockResolvedValueOnce({
available: false,
reason: 'relay_mint_failed',
relayFailure: {
code: 'relay_control_not_active',
stage: 'create_pairing_relay',
message: 'Relay pairing invite request failed'
}
})
const user = userEvent.setup()
await openPairingStep()
await waitFor(() =>
expect(screen.getByTestId('relay-failure')).toHaveTextContent('create_pairing_relay')
)
getPairingQR.mockResolvedValueOnce({
available: true,
qrDataUrl: 'data:image/png;base64,retried',
pairingUrl: 'orca://pair#retried',
endpoint: 'ws://host',
connectionMode: 'automatic'
})
await user.click(screen.getByRole('button', { name: 'Retry Relay' }))
await waitFor(() =>
expect(getPairingQR).toHaveBeenLastCalledWith({
connectionMode: 'automatic',
rotate: true
})
)
await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('retried'))
expect(screen.getByTestId('relay-failure')).toHaveTextContent('none')
})
it('switches to LAN while a Relay retry is still unresolved', async () => {
getPairingQR.mockResolvedValueOnce({
available: false,
reason: 'relay_mint_failed',
relayFailure: {
code: 'relay_mint_failed',
stage: 'create_pairing_relay',
message: 'Relay pairing invite request failed'
}
})
const user = userEvent.setup()
await openPairingStep()
await waitFor(() =>
expect(screen.getByTestId('relay-failure')).toHaveTextContent('create_pairing_relay')
)
let resolveRetry: ((value: Record<string, unknown>) => void) | undefined
getPairingQR.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRetry = resolve
})
)
getPairingQR.mockResolvedValueOnce({
available: true,
qrDataUrl: 'data:image/png;base64,local',
pairingUrl: 'orca://pair#local',
endpoint: 'ws://host',
connectionMode: 'local-only'
})
await user.click(screen.getByRole('button', { name: 'Retry Relay' }))
await user.click(screen.getByRole('button', { name: 'LAN' }))
await waitFor(() =>
expect(getPairingQR).toHaveBeenLastCalledWith({ connectionMode: 'local-only' })
)
await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,local'))
resolveRetry?.({
available: true,
qrDataUrl: 'data:image/png;base64,stale-relay',
pairingUrl: 'orca://pair#stale-relay',
endpoint: 'ws://relay',
connectionMode: 'automatic'
})
await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,local'))
expect(screen.getByTestId('mode')).toHaveTextContent('local-only')
})
it('does not commit a custom direct address when its QR preflight fails', async () => {
const user = userEvent.setup()
await openPairingStep()
await waitFor(() => expect(getPairingQR).toHaveBeenCalledTimes(1))
@@ -331,7 +421,6 @@ describe('MobilePage pairing connection mode', () => {
connectionMode: 'automatic'
})
)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(getPairingQR).toHaveBeenCalledTimes(2)
})
})
@@ -21,6 +21,7 @@ import { useMobilePairingGeneration } from './use-mobile-pairing-generation'
import { useMobilePairingQrInvalidation } from './use-mobile-pairing-qr-invalidation'
import { useMobileInstallActions } from './use-mobile-install-actions'
import { useMobilePagePairedDevices } from './use-mobile-page-paired-devices'
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
export default function MobilePage(): React.JSX.Element {
const [stepIdx, setStepIdx] = useState<StepIndex>(0)
@@ -33,10 +34,7 @@ export default function MobilePage(): React.JSX.Element {
const [pairQrDataUrl, setPairQrDataUrl] = useState<string | null>(null)
const [pairingUrl, setPairingUrl] = useState<string | null>(null)
const [pairingQrError, setPairingQrError] = useState(false)
// Mode the displayed QR actually encodes; can be 'local-only' under an
// Anywhere selection when Relay provisioning degraded server-side.
const [encodedConnectionMode, setEncodedConnectionMode] =
useState<MobilePairingConnectionMode | null>(null)
const [relayMintFailure, setRelayMintFailure] = useState<MobileRelayMintFailure | null>(null)
const [pairLoading, setPairLoading] = useState(false)
const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected')
const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode()
@@ -77,7 +75,7 @@ export default function MobilePage(): React.JSX.Element {
setPairingUrl,
setPairingQrError,
setPairLoading,
setEncodedConnectionMode
setRelayMintFailure
})
const handleConnectionModeChange = useCallback(
@@ -88,12 +86,43 @@ export default function MobilePage(): React.JSX.Element {
// Why: persist the pick and update local state. The QR invalidation +
// rotate-regenerate is handled centrally by useMobilePairingQrInvalidation
// (below), which also covers cross-window preference syncs.
setRelayMintFailure(null)
setConnectionMode(nextMode)
void updateSettings({ mobilePairingConnectionMode: nextMode })
},
[connectionMode, updateSettings, setConnectionMode]
)
const copyRelayDiagnostics = useCallback(async (): Promise<void> => {
if (relayMintFailure == null) {
return
}
// Why: users share this payload — the selected address would leak a LAN/Tailscale IP or hostname.
const payload = {
kind: 'mobile_pairing_relay_failure',
preferredConnectionMode: connectionMode,
failure: relayMintFailure,
at: new Date().toISOString()
}
try {
await window.api.ui.writeClipboardText(JSON.stringify(payload, null, 2))
if (mountedRef.current) {
toast.success(
translate('auto.components.mobile.MobilePage.diagnosticsCopied', 'Diagnostics copied')
)
}
} catch {
if (mountedRef.current) {
toast.error(
translate(
'auto.components.mobile.MobilePage.diagnosticsCopyFailed',
'Failed to copy diagnostics'
)
)
}
}
}, [connectionMode, mountedRef, relayMintFailure])
useMobilePairingQrInvalidation({
connectionMode,
signedIn,
@@ -104,6 +133,7 @@ export default function MobilePage(): React.JSX.Element {
setPairingUrl,
setPairingQrError,
setPairLoading,
setRelayMintFailure,
regenerate: (mode, opts) => void generatePairing(opts.rotate, undefined, mode)
})
@@ -168,8 +198,6 @@ export default function MobilePage(): React.JSX.Element {
// not snap it back to a tailnet/LAN fallback.
const isManual = !networkInterfaces.some((iface) => iface.address === address)
setAddressIsManual(isManual)
// Switching network must remint so the QR encodes the new endpoint —
// but only when the selected path may honestly mint (not signed-out Anywhere).
if (canMintMobilePairingOffer({ connectionMode, signedIn })) {
void generatePairing(true, address)
}
@@ -237,7 +265,7 @@ export default function MobilePage(): React.JSX.Element {
setPairQrDataUrl(null)
setPairingUrl(null)
setPairingQrError(false)
setEncodedConnectionMode(null)
setRelayMintFailure(null)
showFirstPairingFlow()
}
@@ -248,7 +276,7 @@ export default function MobilePage(): React.JSX.Element {
setPairQrDataUrl(null)
setPairingUrl(null)
setPairingQrError(false)
setEncodedConnectionMode(null)
setRelayMintFailure(null)
showPairAnotherDeviceFlow()
}
@@ -299,11 +327,12 @@ export default function MobilePage(): React.JSX.Element {
pairQrDataUrl={pairQrDataUrl}
pairingUrl={pairingUrl}
pairingQrError={pairingQrError}
relayDegraded={
pairQrDataUrl != null &&
connectionMode === 'automatic' &&
encodedConnectionMode === 'local-only'
relayMintFailure={
connectionMode === 'automatic' && pairQrDataUrl == null ? relayMintFailure : null
}
onUseLan={() => handleConnectionModeChange('local-only')}
onRetryRelay={() => void generatePairing(true)}
onCopyRelayDiagnostics={() => void copyRelayDiagnostics()}
platform={platform}
refreshingNetworkInterfaces={refreshingNetworkInterfaces}
revokeDevice={(id) => void revokeDevice(id)}
@@ -13,6 +13,7 @@ import type { MobilePageStage } from './mobile-page-stage'
import { MobilePageToolbar } from './MobilePageToolbar'
import { PhoneCarousel } from './PhoneCarousel'
import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode'
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
type MobilePageContentProps = {
closeMobilePage: () => void
@@ -39,7 +40,10 @@ type MobilePageContentProps = {
pairQrDataUrl: string | null
pairingUrl: string | null
pairingQrError: boolean
relayDegraded: boolean
relayMintFailure: MobileRelayMintFailure | null
onUseLan: () => void
onRetryRelay: () => void
onCopyRelayDiagnostics: () => void
platform: Platform
refreshingNetworkInterfaces: boolean
revokeDevice: (id: string) => void
@@ -78,7 +82,10 @@ export function MobilePageContent({
pairQrDataUrl,
pairingUrl,
pairingQrError,
relayDegraded,
relayMintFailure,
onUseLan,
onRetryRelay,
onCopyRelayDiagnostics,
platform,
refreshingNetworkInterfaces,
revokeDevice,
@@ -92,7 +99,7 @@ export function MobilePageContent({
toggleMobileSidebarButton
}: MobilePageContentProps): React.JSX.Element {
return (
<div className="mobile-page-root">
<div className="mobile-page-root scrollbar-sleek">
<MobilePageToolbar
showMobileButton={showMobileButton}
onClose={closeMobilePage}
@@ -123,7 +130,10 @@ export function MobilePageContent({
pairQrDataUrl={pairQrDataUrl}
pairingUrl={pairingUrl}
pairingQrError={pairingQrError}
relayDegraded={relayDegraded}
relayMintFailure={relayMintFailure}
onUseLan={onUseLan}
onRetryRelay={onRetryRelay}
onCopyRelayDiagnostics={onCopyRelayDiagnostics}
pairLoading={pairLoading}
connectionMode={connectionMode}
onConnectionModeChange={handleConnectionModeChange}
@@ -0,0 +1,129 @@
import { useEffect, useState } from 'react'
import { CircleAlert, Loader2 } from 'lucide-react'
import { Button } from '../ui/button'
import { translate } from '@/i18n/i18n'
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
import { cn } from '@/lib/utils'
export function MobileRelayMintFailureNotice({
failure,
onUseLan,
onRetry,
onCopyDiagnostics,
className,
compact = false,
busy = false
}: {
failure: MobileRelayMintFailure
onUseLan: () => void
onRetry: () => void
onCopyDiagnostics: () => void
className?: string
compact?: boolean
busy?: boolean
}): React.JSX.Element {
const providerMissing = failure.stage === 'provider_missing'
const [showBusyFeedback, setShowBusyFeedback] = useState(false)
useEffect(() => {
if (!busy) {
setShowBusyFeedback(false)
return
}
const timer = window.setTimeout(() => setShowBusyFeedback(true), 200)
return () => window.clearTimeout(timer)
}, [busy])
const visibleBusy = busy && showBusyFeedback
const title = visibleBusy
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.retryingTitle',
'Retrying Orca Relay…'
)
: providerMissing
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableTitle',
'Orca Relay isn’t available on this desktop.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.title',
'Couldn’t create a Relay pairing code.'
)
const body = visibleBusy
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.retryingBody',
'Creating a new pairing code. This can take a moment over a remote connection.'
)
: providerMissing
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.unavailableBody',
'Use LAN to pair over Tailscale or the same Wi‑Fi.'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.body',
'Retry, or use LAN to pair over Tailscale or the same Wi‑Fi.'
)
return (
<div
className={cn(
'flex w-full min-w-0 items-start gap-2 rounded-lg border p-3 text-xs',
visibleBusy
? 'border-border bg-muted/40 text-foreground'
: 'border-destructive/30 bg-destructive/10 text-destructive',
className
)}
data-testid="relay-mint-failure-notice"
>
{visibleBusy ? (
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin" aria-hidden />
) : (
<CircleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1 space-y-2">
<p
className="min-w-0"
role={visibleBusy ? 'status' : 'alert'}
aria-live={visibleBusy ? 'polite' : 'assertive'}
>
<span className="font-medium">{title}</span> {body}
</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size={compact ? 'xs' : 'sm'} onClick={onUseLan}>
{translate('auto.components.mobile.MobileRelayMintFailureNotice.useLan', 'Use LAN')}
</Button>
{!providerMissing ? (
<Button
type="button"
size={compact ? 'xs' : 'sm'}
variant="outline"
onClick={onRetry}
disabled={busy}
className="w-28"
>
{visibleBusy ? <Loader2 className="animate-spin" /> : null}
{visibleBusy
? translate(
'auto.components.mobile.MobileRelayMintFailureNotice.retrying',
'Retrying…'
)
: translate(
'auto.components.mobile.MobileRelayMintFailureNotice.retry',
'Retry Relay'
)}
</Button>
) : null}
<Button
type="button"
size={compact ? 'xs' : 'sm'}
variant="ghost"
onClick={onCopyDiagnostics}
>
{translate(
'auto.components.mobile.MobileRelayMintFailureNotice.copyDiagnostics',
'Copy diagnostics'
)}
</Button>
</div>
</div>
</div>
)
}
@@ -5,13 +5,15 @@ import {
canMintMobilePairingOffer,
type MobilePairingConnectionMode
} from '../../../../shared/mobile-pairing-connection-mode'
import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure'
type MutableRef<T> = { current: T }
/**
* Mints (or rotates) a pairing QR. Every caller must go through this path so
* signed-out Anywhere is refused rather than silently degraded to a local-only
* code under the Relay label.
* code under the Relay label. Anywhere mint failures surface relayFailure and
* clear any QR.
*/
export function useMobilePairingGeneration(params: {
connectionMode: MobilePairingConnectionMode
@@ -24,9 +26,7 @@ export function useMobilePairingGeneration(params: {
setPairingUrl: (value: string | null) => void
setPairingQrError: (value: boolean) => void
setPairLoading: (value: boolean) => void
/** Mode the minted QR actually encodes (degraded Relay mints report
* 'local-only'); null while no QR is shown. */
setEncodedConnectionMode: (value: MobilePairingConnectionMode | null) => void
setRelayMintFailure: (value: MobileRelayMintFailure | null) => void
}): {
generatePairing: (
rotate: boolean,
@@ -45,7 +45,7 @@ export function useMobilePairingGeneration(params: {
setPairingUrl,
setPairingQrError,
setPairLoading,
setEncodedConnectionMode
setRelayMintFailure
} = params
const generatePairing = useCallback(
@@ -55,22 +55,16 @@ export function useMobilePairingGeneration(params: {
connectionModeOverride?: MobilePairingConnectionMode
) => {
const preferredMode = connectionModeOverride ?? connectionMode
// Why: every mint path (auto-generate, regenerate, address change, path
// invalidation) must refuse signed-out Anywhere rather than degrade to a
// local-only QR under the Relay label.
if (!canMintMobilePairingOffer({ connectionMode: preferredMode, signedIn })) {
return
}
const requestId = ++pairingRequestIdRef.current
// Mark the request synchronously so state changes cannot make the
// Step 2 auto-generate effect start a second offer in parallel.
hasGeneratedRef.current = true
if (mountedRef.current) {
setPairLoading(true)
}
try {
const address = addressOverride ?? selectedAddress
// canMint already requires sign-in for Anywhere, so preferred is honest.
const result = await window.api.mobile.getPairingQR({
...(address ? { address } : {}),
connectionMode: preferredMode,
@@ -84,21 +78,28 @@ export function useMobilePairingGeneration(params: {
setPairQrDataUrl(result.qrDataUrl)
setPairingUrl(result.pairingUrl)
setPairingQrError(result.qrDataUrl === null)
setEncodedConnectionMode(result.connectionMode)
setRelayMintFailure(null)
}
} else {
hasGeneratedRef.current = false
// Why: keep hasGenerated so step-2 auto-mint does not loop on failure.
if (mountedRef.current) {
setPairQrDataUrl(null)
setPairingUrl(null)
setPairingQrError(false)
setEncodedConnectionMode(null)
toast.error(
translate(
'auto.components.mobile.MobilePage.b353e18de1',
'WebSocket transport is not running'
if (result.reason === 'relay_mint_failed' && result.relayFailure) {
setRelayMintFailure(result.relayFailure)
} else {
setRelayMintFailure(null)
// Why: IPC now forwards reason/guidance for all unavailability paths;
// prefer that copy over a hard-coded WebSocket-only message.
toast.error(
result.guidance ??
translate(
'auto.components.mobile.MobilePage.b353e18de1',
'WebSocket transport is not running'
)
)
)
}
}
}
} catch {
@@ -107,7 +108,7 @@ export function useMobilePairingGeneration(params: {
setPairQrDataUrl(null)
setPairingUrl(null)
setPairingQrError(false)
setEncodedConnectionMode(null)
setRelayMintFailure(null)
toast.error(
translate(
'auto.components.mobile.MobilePage.4c8bd11c1a',
@@ -127,11 +128,11 @@ export function useMobilePairingGeneration(params: {
mountedRef,
pairingRequestIdRef,
selectedAddress,
setEncodedConnectionMode,
setPairLoading,
setPairQrDataUrl,
setPairingUrl,
setPairingQrError,
setRelayMintFailure,
signedIn
]
)
@@ -23,6 +23,7 @@ export function useMobilePairingQrInvalidation(params: {
setPairingUrl: (value: string | null) => void
setPairingQrError: (value: boolean) => void
setPairLoading: (value: boolean) => void
setRelayMintFailure?: (value: null) => void
regenerate: (mode: MobilePairingConnectionMode, opts: { rotate: boolean }) => void
}): void {
const {
@@ -35,6 +36,7 @@ export function useMobilePairingQrInvalidation(params: {
setPairingUrl,
setPairingQrError,
setPairLoading,
setRelayMintFailure,
regenerate
} = params
const wasSignedInRef = useRef(signedIn)
@@ -57,6 +59,7 @@ export function useMobilePairingQrInvalidation(params: {
setPairingUrl(null)
setPairingQrError(false)
setPairQrDataUrl(null)
setRelayMintFailure?.(null)
if (signedIn && canMintMobilePairingOffer({ connectionMode, signedIn })) {
// Why: rotate on the sign-in edge — the token behind the QR cleared at
// sign-out may have been exposed, so the fresh session mints fresh.
@@ -73,6 +76,7 @@ export function useMobilePairingQrInvalidation(params: {
setPairingUrl,
setPairingQrError,
setPairLoading,
setRelayMintFailure,
regenerate
])
@@ -93,6 +97,7 @@ export function useMobilePairingQrInvalidation(params: {
setPairingUrl(null)
setPairingQrError(false)
setPairQrDataUrl(null)
setRelayMintFailure?.(null)
if (shouldRegenerate && canMintMobilePairingOffer({ connectionMode, signedIn })) {
// Why: no rotate here — the main process rotates exactly once when the
// requested mode differs from the pending token's minted mode, so the
@@ -113,6 +118,7 @@ export function useMobilePairingQrInvalidation(params: {
setPairingUrl,
setPairingQrError,
setPairLoading,
setRelayMintFailure,
regenerate
])
}
@@ -8,6 +8,7 @@ import {
submitNativeChatPrompt
} from './native-chat-runtime-send'
import type { NativeChatSendHandle } from './native-chat-runtime-send'
import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send'
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
import {
@@ -250,21 +251,29 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
return
}
const classification = classifySend(text)
// A parked launch draft must be cleared line-by-line before the body.
const { sendOptions } = resolveNativeChatLaunchDraftSend({
launchDraft,
launchDraftResolved,
agent,
readScreen: () => readTerminalScreen?.()
})
let pendingHandle: NativeChatSendHandle | null = null
// Why: image attachments take the attachment send path even for a
// command/unknown send, otherwise `clearImageAttachments()` below drops
// them silently when the text starts with the agent's slash/skill prefix.
if (classification !== 'chat' && imagePaths.length === 0) {
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text)
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions)
} else if (imagePaths.length > 0) {
pendingHandle = sendNativeChatMessageWithImageAttachments(
target.settings,
target.ptyId,
text,
imagePaths
imagePaths,
sendOptions
)
} else if (text.trim().length > 0) {
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text)
pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions)
} else {
submitNativeChatPrompt(target.settings, target.ptyId)
}
@@ -296,8 +305,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
clearSkillOrigin()
clearImageAttachments()
setNotice(null)
// Why: the send path pre-clears the TUI input line, so any launch-draft
// prefill still parked there is gone — retire the composer seed with it.
// The send cleared the TUI input line before its body, so retire the seed.
useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId)
}, [
agent,
@@ -308,6 +316,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
imageAttachments,
disabled,
isDispatchingSessionOption,
launchDraft,
launchDraftResolved,
readTerminalScreen,
resolveTarget,
onOptimisticSend,
onSlashCommand,
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import {
agentInputLineCleared,
planNativeChatLaunchDraftSend,
resolveNativeChatLaunchDraftSend
} from './native-chat-launch-draft-send'
import {
AGENT_TUI_CLEAR_INPUT_LINE,
buildAgentTuiClearInputForText
} from '../../../../shared/agent-tui-input-clear'
const SEEDED = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
/** Claude's frame: the input line and its continuation rows sit last. */
const screenHoldingDraft = [
' ▘▘ ▝▝ ~/repo',
'────────────────────────────────────────',
'❯ Linked Linear issue: ABC-123',
' https://linear.app/x/issue/ABC-123',
'────────────────────────────────────────'
].join('\n')
const screenPlaceholder = [
' ▘▘ ▝▝ ~/repo',
'────────────────────────────────────────',
'❯ Try "create a util logging.py that..."',
'────────────────────────────────────────'
].join('\n')
const plan = (over: Partial<Parameters<typeof planNativeChatLaunchDraftSend>[0]> = {}) =>
planNativeChatLaunchDraftSend({
seededText: SEEDED,
...over
})
describe('planNativeChatLaunchDraftSend', () => {
it('replaces the parked draft even when the composer copy is unchanged', () => {
expect(plan()).toEqual({
kind: 'replace-draft',
clearInput: buildAgentTuiClearInputForText(SEEDED),
seededText: SEEDED
})
})
it('does not submit a terminal-side edit that preserves the old short prefix', () => {
const samePrefixEdit = [
'────────────────────────────────────────',
'❯ Linked Linear but terminal-side text changed',
'────────────────────────────────────────'
].join('\n')
const result = resolveNativeChatLaunchDraftSend({
launchDraft: { agent: 'codex', text: SEEDED },
launchDraftResolved: false,
agent: 'codex',
readScreen: () => samePrefixEdit
})
expect(result.plan.kind).toBe('replace-draft')
})
it('keeps the ordinary send path when nothing is parked on the line', () => {
expect(plan({ seededText: null })).toEqual({ kind: 'default' })
expect(plan({ seededText: ' ' })).toEqual({ kind: 'default' })
})
it('sizes a multi-line clear well past a single Ctrl+U', () => {
const result = plan()
expect(result.kind === 'replace-draft' && result.clearInput.length).toBeGreaterThan(
AGENT_TUI_CLEAR_INPUT_LINE.length
)
})
})
describe('agentInputLineCleared', () => {
it('confirms only an observably empty prompt', () => {
expect(agentInputLineCleared('› \n gpt-5.6 · ~/repo')).toBe(true)
})
it('does not call a different nonempty prompt cleared', () => {
const edited = [
'────────────────────────────────────────',
'❯ issue: ABC-123 residue after a cursor-middle clear',
'────────────────────────────────────────'
].join('\n')
expect(agentInputLineCleared(edited)).toBe(false)
})
it('does not ignore nonempty continuation rows after an empty prompt row', () => {
const residue = [
'────────────────────────────────────────',
'❯ ',
' suffix after a cursor-middle clear',
'────────────────────────────────────────'
].join('\n')
expect(agentInputLineCleared(residue)).toBe(false)
})
it('treats placeholders and parked drafts as unconfirmed', () => {
expect(agentInputLineCleared(screenPlaceholder)).toBe(false)
expect(agentInputLineCleared(screenHoldingDraft)).toBe(false)
})
it('treats an unreadable screen as unconfirmed', () => {
expect(agentInputLineCleared(null)).toBe(false)
expect(agentInputLineCleared('unparseable')).toBe(false)
})
it('reads an empty prompt through serializer ANSI', () => {
expect(agentInputLineCleared(`❯ `)).toBe(true)
})
})
@@ -0,0 +1,99 @@
// Choosing how a chat send lands when the agent's TUI input line still holds a
// launch-context draft that Orca itself injected.
import { buildAgentTuiClearInputForText } from '../../../../shared/agent-tui-input-clear'
import { stripScrollbackAnsi } from './native-chat-scrape-fallback'
export type NativeChatLaunchDraftSendPlan =
/** Input line holds a stale injected draft — replace it, clearing every line. */
| { kind: 'replace-draft'; clearInput: string; seededText: string }
/** No injected draft is parked on the line; keep the ordinary send path. */
| { kind: 'default' }
/** Prompt glyphs both supported agent TUIs draw at the start of the input line. */
const COMPOSER_PROMPT_LINE = /^\s*([❯›])\s?(.*)$/
const CLAUDE_FRAME_LINE = /^\s*─{3,}\s*$/
const CODEX_FOOTER_LINE = /^\s*\S.*\s[·•]\s.*$/
function composerContinuationIsEmpty(lines: string[], promptIndex: number, glyph: string): boolean {
for (let index = promptIndex + 1; index < lines.length; index += 1) {
const line = lines[index]!
if (
(glyph === '❯' && CLAUDE_FRAME_LINE.test(line)) ||
(glyph === '›' && CODEX_FOOTER_LINE.test(line))
) {
return true
}
if (line.trim() !== '') {
return false
}
}
return true
}
/**
* Whether the rendered composer prompt is observably empty. Placeholder text,
* unrelated edits, and unreadable screens are all unconfirmed.
*/
export function agentInputLineCleared(screen: string | null | undefined): boolean {
if (!screen) {
return false
}
const lines = stripScrollbackAnsi(screen).split('\n')
for (let index = lines.length - 1; index >= 0; index -= 1) {
const match = COMPOSER_PROMPT_LINE.exec(lines[index]!)
if (match) {
return match[2]!.trim() === '' && composerContinuationIsEmpty(lines, index, match[1]!)
}
}
return false
}
/**
* A serialized screen cannot prove the whole parked draft still matches: visual
* wrapping loses logical-line boundaries. Always replace from the composer copy.
*/
export function planNativeChatLaunchDraftSend(args: {
/** Text Orca injected into the TUI line, or null when nothing is parked there. */
seededText: string | null | undefined
}): NativeChatLaunchDraftSendPlan {
const seededText = args.seededText
if (!seededText || seededText.trim() === '') {
return { kind: 'default' }
}
return {
kind: 'replace-draft',
clearInput: buildAgentTuiClearInputForText(seededText),
seededText
}
}
/** What a composer send needs: which path to take, and the clear/confirm bytes
* the ordinary send paths should use when it is a draft replacement. */
export function resolveNativeChatLaunchDraftSend(args: {
launchDraft: { agent: string; text: string } | null | undefined
launchDraftResolved: boolean
agent: string
readScreen: () => string | null | undefined
}): {
plan: NativeChatLaunchDraftSendPlan
sendOptions: { clearInput: string; confirmCleared: () => boolean } | undefined
} {
const { launchDraft, launchDraftResolved, agent, readScreen } = args
// A resolved draft was already submitted or cleared TUI-side, so nothing of
// ours is on the line any more — treating it as parked would clear or submit
// a buffer that no longer holds it.
const seededText =
launchDraft && launchDraft.agent === agent && !launchDraftResolved ? launchDraft.text : null
const plan = planNativeChatLaunchDraftSend({ seededText })
if (plan.kind !== 'replace-draft') {
return { plan, sendOptions: undefined }
}
return {
plan,
sendOptions: {
clearInput: plan.clearInput,
confirmCleared: () => agentInputLineCleared(readScreen())
}
}
}
@@ -9,6 +9,7 @@
export type NativeChatPtySendQueueHandle = {
cancel: () => void
settleAfterMs: number
settled: Promise<void>
bodyStarted: () => boolean
finished: () => boolean
}
@@ -97,6 +98,16 @@ export function enqueueNativeChatPtySend(
const timers: ReturnType<typeof setTimeout>[] = []
let release: (() => void) | null = null
const finishEntry = (): void => {
if (finished) {
return
}
finished = true
const resolve = release
release = null
resolve?.()
}
const delay = (ms: number, fn: () => void): void => {
const timer = setTimeout(() => {
if (!cancelled) {
@@ -106,19 +117,17 @@ export function enqueueNativeChatPtySend(
timers.push(timer)
}
const markFinished = (): void => {
finished = true
}
const markSubmitted = (): void => {
submitted = true
finishEntry()
}
const execute = (): Promise<void> =>
new Promise<void>((resolve) => {
release = resolve
if (cancelled) {
markFinished()
release = null
finished = true
resolve()
return
}
@@ -126,17 +135,7 @@ export function enqueueNativeChatPtySend(
start({ isCancelled: () => cancelled, delay, markSubmitted })
if (durationMs <= 0) {
markSubmitted()
markFinished()
resolve()
return
}
// Why: always release after the declared duration so a cancel mid-flight
// cannot stall the per-pty queue forever.
const done = setTimeout(() => {
markFinished()
resolve()
}, durationMs)
timers.push(done)
})
const runPromise =
@@ -148,7 +147,7 @@ export function enqueueNativeChatPtySend(
const settleQueueEntry = (): void => {
state.depth = Math.max(0, state.depth - 1)
markFinished()
finished = true
dropHandle()
// Why: drop the per-pty record once nothing is in flight so the map does not
// accumulate one permanent entry per pty over a long, multi-pane session.
@@ -157,7 +156,8 @@ export function enqueueNativeChatPtySend(
}
}
state.tail = runPromise.then(settleQueueEntry, settleQueueEntry)
const settled = runPromise.then(settleQueueEntry, settleQueueEntry)
state.tail = settled
const handle: NativeChatPtySendQueueHandle = {
cancel: () => {
@@ -169,20 +169,19 @@ export function enqueueNativeChatPtySend(
clearTimeout(timer)
}
const shouldClear = bodyStarted && !submitted
markFinished()
// Why: refund only THIS sequence's charged window rather than collapsing
// freeAt to now — later queued sends still hold the line, so a blanket
// reset would understate the next enqueue's settle time and let a send
// card drop while a queued Enter is still pending.
state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs))
release?.()
release = null
finishEntry()
dropHandle()
if (shouldClear) {
options?.onCancelUnsubmitted?.()
}
},
settleAfterMs,
settled,
bodyStarted: () => bodyStarted,
finished: () => finished
}
@@ -0,0 +1,195 @@
// Send-path behaviour when a launch-context draft is still parked on the agent's
// TUI input line: the multi-line clear and its confirmation step.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const sendRuntimePtyInput = vi.fn()
const sendRuntimePtyInputVerified = vi.fn()
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args),
sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args)
}))
import {
NATIVE_CHAT_CLEAR_CONFIRM_MS,
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS,
NATIVE_CHAT_SUBMIT_DELAY_MS,
resetNativeChatPtySendQueuesForTests,
sendNativeChatMessage,
sendNativeChatMessageWithImageAttachments
} from './native-chat-runtime-send'
import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send'
import {
AGENT_TUI_CLEAR_INPUT_MAX,
buildAgentTuiClearInputForText
} from '../../../../shared/agent-tui-input-clear'
const SETTINGS = {} as Parameters<typeof sendNativeChatMessage>[0]
const PTY = 'pty-launch-draft'
const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
const writes = (): string[] => sendRuntimePtyInput.mock.calls.map((call) => call[2] as string)
beforeEach(() => {
vi.useFakeTimers()
sendRuntimePtyInput.mockClear()
sendRuntimePtyInput.mockReturnValue(true)
resetNativeChatPtySendQueuesForTests()
})
afterEach(() => {
vi.useRealTimers()
resetNativeChatPtySendQueuesForTests()
})
describe('sendNativeChatMessage with a parked multi-line draft', () => {
it('leads with a clear sized to every line of the draft, not one Ctrl+U', () => {
const clearInput = buildAgentTuiClearInputForText(DRAFT)
sendNativeChatMessage(SETTINGS, PTY, 'edited text', { clearInput })
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited text')])
expect(clearInput).not.toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
})
it('still defaults to a single Ctrl+U when no draft is parked', () => {
sendNativeChatMessage(SETTINGS, PTY, 'plain')
expect(writes()[0]).toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
})
it('holds the body until the clear is confirmed, then submits after the gap', () => {
const clearInput = buildAgentTuiClearInputForText(DRAFT)
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
clearInput,
confirmCleared: () => true
})
// Body must NOT ride out with the clear — the confirm happens in between.
expect(writes()).toEqual([clearInput])
vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS)
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited')])
vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited'), NATIVE_CHAT_SUBMIT])
})
it('preserves the body-to-Enter gap when the renderer stalls past both nominal deadlines', async () => {
vi.useRealTimers()
const writeTimes = new Map<string, number>()
sendRuntimePtyInput.mockImplementation((_settings, _pty, bytes: string) => {
writeTimes.set(bytes, performance.now())
return true
})
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
clearInput: buildAgentTuiClearInputForText(DRAFT),
confirmCleared: () => true
})
sendNativeChatMessage(SETTINGS, PTY, 'queued')
const blockedUntil =
performance.now() + NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + 50
while (performance.now() < blockedUntil) {
// Simulate a renderer long task delaying both nominal deadlines.
}
await vi.waitFor(() => expect(writeTimes.has(NATIVE_CHAT_SUBMIT)).toBe(true), {
timeout: NATIVE_CHAT_SUBMIT_DELAY_MS + 1_000
})
await vi.waitFor(() => expect(writeTimes.has(buildNativeChatPasteBytes('queued'))).toBe(true))
expect(
writeTimes.get(NATIVE_CHAT_SUBMIT)! - writeTimes.get(buildNativeChatPasteBytes('edited'))!
).toBeGreaterThanOrEqual(NATIVE_CHAT_SUBMIT_DELAY_MS - 20)
expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan(
writes().indexOf(buildNativeChatPasteBytes('queued'))
)
})
it('widens to a maximal burst when the draft is still observed on the line', () => {
const clearInput = buildAgentTuiClearInputForText(DRAFT)
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
clearInput,
confirmCleared: () => false
})
vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS)
expect(writes()).toEqual([
clearInput,
AGENT_TUI_CLEAR_INPUT_MAX,
buildNativeChatPasteBytes('edited')
])
})
it('re-clears before the body, never after it', () => {
sendNativeChatMessage(SETTINGS, PTY, 'edited', {
clearInput: buildAgentTuiClearInputForText(DRAFT),
confirmCleared: () => false
})
vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS)
const order = writes()
expect(order.indexOf(AGENT_TUI_CLEAR_INPUT_MAX)).toBeLessThan(
order.indexOf(buildNativeChatPasteBytes('edited'))
)
})
it('charges the confirm gap to the handle so the send card outlives the Enter', () => {
const withConfirm = sendNativeChatMessage(SETTINGS, PTY, 'a', {
clearInput: '\x15',
confirmCleared: () => true
})
expect(withConfirm.settleAfterMs).toBe(
NATIVE_CHAT_SUBMIT_DELAY_MS + NATIVE_CHAT_CLEAR_CONFIRM_MS
)
})
it('submits before a queued send starts after clear confirmation', async () => {
const clearInput = buildAgentTuiClearInputForText(DRAFT)
sendNativeChatMessage(SETTINGS, PTY, 'first', {
clearInput,
confirmCleared: () => true
})
sendNativeChatMessage(SETTINGS, PTY, 'second')
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(writes()).toEqual([
clearInput,
buildNativeChatPasteBytes('first'),
NATIVE_CHAT_SUBMIT,
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('second')
])
})
})
describe('image sends with a parked multi-line draft', () => {
it('clears every draft line before pasting, so no line rides along with the image', () => {
const clearInput = buildAgentTuiClearInputForText(DRAFT)
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
clearInput
})
expect(writes()[0]).toBe(clearInput)
})
it('clears exactly once — a second Ctrl+U would wipe the just-pasted image', () => {
const clearInput = buildAgentTuiClearInputForText(DRAFT)
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
clearInput
})
vi.advanceTimersByTime(10_000)
expect(writes().filter((write) => write === clearInput)).toHaveLength(1)
})
it('submits the image send before a queued message starts', async () => {
const clearInput = buildAgentTuiClearInputForText(DRAFT)
sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'caption', ['/tmp/a.png'], {
clearInput,
confirmCleared: () => true
})
sendNativeChatMessage(SETTINGS, PTY, 'second')
await vi.advanceTimersByTimeAsync(
NATIVE_CHAT_CLEAR_CONFIRM_MS +
NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS +
NATIVE_CHAT_SUBMIT_DELAY_MS
)
expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan(
writes().indexOf(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
)
})
})
@@ -142,6 +142,23 @@ describe('sendNativeChatMessage', () => {
expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6)
})
it('does not let a canceled queued send stall the sends behind it', async () => {
sendNativeChatMessage(SETTINGS, PTY, 'first')
const canceled = sendNativeChatMessage(SETTINGS, PTY, 'canceled')
sendNativeChatMessage(SETTINGS, PTY, 'third')
canceled.cancel()
await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS)
expectWriteOrder(sendRuntimePtyInput.mock.calls, [
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('first'),
NATIVE_CHAT_SUBMIT,
NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
buildNativeChatPasteBytes('third')
])
})
it('does not serialize sends across different PTYs', () => {
sendNativeChatMessage(SETTINGS, 'pty-a', 'one')
sendNativeChatMessage(SETTINGS, 'pty-b', 'two')
@@ -8,6 +8,7 @@ import {
} from '@/runtime/runtime-terminal-inspection'
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt'
import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear'
import {
NATIVE_CHAT_ADVANCE_BUFFER_MS,
NATIVE_CHAT_QUESTION_STEP_MS,
@@ -34,20 +35,86 @@ export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300
// start from an empty line so a prior cancelled paste cannot glue onto the next
// prompt. Not used on verified option commands — model-switch confirmation
// observes the PTY and Ctrl+U can miss confirmation markers.
//
// One Ctrl+U only ever clears ONE logical line. When the line may hold an
// injected multi-line launch draft, callers pass `clearInput` built by
// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured
// 2N-1 law and the sequences that do NOT work.
export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
/** Gap before re-reading the agent's input line to confirm a clear landed. */
export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140
export type NativeChatSendOptions = {
/** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */
clearInput?: string
/**
* Observed check that the input line is now empty.
* Supplied only for launch-draft replacement; when it reports "not cleared"
* the send widens to a maximal burst before writing the body rather than
* pasting on top of residue.
*/
confirmCleared?: () => boolean
}
/** Cancels an in-flight send's pending pty writes (the delayed Enter, and any
* later question bodies/Enters). Safe to call after the send completes. */
export type NativeChatSendHandle = {
cancel: () => void
/** Time after which every scheduled write has fired and the handle can drop. */
settleAfterMs: number
/** Actual completion, which can outlive the nominal schedule if the renderer stalls. */
settled?: Promise<void>
}
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): void {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
function clearUnsubmittedAgentInput(
settings: RuntimeSettings,
ptyId: string,
options?: NativeChatSendOptions
): void {
sendRuntimePtyInput(settings, ptyId, options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
}
/**
* Run `writeBody` once the input line is clear. With no `confirmCleared` the
* clear is a plain in-order write on the same byte stream, so the TUI consumes
* it before the body and the body follows immediately. With one, we pause to
* actually look at the agent's input line, and widen to a maximal burst when the
* draft is still visible — the injected line count is only a lower bound on what
* the buffer holds, since the user can type into the TUI directly.
*/
function clearThenWrite(
settings: RuntimeSettings,
ptyId: string,
options: NativeChatSendOptions | undefined,
delay: (ms: number, fn: () => void) => void,
writeBody: () => void
): void {
clearUnsubmittedAgentInput(settings, ptyId, options)
const confirmCleared = options?.confirmCleared
if (!confirmCleared) {
writeBody()
return
}
delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => {
let cleared = false
try {
cleared = confirmCleared()
} catch {
// An unreadable terminal is unconfirmed; the maximal clear remains safe.
}
if (!cleared) {
sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX)
}
writeBody()
})
}
/** Extra time a send needs when it stops to confirm the clear before the body. */
function clearConfirmDurationMs(options?: NativeChatSendOptions): number {
return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0
}
/**
@@ -61,27 +128,31 @@ function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): v
export function sendNativeChatMessage(
settings: RuntimeSettings,
ptyId: string,
text: string
text: string,
options?: NativeChatSendOptions
): NativeChatSendHandle {
return enqueueNativeChatPtySend(
ptyId,
NATIVE_CHAT_SUBMIT_DELAY_MS,
NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options),
({ isCancelled, delay, markSubmitted }) => {
if (isCancelled()) {
return
}
clearUnsubmittedAgentInput(settings, ptyId)
if (isCancelled()) {
return
}
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
clearThenWrite(settings, ptyId, options, delay, () => {
if (isCancelled()) {
return
}
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
// Schedule from the actual body write: an overdue clear-confirm callback
// must not collapse the required body-to-Enter gap after a renderer stall.
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
})
},
{
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId)
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options)
}
)
}
@@ -146,16 +217,17 @@ export function sendNativeChatMessageWithImageAttachments(
settings: RuntimeSettings,
ptyId: string,
text: string,
imagePaths: readonly string[]
imagePaths: readonly string[],
options?: NativeChatSendOptions
): NativeChatSendHandle {
if (imagePaths.length === 0) {
return sendNativeChatMessage(settings, ptyId, text)
return sendNativeChatMessage(settings, ptyId, text, options)
}
const trimmedText = text.trim()
const durationMs =
trimmedText.length > 0
(trimmedText.length > 0
? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS
: NATIVE_CHAT_SUBMIT_DELAY_MS
: NATIVE_CHAT_SUBMIT_DELAY_MS) + clearConfirmDurationMs(options)
return enqueueNativeChatPtySend(
ptyId,
durationMs,
@@ -163,30 +235,31 @@ export function sendNativeChatMessageWithImageAttachments(
if (isCancelled()) {
return
}
clearUnsubmittedAgentInput(settings, ptyId)
if (isCancelled()) {
return
}
for (const imagePath of imagePaths) {
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
}
if (trimmedText.length > 0) {
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
})
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
clearThenWrite(settings, ptyId, options, delay, () => {
if (isCancelled()) {
return
}
for (const imagePath of imagePaths) {
sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath))
}
if (trimmedText.length > 0) {
delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => {
sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text))
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
})
return
}
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
return
}
delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => {
sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT)
markSubmitted()
})
},
{
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId)
onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options)
}
)
}

Some files were not shown because too many files have changed in this diff Show More