diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 731521ea8d6..f6f2c0bd13c 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -20,6 +20,7 @@ on: - 'config/scripts/computer-use-smoke.mjs' - 'config/scripts/computer-use-smoke.test.mjs' - 'config/scripts/daemon-boot-smoke.mjs' + - 'config/scripts/daemon-endpoint-handover-smoke.mjs' - 'config/scripts/windows-daemon-workspace-close-repro.mjs' - 'config/scripts/verify-computer-native.mjs' # Why: the native-smoke job boots the built terminal daemon under plain @@ -135,6 +136,11 @@ jobs: # the PR when the built daemon cannot start on ubuntu-22.04 / windows. - name: Daemon boot smoke run: node config/scripts/daemon-boot-smoke.mjs + # Why: a daemon that lost its endpoint name used to delete the replacement's + # socket on exit, stranding a live daemon that hosts PTYs nothing can reach. + # Only two real processes racing the same endpoint reproduce it. + - name: Daemon endpoint handover smoke + run: node config/scripts/daemon-endpoint-handover-smoke.mjs # Why: workspace removal overlaps graceful renderer teardown with the # forced main-process sweep; exercise that exact pair against real ConPTY. - name: Windows daemon workspace-close repro diff --git a/.oxlintrc.json b/.oxlintrc.json index 4cdb4f2c0e2..0f679f17d52 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -150,10 +150,5 @@ } } ], - "ignorePatterns": [ - "**/node_modules", - "**/dist", - "**/out", - "tests/e2e/.cross-version-checkouts" - ] + "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"] } diff --git a/config/scripts/computer-use-modifier-safety.test.mjs b/config/scripts/computer-use-modifier-safety.test.mjs index f39015796a9..32fdd94e938 100644 --- a/config/scripts/computer-use-modifier-safety.test.mjs +++ b/config/scripts/computer-use-modifier-safety.test.mjs @@ -28,7 +28,10 @@ describe('computer-use modifier safety', () => { ) expect(mouseInput).toContain('event.flags = flags') - expect(clickInput.match(/flags: flags/gu)).toHaveLength(3) + // Every click event flows through the shared delivery plan and carries + // the modifier flags on the mouse event itself. + expect(clickInput).toContain('SyntheticMouseClickDelivery.steps(clickCount: count)') + expect(clickInput).toContain('event.flags = flags') expect(clickInput).not.toContain('down: true') }) diff --git a/config/scripts/daemon-boot-smoke.mjs b/config/scripts/daemon-boot-smoke.mjs index 4ed155e8c5f..22c78a21cc2 100644 --- a/config/scripts/daemon-boot-smoke.mjs +++ b/config/scripts/daemon-boot-smoke.mjs @@ -18,7 +18,7 @@ import { fork } from 'node:child_process' import { connect } from 'node:net' import { randomUUID } from 'node:crypto' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -54,30 +54,27 @@ function makeSocketPath(userDataDir) { return join(userDataDir, 'daemon.sock') } -// Best-effort end-to-end PTY check over the daemon's own control-socket RPC. -// Connects a single control socket, completes the hello handshake, and calls -// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves -// true on success, false on any failure — never throws. -function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { - return new Promise((resolveCheck) => { +function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) { + return new Promise((resolveRpc, rejectRpc) => { let settled = false let buffer = '' const socket = connect(socketPath) - const finish = (ok, reason) => { + const finish = (error, response) => { if (settled) { return } settled = true clearTimeout(timer) socket.destroy() - if (!ok && reason) { - log(`PTY spawn health check skipped (best-effort): ${reason}`) + if (error) { + rejectRpc(error) + } else { + resolveRpc(response) } - resolveCheck(ok) } - const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS) + const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs) - socket.on('error', (err) => finish(false, err.message)) + socket.on('error', (error) => finish(error)) socket.on('connect', () => { const token = readFileSync(tokenPath, 'utf8').trim() socket.write( @@ -100,19 +97,19 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { try { msg = JSON.parse(line) } catch { - finish(false, 'invalid response line') + finish(new Error('invalid response line')) return } if (msg.type === 'hello') { if (!msg.ok) { - finish(false, `hello rejected: ${msg.error ?? 'unknown'}`) + finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`)) return } - socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`) - } else if (msg.id === 'health-1') { + socket.write(`${JSON.stringify(request)}\n`) + } else if (msg.id === request.id) { finish( - msg.ok === true, - msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed') + msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`), + msg ) return } @@ -122,28 +119,61 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { }) } +// Best-effort: constrained CI runners can make node-pty spawn flaky. +async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) { + try { + await runDaemonRpc( + socketPath, + tokenPath, + protocolVersion, + { id: 'health-1', type: 'ptySpawnHealth' }, + PTY_HEALTH_TIMEOUT_MS + ) + return true + } catch (error) { + log(`PTY spawn health check skipped (best-effort): ${error.message}`) + return false + } +} + async function main() { const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-')) const socketPath = makeSocketPath(userDataDir) const tokenPath = join(userDataDir, 'daemon.token') + const pidPath = join(userDataDir, 'daemon.pid') + const launchNonce = randomUUID() const protocolVersion = readProtocolVersion() log(`forking ${entryPath} under plain Node (${process.execPath})`) - const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], { - // Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in - // CI, and this is exactly the runtime where a leaked `require("electron")` - // throws MODULE_NOT_FOUND — the failure this smoke exists to catch. - stdio: ['ignore', 'pipe', 'pipe', 'ipc'], - env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir } - }) + const child = fork( + entryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + launchNonce, + '--entry-path', + entryPath, + '--app-version', + 'daemon-boot-smoke' + ], + { + // Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in + // CI, and this is exactly the runtime where a leaked `require("electron")` + // throws MODULE_NOT_FOUND — the failure this smoke exists to catch. + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir } + } + ) let stderr = '' child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf8') }) - child.stdout?.on('data', (chunk) => { - process.stdout.write(chunk) - }) const cleanup = () => { if (child.exitCode === null && child.signalCode === null && child.pid) { @@ -185,6 +215,25 @@ async function main() { }) }) log('daemon signaled ready') + const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8')) + if ( + pidRecord.pid !== child.pid || + pidRecord.launchNonce !== launchNonce || + pidRecord.entryPath !== entryPath || + pidRecord.appVersion !== 'daemon-boot-smoke' + ) { + throw new Error('daemon readiness did not publish the expected PID ownership record') + } + log('PID ownership record matches the ready daemon') + if (!existsSync(socketPath)) { + throw new Error('daemon did not publish its endpoint at the canonical socket path') + } + log('endpoint published at the canonical socket path') + // Production releases startup-only handles after ready; they can pin the child on Windows. + // Diagnostics past this point therefore carry the tail captured up to readiness only. + child.stderr?.destroy() + stderr += '[boot-smoke] stderr released at readiness, mirroring production\n' + child.disconnect() const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) if (ptyHealthy) { @@ -193,18 +242,39 @@ async function main() { await new Promise((resolveExit, rejectExit) => { const timer = setTimeout(() => { - rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)) + rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`)) }, SHUTDOWN_TIMEOUT_MS) child.on('exit', (code, signal) => { clearTimeout(timer) - log(`daemon exited after signal (code=${code}, signal=${signal})`) + log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`) resolveExit() }) - // Why: SIGTERM is the graceful stop on POSIX (the daemon handles it); - // Windows has no POSIX signal delivery, so Node maps this to process - // termination. Either way the hard assertion is "it stops, no hang". - child.kill('SIGTERM') + void runDaemonRpc( + socketPath, + tokenPath, + protocolVersion, + { + id: 'shutdown-1', + type: 'shutdown', + payload: { killSessions: false } + }, + SHUTDOWN_TIMEOUT_MS + ).catch((error) => { + clearTimeout(timer) + rejectExit(error) + }) }) + if (existsSync(pidPath)) { + throw new Error('daemon left its PID ownership record behind after shutdown') + } + if (process.platform !== 'win32' && existsSync(socketPath)) { + throw new Error('daemon left its endpoint socket behind after shutdown') + } + // The private bind name is consumed by the publish; nothing may linger in the runtime dir. + const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b')) + if (leaked.length > 0) { + throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`) + } log('PASS: daemon booted, served, and shut down under plain Node') } finally { diff --git a/config/scripts/daemon-endpoint-handover-smoke.mjs b/config/scripts/daemon-endpoint-handover-smoke.mjs new file mode 100644 index 00000000000..b6c39a5b55f --- /dev/null +++ b/config/scripts/daemon-endpoint-handover-smoke.mjs @@ -0,0 +1,164 @@ +/** + * Endpoint handover smoke — guards the split-brain failure with real daemon processes. + * + * The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still + * alive used to delete the *replacement's* socket when it finally exited, because libuv + * unlinks the pathname a server bound to with no ownership check. The replacement stayed + * alive hosting PTYs that nothing could reach — terminals that acknowledge input and never + * run it, and that a user cannot fix by restarting the app. + * + * Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur. + * + * Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs + */ +import { fork } from 'node:child_process' +import { connect } from 'node:net' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const repoRoot = resolve(import.meta.dirname, '..', '..') +const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js') +const READY_TIMEOUT_MS = 20_000 +const EXIT_TIMEOUT_MS = 15_000 + +const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`) + +function bootDaemon(tag, dir, socketPath) { + const tokenPath = join(dir, `${tag}.token`) + const pidPath = join(dir, `${tag}.pid`) + const child = fork( + entryPath, + [ + '--socket', + socketPath, + '--token', + tokenPath, + '--pid-record', + pidPath, + '--launch-nonce', + randomUUID(), + '--entry-path', + entryPath, + '--app-version', + 'endpoint-handover-smoke' + ], + { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { ...process.env, ORCA_USER_DATA_PATH: dir } + } + ) + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString('utf8') + }) + return new Promise((resolveReady, rejectReady) => { + const timer = setTimeout( + () => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)), + READY_TIMEOUT_MS + ) + child.on('message', (msg) => { + if (msg && typeof msg === 'object' && msg.type === 'ready') { + clearTimeout(timer) + resolveReady({ child, tokenPath, pidPath }) + } + }) + child.on('exit', (code) => { + clearTimeout(timer) + rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`)) + }) + }) +} + +function isReachable(socketPath) { + return new Promise((resolveReachable) => { + const socket = connect({ path: socketPath }) + socket.on('connect', () => { + socket.destroy() + resolveReachable(true) + }) + socket.on('error', () => resolveReachable(false)) + }) +} + +function killAndWait(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve() + } + return new Promise((resolveExit, rejectExit) => { + const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS) + child.on('exit', () => { + clearTimeout(timer) + resolveExit() + }) + child.kill('SIGTERM') + }) +} + +async function main() { + if (process.platform === 'win32') { + log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur') + return + } + if (!existsSync(entryPath)) { + throw new Error(`missing ${entryPath} — run \`pnpm build\` first`) + } + + const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-')) + const socketPath = join(dir, 'daemon.sock') + let replaced + let replacement + try { + replaced = await bootDaemon('replaced', dir, socketPath) + const replacedInode = statSync(socketPath).ino + log('daemon A published the endpoint') + + // Reclaim the endpoint name the way daemon replacement does, while A is still alive. + unlinkSync(socketPath) + replacement = await bootDaemon('replacement', dir, socketPath) + const replacementInode = statSync(socketPath).ino + if (replacedInode === replacementInode) { + throw new Error('daemon B did not publish a distinct endpoint') + } + if (!(await isReachable(socketPath))) { + throw new Error('daemon B is not reachable through the canonical endpoint') + } + log('daemon B took over the endpoint and is reachable') + + // A exits long after losing the endpoint. This is the step that used to break B. + await killAndWait(replaced.child) + await new Promise((r) => setTimeout(r, 300)) + + if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) { + throw new Error("daemon A's late exit deleted daemon B's endpoint") + } + if (!(await isReachable(socketPath))) { + throw new Error('daemon B became unreachable after daemon A exited') + } + if (!existsSync(replacement.pidPath)) { + throw new Error("daemon A's late exit removed daemon B's ownership record") + } + if (existsSync(replaced.pidPath)) { + throw new Error('daemon A left its own ownership record behind') + } + + log('PASS: the endpoint owner and the session host stayed the same daemon') + } finally { + for (const daemon of [replaced, replacement]) { + if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) { + try { + daemon.child.kill('SIGKILL') + } catch { + // already gone + } + } + } + rmSync(dir, { recursive: true, force: true }) + } +} + +main().catch((error) => { + console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`) + process.exit(1) +}) diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index e869f652e40..080cb4fa785 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -275,6 +275,16 @@ describe('orchestration skill guidance', () => { ) }) + it('documents per-invocation model and effort for supervised workers', () => { + const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop') + + expect(workerLoop).toContain('opaque provider model id with `--model`') + expect(workerLoop).toContain('`--effort` requires `--model`') + expect(workerLoop).toContain('neither option can combine with `--terminal`') + expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json') + expect(workerLoop).toContain('`launch.requested` and `launch.effective`') + }) + it('never authorizes release from idle, timeout, or worker-side triggers', () => { const skill = readSkill() const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop') diff --git a/config/scripts/relay-watcher-fault-harness.mjs b/config/scripts/relay-watcher-fault-harness.mjs index 12e36f72490..b27a1f945d9 100644 --- a/config/scripts/relay-watcher-fault-harness.mjs +++ b/config/scripts/relay-watcher-fault-harness.mjs @@ -109,9 +109,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) { settled = true proc.stdout.off('data', onData) rejectPromise( - new Error( - `process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}` - ) + new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`) ) } proc.stdout.on('data', onData) @@ -162,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) { }) const waitForMessage = (startIndex, predicate, label) => - pollUntil( - () => messages.slice(startIndex).find(predicate), - label, - streams.stderr - ) + pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr) const request = async (method, params = {}) => { const id = nextSequence++ @@ -317,8 +311,10 @@ async function main() { ) await relay.request('fs.watch', { rootPath: watchRoot }) - const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const firstWatcherPid = await waitForWatcherPid( + pidFile, + undefined, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) const beforePath = join(watchRoot, 'before.txt') startIndex = relay.messageCount() @@ -330,8 +326,10 @@ async function main() { const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV' startIndex = relay.messageCount() process.kill(firstWatcherPid, faultSignal) - const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () => - `${daemonStreams.stderr()}\n${relay.stderr()}` + const replacementWatcherPid = await waitForWatcherPid( + pidFile, + firstWatcherPid, + () => `${daemonStreams.stderr()}\n${relay.stderr()}` ) await relay.waitForNotification(startIndex, 'fs.changed', (params) => Array.isArray(params.events) diff --git a/config/scripts/run-codex-real-account-validation.mjs b/config/scripts/run-codex-real-account-validation.mjs index 53d237f22ee..9318f412ffe 100644 --- a/config/scripts/run-codex-real-account-validation.mjs +++ b/config/scripts/run-codex-real-account-validation.mjs @@ -35,7 +35,6 @@ const RESTRICTED_ENV_KEYS = [ 'HOMEPATH', 'CODEX_HOME', 'ORCA_CODEX_HOME', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_E2E_HOME_DIR', 'ORCA_E2E_USER_DATA_DIR', 'ORCA_USER_DATA_PATH', @@ -69,7 +68,7 @@ async function resolveRealPath(candidate) { } } -export function createValidationEnv(inheritedEnv, layout, options = {}) { +export function createValidationEnv(inheritedEnv, layout) { const env = { ...inheritedEnv } for (const key of RESTRICTED_ENV_KEYS) { delete env[key] @@ -81,12 +80,7 @@ export function createValidationEnv(inheritedEnv, layout, options = {}) { NODE_ENV: 'development', ORCA_E2E_HOME_DIR: layout.homeDir, ORCA_E2E_USER_DATA_DIR: layout.userDataDir, - ORCA_USER_DATA_PATH: layout.userDataDir, - // Why: flag OFF pins every codex spawn to an explicit managed CODEX_HOME, - // so native codex never resolves the OS profile — the only Windows - // configuration where strict zero-event containment is reachable. It also - // exercises the emergency kill-switch lane users fall back to. - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: options.systemDefaultRealHome === 'off' ? '0' : '1' + ORCA_USER_DATA_PATH: layout.userDataDir } } @@ -268,8 +262,7 @@ function parseArgs(argv) { primaryHome: os.homedir(), configTemplate: null, tempParent: null, - laneAwareContainment: false, - systemDefaultRealHome: 'on' + laneAwareContainment: false } for (let index = 0; index < argv.length; index += 1) { const arg = argv[index] @@ -299,14 +292,8 @@ function parseArgs(argv) { options.skipBuild = true } else if (arg === '--keep') { options.keep = true - } else if (arg === '--system-default-real-home') { - const value = readValue() - if (value !== 'on' && value !== 'off') { - throw new Error('--system-default-real-home must be "on" or "off"') - } - options.systemDefaultRealHome = value } else if (arg === '--lane-aware-containment') { - // Why: on Windows the flag-ON system-default lane cannot be env-sandboxed + // Why: on Windows the system-default real-home lane cannot be env-sandboxed // (native codex ignores USERPROFILE), so strict zero-event containment is // structurally unreachable there. This mode records codex's designed // volatile churn without aborting while every other real-home write stays @@ -314,7 +301,7 @@ function parseArgs(argv) { options.laneAwareContainment = true } else if (arg === '--help') { console.log( - 'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template ] [--temp-parent ] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--system-default-real-home on|off] [--report ]' + 'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template ] [--temp-parent ] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--report ]' ) process.exit(0) } else { @@ -480,7 +467,7 @@ async function main() { const reportPath = options.reportPath ?? path.join(os.tmpdir(), `orca-codex-real-account-${options.scenario}-${Date.now()}.json`) - const launchEnv = createValidationEnv(process.env, layout, options) + const launchEnv = createValidationEnv(process.env, layout) let app = null let tripwire = null const abortController = new AbortController() diff --git a/config/scripts/run-codex-real-account-validation.test.ts b/config/scripts/run-codex-real-account-validation.test.ts index 0b10dee6ee8..43b94983749 100644 --- a/config/scripts/run-codex-real-account-validation.test.ts +++ b/config/scripts/run-codex-real-account-validation.test.ts @@ -46,29 +46,9 @@ describe('Codex real-account validation harness', () => { expect(env.CODEX_HOME).toBeUndefined() expect(env.ORCA_CODEX_HOME).toBeUndefined() expect(env.ZDOTDIR).toBeUndefined() - expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1') expect(env.SAFE_VALUE).toBe('preserved') }) - it('pins the real-home flag off when the system-default lane is disabled', async () => { - const primaryHome = path.join(os.tmpdir(), 'orca-primary-home-sentinel') - const { layout, env } = runValidationModule<{ - layout: { tempRoot: string } - env: Record - }>( - ` - const { createValidationEnv, createValidationLayout } = await import(process.argv[1]) - const layout = await createValidationLayout({ primaryHome: process.argv[2] }) - const env = createValidationEnv({}, layout, { systemDefaultRealHome: 'off' }) - console.log(JSON.stringify({ layout, env })) - `, - [primaryHome] - ) - cleanupPaths.push(layout.tempRoot) - - expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('0') - }) - it('records only fingerprints for system-default and managed auth', async () => { const { layout, snapshot } = runValidationModule<{ layout: { tempRoot: string } diff --git a/config/scripts/run-idle-cpu-benchmark.mjs b/config/scripts/run-idle-cpu-benchmark.mjs index baa061b3590..53cabd3380b 100644 --- a/config/scripts/run-idle-cpu-benchmark.mjs +++ b/config/scripts/run-idle-cpu-benchmark.mjs @@ -483,7 +483,6 @@ async function main() { HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) } }) diff --git a/config/scripts/serve-headless-fresh-profile-pairing.mjs b/config/scripts/serve-headless-fresh-profile-pairing.mjs index 8865b841541..1cbfe92473a 100755 --- a/config/scripts/serve-headless-fresh-profile-pairing.mjs +++ b/config/scripts/serve-headless-fresh-profile-pairing.mjs @@ -55,7 +55,6 @@ Object.assign(childEnv, { ORCA_DEV_USER_DATA_PATH: profileDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' } : {}) diff --git a/config/scripts/terminal-reattach-payload-scan-benchmark.mjs b/config/scripts/terminal-reattach-payload-scan-benchmark.mjs new file mode 100644 index 00000000000..fedfdc2c699 --- /dev/null +++ b/config/scripts/terminal-reattach-payload-scan-benchmark.mjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node +// Benchmark: the renderer-side scans that run over a whole reattach payload. +// +// 1. hasCursorAgentReattachPayloadScreenSignal — compares the old hand-rolled char-by-char +// CSI strip against the shipped shape (256KB tail + shared CSI_SEQUENCE_PATTERN). Every +// variant is asserted to agree with the baseline before it is timed. +// 2. TerminalKittyKeyboardModeTracker.scanReplay — measured to justify leaving it alone, and +// to record that porting the daemon mouse mirror's includes() pre-filter makes it slower. +// +// Payloads are generated deterministically (LCG, no Math.random) so runs compare. +// +// Run with: node config/scripts/terminal-reattach-payload-scan-benchmark.mjs +import { performance } from 'node:perf_hooks' +import { CSI_SEQUENCE_PATTERN } from '../../src/shared/ansi-escape-sequences.ts' +import { TerminalKittyKeyboardModeTracker } from '../../src/shared/terminal-kitty-keyboard-mode-tracker.ts' + +const ROUNDS = Number(process.env.ORCA_REATTACH_SCAN_BENCH_ROUNDS ?? '7') +const MIN_ITERATION_MS = 120 + +// --- payload generation ----------------------------------------------------- + +function makeRng(seed) { + let state = seed >>> 0 + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 0x100000000 + } +} + +const WORDS = [ + 'src', + 'renderer', + 'components', + 'terminal', + 'pane', + 'connection', + 'reattach', + 'payload', + 'snapshot', + 'daemon', + 'passed', + 'failed', + 'warning', + 'building', + 'index.ts', + '1.24s', + 'ok' +] + +// Heavily SGR-colored scrollback, ~120 cols/line — what a serialized daemon snapshot +// of a colorized TUI/build log looks like. +function buildColoredScrollback(targetBytes, seed) { + const rng = makeRng(seed) + const lines = [] + let size = 0 + while (size < targetBytes) { + let line = '' + let visible = 0 + while (visible < 118) { + const word = WORDS[Math.floor(rng() * WORDS.length)] + const color = 16 + Math.floor(rng() * 200) + line += `\x1b[38;5;${color}m${word}\x1b[0m ` + visible += word.length + 1 + } + lines.push(line) + size += line.length + 1 + } + return lines.join('\r\n') +} + +// Live TUIs also emit cursor moves / erases; keep some of those in the mix. +function withCursorTraffic(body, seed) { + const rng = makeRng(seed) + return body + .split('\r\n') + .map((line, i) => `\x1b[${(i % 40) + 1};1H\x1b[K${line}${rng() < 0.1 ? '\x1b[?25l' : ''}`) + .join('\r\n') +} + +const CURSOR_AGENT_SCREEN = [ + '\x1b[38;5;39mCursor Agent\x1b[0m', + '', + ' \x1b[2mReady\x1b[0m', + '', + '\x1b[38;5;245m→ \x1b[0m' +].join('\r\n') + +function buildPayloads() { + const base200k = buildColoredScrollback(200 * 1024, 1) + const base2m = buildColoredScrollback(2 * 1024 * 1024, 2) + const relayTail = buildColoredScrollback(100 * 1024, 3) + return [ + { name: '200KB snapshot, header hit (tail)', data: `${base200k}\r\n${CURSOR_AGENT_SCREEN}` }, + { name: '200KB snapshot, no header (miss)', data: base200k }, + { name: '2MB snapshot, header hit (tail)', data: `${base2m}\r\n${CURSOR_AGENT_SCREEN}` }, + { name: '2MB snapshot, no header (miss)', data: base2m }, + { name: '100KB relay tail, cursor traffic', data: withCursorTraffic(relayTail, 4) } + ] +} + +// --- strip variants --------------------------------------------------------- + +// Baseline: verbatim copy of pty-connection.ts:448 (not exported). +function stripBaseline(data) { + let normalized = '' + let index = 0 + while (index < data.length) { + if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') { + index += 2 + while (index < data.length) { + const code = data.charCodeAt(index) + index += 1 + if (code >= 0x40 && code <= 0x7e) { + break + } + } + continue + } + normalized += data[index] + index += 1 + } + return normalized +} + +const stripRegex = (data) => data.replace(CSI_SEQUENCE_PATTERN, '') + +const HEADER = 'Cursor Agent' +const TAIL_CHARS = 5000 +// Mirrors CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS in pty-connection.ts. +const SUFFIX_RAW_CHARS = 256 * 1024 + +const signalBaseline = (data) => hasSignal(stripBaseline(data)) +const signalRegex = (data) => hasSignal(stripRegex(data)) +// The shipped shape. +const signalSuffixRegex = (data) => + hasSignal(stripRegex(data.length > SUFFIX_RAW_CHARS ? data.slice(-SUFFIX_RAW_CHARS) : data)) + +function hasSignal(normalized) { + const headerIndex = normalized.lastIndexOf(HEADER) + if (headerIndex === -1) { + return false + } + return normalized.slice(headerIndex + HEADER.length, headerIndex + TAIL_CHARS).includes(`${'→'} `) +} + +// --- timing ----------------------------------------------------------------- + +function median(values) { + const sorted = [...values].sort((a, b) => a - b) + const mid = sorted.length >> 1 + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 +} + +function timeMsPerCall(fn, arg) { + let iterations = 1 + while (true) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + globalThis.__sink = fn(arg) + } + const elapsed = performance.now() - start + if (elapsed >= MIN_ITERATION_MS || iterations >= 1 << 22) { + return elapsed / iterations + } + iterations *= Math.max(2, Math.ceil(MIN_ITERATION_MS / Math.max(elapsed, 0.01))) + } +} + +function measure(fn, arg) { + timeMsPerCall(fn, arg) // warmup / JIT + const samples = [] + for (let round = 0; round < ROUNDS; round++) { + samples.push(timeMsPerCall(fn, arg)) + } + return median(samples) +} + +const pad = (value, width) => String(value).padStart(width) +const kb = (data) => `${(data.length / 1024).toFixed(0)}KB` + +// --- run -------------------------------------------------------------------- + +const payloads = buildPayloads() + +console.log('Reattach payload scans, ms/call (median of %d rounds). Lower is better.\n', ROUNDS) +console.log('== hasCursorAgentReattachPayloadScreenSignal ==') +console.log( + `${pad('payload', 36)} ${pad('size', 8)} ${pad('char-loop', 11)} ${pad('regex', 10)} ${pad('suffix+regex', 13)} ${pad('speedup', 9)}` +) +for (const { name, data } of payloads) { + const expected = signalBaseline(data) + for (const [label, fn] of Object.entries({ + regex: signalRegex, + 'suffix+regex': signalSuffixRegex + })) { + if (fn(data) !== expected) { + throw new Error(`${label} disagrees with baseline on "${name}"`) + } + } + const base = measure(signalBaseline, data) + const re = measure(signalRegex, data) + const suffix = measure(signalSuffixRegex, data) + console.log( + `${pad(name, 36)} ${pad(kb(data), 8)} ${pad(base.toFixed(3), 11)} ${pad(re.toFixed(3), 10)} ${pad(suffix.toFixed(3), 13)} ${pad(`${(base / suffix).toFixed(0)}x`, 9)}` + ) +} + +console.log('\n== TerminalKittyKeyboardModeTracker.scanReplay ==') +console.log( + `${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}` +) +const kittyScan = (data) => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scanReplay(data) + return tracker.flags +} +// Mirrors src/main/daemon/terminal-mouse-mode-mirror.ts:41-47. +const kittyGated = (data) => { + if (!data.includes('\x1b[?') && !data.includes('\x1bc') && !data.includes('\x9b')) { + return 0 + } + return kittyScan(data) +} +for (const { name, data } of payloads) { + console.log( + `${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}` + ) +} + +// The gate can only pay off where no introducer exists: live plain-output chunks, not +// snapshots (which carry ?1049h/?25l by construction). +const liveChunk = buildColoredScrollback(4 * 1024, 5).replaceAll('\x1b', '') +const introducerRows = [ + ['4KB live chunk, no escapes', liveChunk], + ['4KB live chunk, \\x1b[?25l present', `${liveChunk}\x1b[?25l`] +] +console.log( + `\n${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}` +) +for (const [name, data] of introducerRows) { + console.log( + `${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}` + ) +} + +console.log( + '\nSnapshot/replay payloads always contain \\x1b[?, so the includes() gate never fires on\nthe reattach path — it only helps the live per-chunk path.' +) + +// Reference point: xterm parses the same bytes right after these scans run, so its cost +// is the yardstick for whether the scans are worth cutting. +const xtermHeadless = await import('@xterm/headless') +const Terminal = xtermHeadless.Terminal ?? xtermHeadless.default.Terminal +const writeToXterm = (data) => + new Promise((resolve) => { + const terminal = new Terminal({ cols: 120, rows: 40, scrollback: 5000, allowProposedApi: true }) + const start = performance.now() + terminal.write(data, () => { + const elapsed = performance.now() - start + terminal.dispose() + resolve(elapsed) + }) + }) + +console.log(`\n== xterm headless parse of the same payload (reference) ==`) +console.log(`${pad('payload', 36)} ${pad('size', 8)} ${pad('xterm write', 11)}`) +for (const { name, data } of payloads) { + await writeToXterm(data) + const samples = [] + for (let round = 0; round < ROUNDS; round++) { + samples.push(await writeToXterm(data)) + } + console.log(`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(median(samples).toFixed(3), 11)}`) +} diff --git a/config/scripts/verify-linux-wayland-gpu-sandbox.mjs b/config/scripts/verify-linux-wayland-gpu-sandbox.mjs index ef35b6f7f3d..a36f81db86b 100644 --- a/config/scripts/verify-linux-wayland-gpu-sandbox.mjs +++ b/config/scripts/verify-linux-wayland-gpu-sandbox.mjs @@ -219,7 +219,6 @@ async function runValidation(mode) { ORCA_DEV_USER_DATA_PATH: userDataPath, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ELECTRON_ENABLE_LOGGING: '1', ELECTRON_ENABLE_STACK_DUMPING: '1', ELECTRON_OZONE_PLATFORM_HINT: 'wayland', diff --git a/config/scripts/windows-apphang-repro/electron-dev-session.mjs b/config/scripts/windows-apphang-repro/electron-dev-session.mjs index 0aaead7237e..be09b97014b 100644 --- a/config/scripts/windows-apphang-repro/electron-dev-session.mjs +++ b/config/scripts/windows-apphang-repro/electron-dev-session.mjs @@ -63,7 +63,6 @@ export function launchDevApp({ cdpPort, userDataDir }) { ORCA_DEV_USER_DATA_PATH: userDataDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ORCA_SKIP_DEV_WEB_PREPARE: '1', ORCA_STARTUP_DIAGNOSTICS: '1', REMOTE_DEBUGGING_PORT: String(cdpPort), diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f4247281300..3e0e59381a4 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -63,6 +63,7 @@ "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", "../src/main/grok/hook-service.ts", + "../src/main/grok/windows-grok-hook-script.ts", "../src/main/devin/hook-settings.ts", "../src/main/devin/hook-service.ts", "../src/main/devin/hook-config-json.ts", diff --git a/mobile/app.json b/mobile/app.json index 53654335486..87b50446486 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.37", + "version": "0.0.41", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 10 + "versionCode": 11 }, "plugins": [ "expo-router", diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 1292b6e5f5f..9080cdedcf9 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -10,7 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationTarget } from '../src/notifications/notification-routing' import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route' -import { loadHosts } from '../src/transport/host-store' +import { loadHostCatalog } from '../src/transport/host-store' import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' @@ -95,9 +95,12 @@ export default function RootLayout() { } async function getNavigationTarget(data: unknown) { - const hosts = await loadHosts().catch(() => null) + const hosts = await loadHostCatalog().catch(() => null) return getNotificationNavigationTarget(data, { - knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined + knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined, + credentialStatusByHostId: hosts + ? new Map(hosts.map((host) => [host.id, host.credentialStatus])) + : undefined }) } diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c5e4175acc0..755d6605501 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -4986,6 +4986,7 @@ export default function SessionScreen() { ([]) + const [hostCatalog, setHostCatalog] = useState([]) const [actionTarget, setActionTarget] = useState(null) - const [confirmRemove, setConfirmRemove] = useState(null) + const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null) const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) const [hostLastConnected, setHostLastConnected] = useState>({}) @@ -242,10 +247,15 @@ export default function HomeScreen() { const onboardingOptInCheckedRef = useRef(false) // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. + const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog]) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) - const allClients = useAllHostClients(hostIds) + const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts]) + const allClients = useAllHostClients(hostIds, { + autoConnectHostIds, + closeUnusedOnRelease: true + }) const hostPaths = useMemo( () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), [allClients] @@ -308,12 +318,12 @@ export default function HomeScreen() { useFocusEffect( useCallback(() => { let stale = false - void loadHosts().then(async (h) => { + void loadHostCatalog().then(async (catalog) => { if (stale) { return } - setHosts(h) - if (h.length === 0 || onboardingOptInCheckedRef.current) { + setHostCatalog(catalog) + if (catalog.length === 0 || onboardingOptInCheckedRef.current) { return } onboardingOptInCheckedRef.current = true @@ -351,6 +361,10 @@ export default function HomeScreen() { () => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected), [hosts] ) + const sortedHostCatalog = useMemo( + () => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected), + [hostCatalog] + ) // Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working. useEffect(() => { @@ -389,17 +403,24 @@ export default function HomeScreen() { } } // Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'. - for (const host of hosts) { + for (const host of hostCatalog) { if (liveIds.has(host.id)) { continue } - if (!host.publicKeyB64 || !host.deviceToken) { + if (host.credentialStatus === 'missing') { if (next[host.id] !== 'auth-failed') { next[host.id] = 'auth-failed' changed = true } continue } + if (host.credentialStatus === 'temporarily-unavailable') { + if (next[host.id] !== 'disconnected') { + next[host.id] = 'disconnected' + changed = true + } + continue + } const prevState = next[host.id] if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') { next[host.id] = 'disconnected' @@ -408,14 +429,14 @@ export default function HomeScreen() { } // Drop entries for hosts we no longer track at all. for (const id of Object.keys(next)) { - if (!liveIds.has(id) && hosts.some((h) => h.id === id) === false) { + if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) { delete next[id] changed = true } } return changed ? next : prev }) - }, [allClients, hosts]) + }, [allClients, hostCatalog]) // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the @@ -629,7 +650,7 @@ export default function HomeScreen() { try { await removeHostAndCloseClient(hostToRemove.id, closeHostClient) setConfirmRemove(null) - setHosts(await loadHosts()) + setHostCatalog(await loadHostCatalog()) } catch { // Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent. setConfirmRemove(hostToRemove) @@ -655,7 +676,7 @@ export default function HomeScreen() { - {hosts.length === 0 ? ( + {hostCatalog.length === 0 ? ( /* ─── Empty state: onboarding ─── */ h.id} // Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator. contentContainerStyle={[ @@ -732,7 +753,11 @@ export default function HomeScreen() { } ItemSeparatorComponent={CardGap} renderItem={({ item }) => { - const state = hostStates[item.id] ?? 'connecting' + const state = resolveHomeHostConnectionState( + item.id, + hostStates[item.id], + autoConnectHostIds + ) const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null const verdict = classifyConnection({ @@ -744,14 +769,36 @@ export default function HomeScreen() { return ( router.push(`/h/${item.id}`)} + onPress={() => { + if (item.credentialStatus === 'missing') { + router.push('/pair-scan') + } else if (item.credentialStatus === 'temporarily-unavailable') { + void loadHostCatalog() + .then(setHostCatalog) + .catch(() => Alert.alert('Could not check pairing', 'Please try again.')) + } else { + router.push(`/h/${item.id}`) + } + }} onLongPress={() => { triggerMediumImpact() - setActionTarget(item) + if (item.profile) { + setActionTarget(item.profile) + } else { + setConfirmRemove(item) + } + }} + onOpenActions={() => { + if (item.profile) { + setActionTarget(item.profile) + } else { + setConfirmRemove(item) + } }} /> ) @@ -920,7 +967,13 @@ export default function HomeScreen() { message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined} actions={getHostListActionSheetActions({ host: actionTarget, - state: actionTarget ? (hostStates[actionTarget.id] ?? 'connecting') : 'disconnected', + state: actionTarget + ? resolveHomeHostConnectionState( + actionTarget.id, + hostStates[actionTarget.id], + autoConnectHostIds + ) + : 'disconnected', hasEverConnected: actionTarget ? (hostLastConnected[actionTarget.id] ?? null) != null : false, @@ -928,7 +981,7 @@ export default function HomeScreen() { onReconnect: (hostId) => void forceReconnectHost(hostId), onDisconnect: closeHostClient, onEdit: openMobileHostEdit, - onRemove: setConfirmRemove + onRemove: (host) => setConfirmRemove(host) })} onClose={() => setActionTarget(null)} /> diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index e6aa691c76e..e55819aa22a 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' -import { useAllHostClients } from '../src/transport/use-all-host-clients' +import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings' @@ -127,7 +127,7 @@ export default function TerminalSettingsScreen() { void loadHosts().then(setHosts) }, []) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - const hostClients = useAllHostClients(hostIds) + const { clients: hostClients } = useFocusedSettingsHostClients(hostIds) const hostClientsById = useMemo( () => new Map(hostClients.map((entry) => [entry.hostId, entry.client])), [hostClients] diff --git a/mobile/app/voice-settings.tsx b/mobile/app/voice-settings.tsx index a09d964b388..8648a1d38e5 100644 --- a/mobile/app/voice-settings.tsx +++ b/mobile/app/voice-settings.tsx @@ -9,12 +9,12 @@ import { View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useFocusEffect, useRouter } from 'expo-router' +import { useRouter } from 'expo-router' import { ChevronLeft, ChevronRight } from 'lucide-react-native' import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' -import { useAllHostClients } from '../src/transport/use-all-host-clients' +import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' import type { RpcClient } from '../src/transport/rpc-client' import { BottomDrawer } from '../src/components/BottomDrawer' import { VoiceModelList } from '../src/components/VoiceModelList' @@ -47,7 +47,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element { void loadHosts().then(setHosts) }, []) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) - const hostClients = useAllHostClients(hostIds) + const { clients: hostClients, focused: routeFocused } = useFocusedSettingsHostClients(hostIds) // Voice dictation runs on the paired desktop, so pick the first connected host. const client: RpcClient | null = useMemo( () => hostClients.find((entry) => entry.state === 'connected')?.client ?? null, @@ -59,15 +59,6 @@ export default function VoiceSettingsScreen(): React.JSX.Element { const [error, setError] = useState(null) const [busyAction, setBusyAction] = useState(null) const [modelDrawerOpen, setModelDrawerOpen] = useState(false) - const [routeFocused, setRouteFocused] = useState(false) - - useFocusEffect( - useCallback(() => { - setRouteFocused(true) - return () => setRouteFocused(false) - }, []) - ) - const refresh = useCallback(async (): Promise => { if (!client) { return false diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts index 03893215f2e..f938a98772c 100644 --- a/mobile/src/accounts/mobile-accounts-route.test.ts +++ b/mobile/src/accounts/mobile-accounts-route.test.ts @@ -45,7 +45,7 @@ describe('mobile accounts route', () => { navigateToHostStackRoute( harness.navigation, - { push }, + { push, replace: vi.fn() }, 'host/one', mobileAccountsRouteTarget('host/one') ) diff --git a/mobile/src/components/MobileHostCard.test.ts b/mobile/src/components/MobileHostCard.test.ts new file mode 100644 index 00000000000..f7c494cfdbf --- /dev/null +++ b/mobile/src/components/MobileHostCard.test.ts @@ -0,0 +1,203 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileHostCard } from './MobileHostCard' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles }, + Text: 'Text', + View: 'View' +})) + +vi.mock('lucide-react-native', () => ({ + Monitor: 'Monitor', + MoreVertical: 'MoreVertical' +})) + +vi.mock('./StatusDot', () => ({ + StatusDot: 'StatusDot' +})) + +function suppressRendererDeprecation() { + return vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) { + throw new Error(String(args[0])) + } + }) +} + +describe('MobileHostCard', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + it('keeps host navigation and actions as separate accessible controls', async () => { + const onPress = vi.fn() + const onLongPress = vi.fn() + const onOpenActions = vi.fn() + const consoleError = suppressRendererDeprecation() + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host: { + id: 'desk', + name: 'Desk', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 1 + }, + state: 'disconnected', + verdict: { kind: 'normal', label: 'Disconnected' }, + path: 'lan', + onPress, + onLongPress, + onOpenActions + }) + ) + }) + consoleError.mockRestore() + + const buttons = renderer.root.findAllByType('Pressable') + expect(buttons).toHaveLength(2) + expect(buttons[0].props.accessibilityRole).toBe('button') + expect(buttons[0].props.accessibilityLabel).toBe('Open Desk, Disconnected') + expect(buttons[1].props.accessibilityRole).toBe('button') + expect(buttons[1].props.accessibilityLabel).toBe('Actions for Desk') + expect(buttons[1].props.hitSlop).toBe(8) + expect(buttons[1].props.style({ pressed: false })[0]).toMatchObject({ width: 40, height: 40 }) + expect(renderer.root.findAllByType('MoreVertical')).toHaveLength(1) + expect(renderer.root.findAllByType('ChevronRight')).toHaveLength(0) + + act(() => buttons[1].props.onPress()) + expect(onOpenActions).toHaveBeenCalledOnce() + expect(onPress).not.toHaveBeenCalled() + + act(() => buttons[0].props.onPress()) + act(() => buttons[0].props.onLongPress()) + expect(onPress).toHaveBeenCalledOnce() + expect(onLongPress).toHaveBeenCalledOnce() + }) + + it('announces the connection path without the visual separator', async () => { + const consoleError = suppressRendererDeprecation() + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host: { + id: 'desk', + name: 'Desk', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 1 + }, + state: 'connected', + verdict: { kind: 'normal', label: 'Connected' }, + path: 'tailscale', + worktreeInfo: { + hostId: 'desk', + totalWorktrees: 3, + activeCount: 2, + lastActiveWorktree: null, + countsProvenAt: Date.now() + }, + onPress: vi.fn(), + onLongPress: vi.fn(), + onOpenActions: vi.fn() + }) + ) + }) + consoleError.mockRestore() + + const navigationButton = renderer.root.findAllByType('Pressable')[0] + expect(navigationButton.props.accessibilityLabel).toBe( + 'Open Desk, Connected, Direct via Tailscale, 3 worktrees, 2 active' + ) + }) + + it('preserves the connected worktree-catalog failure state', async () => { + const consoleError = suppressRendererDeprecation() + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host: { + id: 'desk', + name: 'Desk', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 1 + }, + state: 'connected', + verdict: { kind: 'normal', label: 'Connected' }, + path: 'relay', + worktreeInfo: { + hostId: 'desk', + totalWorktrees: 0, + activeCount: 0, + lastActiveWorktree: null, + catalogUnavailable: true + }, + onPress: vi.fn(), + onLongPress: vi.fn(), + onOpenActions: vi.fn() + }) + ) + }) + consoleError.mockRestore() + + const navigationButton = renderer.root.findAllByType('Pressable')[0] + expect(navigationButton.props.accessibilityLabel).toBe( + 'Open Desk, Connected, Orca Relay, Worktree list unavailable' + ) + expect( + renderer.root + .findAllByType('Text') + .some((node) => node.children.includes('Worktree list unavailable')) + ).toBe(true) + }) + + it('includes visible offline recovery guidance in the navigation label', async () => { + const consoleError = suppressRendererDeprecation() + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host: { + id: 'desk', + name: 'Desk', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 1 + }, + state: 'reconnecting', + verdict: { + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'never-connected' + }, + path: 'lan', + onPress: vi.fn(), + onLongPress: vi.fn(), + onOpenActions: vi.fn() + }) + ) + }) + consoleError.mockRestore() + + const navigationButton = renderer.root.findAllByType('Pressable')[0] + expect(navigationButton.props.accessibilityLabel).toBe( + "Open Desk, Can't reach desktop, Update desktop Orca and sign in to connect from anywhere" + ) + }) +}) diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx index 6138e311127..66e978a14ce 100644 --- a/mobile/src/components/MobileHostCard.test.tsx +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ConnectionVerdict } from '../transport/connection-health' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' -import type { ConnectionState, HostProfile } from '../transport/types' +import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types' import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo @@ -16,7 +16,7 @@ vi.mock('react-native', () => ({ Text: 'Text', View: 'View' })) -vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight', Monitor: 'Monitor' })) +vi.mock('lucide-react-native', () => ({ Monitor: 'Monitor', MoreVertical: 'MoreVertical' })) vi.mock('./StatusDot', () => ({ StatusDot: 'StatusDot' })) const host: HostProfile = { @@ -54,6 +54,7 @@ describe('MobileHostCard', () => { state?: ConnectionState verdict?: ConnectionVerdict path?: MobileConnectionPath + credentialStatus?: HostCredentialStatus } ): Promise { await act(async () => { @@ -63,9 +64,11 @@ describe('MobileHostCard', () => { state: overrides?.state ?? 'connected', verdict: overrides?.verdict ?? verdict, path: overrides?.path ?? 'lan', + credentialStatus: overrides?.credentialStatus, worktreeInfo, onPress: () => {}, - onLongPress: () => {} + onLongPress: () => {}, + onOpenActions: () => {} }) ) }) @@ -139,4 +142,28 @@ describe('MobileHostCard', () => { expect(lines).not.toContain('0 worktrees') expect(lines).not.toContain('Worktree list unavailable') }) + + it('offers re-pairing when the credential is missing', async () => { + const lines = await renderCard(loaded, { + state: 'connected', + verdict: { kind: 'auth-failed', label: 'Pairing invalid' }, + credentialStatus: 'missing' + }) + + expect(lines).toContain('Pairing invalid') + expect(lines).toContain('Tap to re-pair with your desktop') + expect(lines).not.toContain('12 worktrees · 2 active') + }) + + it('offers a retry without declaring a transient read failure invalid', async () => { + const lines = await renderCard(undefined, { + state: 'disconnected', + verdict: { kind: 'normal', label: 'Disconnected' }, + credentialStatus: 'temporarily-unavailable' + }) + + expect(lines).toContain('Pairing temporarily unavailable') + expect(lines).toContain('Unlock your phone, then tap to retry') + expect(lines).not.toContain('Pairing invalid') + }) }) diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index f81f936405a..a09ae0f3cf7 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -1,16 +1,17 @@ -import { ChevronRight, Monitor } from 'lucide-react-native' +import { Monitor, MoreVertical } from 'lucide-react-native' import { Pressable, StyleSheet, Text, View } from 'react-native' import type { ConnectionVerdict } from '../transport/connection-health' import { verdictDisplayLabel } from '../transport/connection-health' import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label' import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' -import type { ConnectionState, HostProfile } from '../transport/types' +import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types' import { colors, radii, spacing } from '../theme/mobile-theme' import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info' import { StatusDot } from './StatusDot' export function MobileHostCard(props: { - host: HostProfile + host: HostProfile | HostCatalogEntry + credentialStatus?: HostCatalogEntry['credentialStatus'] state: ConnectionState verdict: ConnectionVerdict path: MobileConnectionPath @@ -19,52 +20,113 @@ export function MobileHostCard(props: { worktreeInfo?: HostWorktreeInfo onPress: () => void onLongPress: () => void + onOpenActions: () => void }) { - const connected = props.state === 'connected' + const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable' + const credentialMissing = props.credentialStatus === 'missing' + const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing // Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the // path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is // named — 'lan' doubles as the unknown-path default, so it would be a guess before connect. const dialingPath = ['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay' - const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) + const isError = + credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) + const statusLabel = credentialMissing + ? 'Pairing invalid' + : credentialUnavailable + ? 'Pairing temporarily unavailable' + : verdictDisplayLabel(props.verdict) + const statusVerdict: ConnectionVerdict = credentialMissing + ? { kind: 'auth-failed', label: statusLabel } + : credentialUnavailable + ? { kind: 'warning', label: statusLabel } + : props.verdict const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) + const connectionPathLabel = + !credentialMissing && !credentialUnavailable && (connected || dialingPath) + ? mobileConnectionPathLabel(props.path) + : null + const discoveryHint = + props.verdict.kind === 'unreachable' && !props.host.relay + ? 'Update desktop Orca and sign in to connect from anywhere' + : null + const credentialHint = credentialMissing + ? 'Tap to re-pair with your desktop' + : credentialUnavailable + ? 'Unlock your phone, then tap to retry' + : null + const accessibilityLabel = [ + `Open ${props.host.name}`, + statusLabel, + connectionPathLabel?.replace(' · ', ' via '), + connected ? worktreeSummary?.replace(' · ', ', ') : null, + discoveryHint, + credentialHint + ] + .filter(Boolean) + .join(', ') return ( - [styles.card, pressed && styles.cardPressed]} - onPress={props.onPress} - onLongPress={props.onLongPress} - delayLongPress={400} - > - - - - - - {props.host.name} - - - - - {verdictDisplayLabel(props.verdict)} - {connected || dialingPath ? ` · ${mobileConnectionPathLabel(props.path)}` : ''} - + + [styles.cardMain, pressed && styles.cardPressed]} + onPress={props.onPress} + onLongPress={props.onLongPress} + delayLongPress={400} + > + + - {connected && worktreeSummary ? ( - - {worktreeSummary} + + + {props.host.name} - ) : null} - {props.verdict.kind === 'unreachable' && !props.host.relay ? ( - - Update desktop Orca and sign in to connect from anywhere - - ) : null} - - - + + + + {statusLabel} + {connectionPathLabel ? ` · ${connectionPathLabel}` : ''} + + + {connected && worktreeSummary ? ( + + {worktreeSummary} + + ) : null} + {discoveryHint ? ( + + {discoveryHint} + + ) : null} + {credentialHint ? ( + + {credentialHint} + + ) : null} + + + [styles.actionButton, pressed && styles.actionButtonPressed]} + onPress={props.onOpenActions} + > + + + ) } @@ -72,12 +134,19 @@ const styles = StyleSheet.create({ card: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: 12, borderRadius: radii.card, backgroundColor: colors.bgPanel, borderWidth: 1, - borderColor: colors.borderSubtle + borderColor: colors.borderSubtle, + overflow: 'hidden' + }, + cardMain: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + paddingLeft: spacing.md, + paddingVertical: 12 }, cardPressed: { backgroundColor: colors.bgRaised }, icon: { @@ -104,5 +173,16 @@ const styles = StyleSheet.create({ fontSize: 11, lineHeight: 15, color: colors.textMuted + }, + actionButton: { + width: 40, + height: 40, + marginHorizontal: spacing.xs, + borderRadius: radii.row, + alignItems: 'center', + justifyContent: 'center' + }, + actionButtonPressed: { + backgroundColor: colors.bgRaised } }) diff --git a/mobile/src/files/file-tree.test.ts b/mobile/src/files/file-tree.test.ts index bdd9e9225cb..a11690047b0 100644 --- a/mobile/src/files/file-tree.test.ts +++ b/mobile/src/files/file-tree.test.ts @@ -34,6 +34,18 @@ describe('file-tree', () => { ]) }) + it('orders numbered names naturally, matching the desktop explorer', () => { + const cache: DirectoryCache = { + '': { entries: [entry('100 - b.txt'), entry('9 - c.txt'), entry('99 - a.txt')] } + } + + expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([ + 'file:9 - c.txt', + 'file:99 - a.txt', + 'file:100 - b.txt' + ]) + }) + it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => { const cache: DirectoryCache = { '': { diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts index a9283cfa457..71f26ca155b 100644 --- a/mobile/src/files/file-tree.ts +++ b/mobile/src/files/file-tree.ts @@ -1,5 +1,6 @@ // Pure tree projection for the mobile file explorer. Mobile mirrors desktop // browse semantics by flattening cached files.readDir results as folders open. +import { compareFileNames } from '../../../src/shared/file-name-sort' export type MobileDirEntry = { name: string @@ -118,7 +119,7 @@ function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number { if (a.isDirectory !== b.isDirectory) { return a.isDirectory ? -1 : 1 } - return a.name.localeCompare(b.name) + return compareFileNames(a.name, b.name) } export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean { diff --git a/mobile/src/navigation/host-stack-navigation.test.ts b/mobile/src/navigation/host-stack-navigation.test.ts index dc3a94cfe7a..43848f35449 100644 --- a/mobile/src/navigation/host-stack-navigation.test.ts +++ b/mobile/src/navigation/host-stack-navigation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { coordinateHostStackNavigation, hostStackHostRoute, + hostStackRouteHref, navigateToHostStackRoute, type HostStackNavigationState } from './host-stack-navigation' @@ -69,7 +70,7 @@ describe('host stack navigation', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', TARGET) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -89,7 +90,12 @@ describe('host stack navigation', () => { rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] }) ) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(rootLayoutScopedState(committedHostState('host/one'))) expect(harness.navigation.dispatch).toHaveBeenCalledWith({ @@ -103,7 +109,12 @@ describe('host stack navigation', () => { it('survives the state emitted before the root navigator has hydrated', () => { const harness = navigationHarness(undefined) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) expect(() => harness.setState(undefined)).not.toThrow() expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -112,10 +123,30 @@ describe('host stack navigation', () => { expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1) }) + it('replaces through Expo Router when root state omits the mounted child stack', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() + + navigateToHostStackRoute(harness.navigation, { push: vi.fn(), replace }, 'host/one', TARGET) + harness.setState({ + index: 0, + routes: [{ name: 'h', params: { hostId: 'host/one' } }] + }) + + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).toHaveBeenCalledWith(hostStackRouteHref(TARGET)) + expect(harness.listenerCount()).toBe(0) + }) + it('abandons the transition when navigation leaves the host route it was waiting on', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState({ index: 0, routes: [{ name: 'h' }] }) harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState(committedHostState('host/one')) @@ -127,7 +158,12 @@ describe('host stack navigation', () => { it('ignores a different host whose id merely decodes badly', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(committedHostState('100%')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -138,7 +174,7 @@ describe('host stack navigation', () => { const controller = navigateToHostStackRoute( harness.navigation, - { push: vi.fn() }, + { push: vi.fn(), replace: vi.fn() }, 'host/one', TARGET ) @@ -156,7 +192,7 @@ describe('host stack navigation', () => { it('retargets a still-pending transition to the same host instead of pushing twice', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - const router = { push } + const router = { push, replace: vi.fn() } const pending = coordinateHostStackNavigation( null, diff --git a/mobile/src/navigation/host-stack-navigation.ts b/mobile/src/navigation/host-stack-navigation.ts index 6f73dd3e57a..cb3b87cd98f 100644 --- a/mobile/src/navigation/host-stack-navigation.ts +++ b/mobile/src/navigation/host-stack-navigation.ts @@ -35,8 +35,14 @@ export type HostStackRootNavigation = { export type HostStackHostRoute = `/h/${string}` +export type HostStackRouteHref = Readonly<{ + pathname: `/h/${string}` + params: Readonly> +}> + export type HostStackRouter = { push: (route: HostStackHostRoute) => void + replace: (route: HostStackRouteHref) => void } export type HostStackNavigationController = Readonly<{ @@ -54,6 +60,10 @@ export function hostStackHostRoute(hostId: string): HostStackHostRoute { return `/h/${encodeURIComponent(hostId)}` } +export function hostStackRouteHref(target: HostStackRouteTarget): HostStackRouteHref { + return { pathname: `/h/${target.name}`, params: target.params } +} + // Why: the host is pushed as an encoded segment, so the committed route may hold // either form — an id with `/`, `#`, or `%` must still match its own push. function hostParamMatches(param: unknown, expectedHostId: string): boolean { @@ -144,6 +154,10 @@ export function navigateToHostStackRoute( } const hostStack = hostContainer && mountedHostStack(hostContainer, hostId) if (!hostStack) { + if (hostContainer && hostParamMatches(hostContainer.params?.hostId, hostId)) { + dispose() + router.replace(hostStackRouteHref(selectedTarget)) + } return } dispose() diff --git a/mobile/src/notifications/notification-route-coordination.test.ts b/mobile/src/notifications/notification-route-coordination.test.ts index e41df45a8a2..eab13fdc046 100644 --- a/mobile/src/notifications/notification-route-coordination.test.ts +++ b/mobile/src/notifications/notification-route-coordination.test.ts @@ -47,7 +47,12 @@ describe('notification route coordination', () => { const harness = navigationHarness(undefined) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, target!.hostId, target!.sessionTarget!) + navigateToHostStackRoute( + harness.navigation, + { push, replace: vi.fn() }, + target!.hostId, + target!.sessionTarget! + ) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() diff --git a/mobile/src/notifications/notification-routing.test.ts b/mobile/src/notifications/notification-routing.test.ts index 6b5762aefb4..779aa2425e5 100644 --- a/mobile/src/notifications/notification-routing.test.ts +++ b/mobile/src/notifications/notification-routing.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildLocalNotificationData, getNotificationNavigationTarget } from './notification-routing' +import { + buildLocalNotificationData, + getNotificationNavigationTarget, + notificationCredentialRecoveryRoute +} from './notification-routing' describe('notification routing', () => { it('includes the host id in locally scheduled notification data', () => { @@ -55,4 +59,32 @@ describe('notification routing', () => { ) ).toBeNull() }) + + it.each([ + ['missing', 're-pair'], + ['temporarily-unavailable', 'retry'] + ] as const)('routes %s host credentials to %s recovery', (status, recovery) => { + const target = getNotificationNavigationTarget( + { hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' }, + { + knownHostIds: new Set(['host-1']), + credentialStatusByHostId: new Map([['host-1', status]]) + } + ) + + expect(target).toMatchObject({ hostId: 'host-1', credentialRecovery: recovery }) + expect(notificationCredentialRecoveryRoute(target!)).toBe( + status === 'missing' ? '/pair-scan' : '/' + ) + }) + + it('keeps ready hosts on the requested notification destination', () => { + const target = getNotificationNavigationTarget( + { hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' }, + { credentialStatusByHostId: new Map([['host-1', 'ready']]) } + ) + + expect(target?.sessionTarget).not.toBeNull() + expect(notificationCredentialRecoveryRoute(target!)).toBeNull() + }) }) diff --git a/mobile/src/notifications/notification-routing.ts b/mobile/src/notifications/notification-routing.ts index feda52c596b..5f81fb3567d 100644 --- a/mobile/src/notifications/notification-routing.ts +++ b/mobile/src/notifications/notification-routing.ts @@ -1,5 +1,6 @@ import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' import { mobileSessionRouteTarget } from '../session/mobile-session-route' +import type { HostCredentialStatus } from '../transport/types' export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test' @@ -18,6 +19,7 @@ export type LocalNotificationData = { export type NotificationNavigationOptions = { knownHostIds?: ReadonlySet + credentialStatusByHostId?: ReadonlyMap } function readNonEmptyString(value: unknown): string | null { @@ -46,8 +48,18 @@ export function buildLocalNotificationData( export type NotificationNavigationTarget = Readonly<{ hostId: string sessionTarget: HostStackRouteTarget | null + credentialRecovery?: 'retry' | 're-pair' }> +export function notificationCredentialRecoveryRoute( + target: NotificationNavigationTarget +): '/' | '/pair-scan' | null { + if (target.credentialRecovery === 're-pair') { + return '/pair-scan' + } + return target.credentialRecovery === 'retry' ? '/' : null +} + export function getNotificationNavigationTarget( data: unknown, options: NotificationNavigationOptions = {} @@ -66,8 +78,14 @@ export function getNotificationNavigationTarget( } const worktreeId = readNonEmptyString(record.worktreeId) + const credentialStatus = options.credentialStatusByHostId?.get(hostId) return { hostId, - sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null + sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null, + ...(credentialStatus === 'missing' + ? { credentialRecovery: 're-pair' as const } + : credentialStatus === 'temporarily-unavailable' + ? { credentialRecovery: 'retry' as const } + : {}) } } diff --git a/mobile/src/notifications/use-open-notification-route.ts b/mobile/src/notifications/use-open-notification-route.ts index 3a0809c7be8..31d4715c43e 100644 --- a/mobile/src/notifications/use-open-notification-route.ts +++ b/mobile/src/notifications/use-open-notification-route.ts @@ -2,7 +2,10 @@ import { useCallback } from 'react' import { useRouter } from 'expo-router' import { hostStackHostRoute } from '../navigation/host-stack-navigation' import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' -import type { NotificationNavigationTarget } from './notification-routing' +import { + notificationCredentialRecoveryRoute, + type NotificationNavigationTarget +} from './notification-routing' export function useOpenNotificationRoute(): (target: NotificationNavigationTarget) => void { const openHostStackRoute = useOpenHostStackRoute() @@ -10,6 +13,11 @@ export function useOpenNotificationRoute(): (target: NotificationNavigationTarge return useCallback( (target) => { + const recoveryRoute = notificationCredentialRecoveryRoute(target) + if (recoveryRoute) { + router.push(recoveryRoute) + return + } if (target.sessionTarget) { openHostStackRoute(target.hostId, target.sessionTarget) return diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index 86fcb0dc4a2..68258c22820 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -187,7 +187,7 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac disabled={!canAdvance} > - {isLast ? 'Send answer' : 'Next'} + {isLast ? 'Submit' : 'Next'} diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 2680064c94b..c8e62c146cb 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -47,6 +47,7 @@ function overlayElement(tick: Tick): ReturnType { nativeChatStreamLive: tick.streamLive ?? false, nativeChatStreamScopeKey: tick.identity ?? 'tab-a', chatPending: [], + chatImagePreviewsByMessageId: {}, chatComposerText: '', setChatComposerText: vi.fn() } as unknown as MobileNativeChatController diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 4361c185d3c..699f8a1614b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -82,6 +82,7 @@ export function MobileNativeChatOverlay({ onLoadEarlier={session.loadEarlier} onSend={images.sendNativeChat} pending={controller.chatPending} + imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} composerText={controller.chatComposerText} onComposerTextChange={controller.setChatComposerText} onAttachImage={() => void images.attachImage('library')} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 05763d59707..21a5a7c4aa1 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -50,8 +50,13 @@ vi.mock('./MobileAgentWorkingIndicator', () => ({ vi.mock('./MobileNativeChatComposer', async () => { const React = await import('react') return { - MobileNativeChatComposer: (props: { onSend: (text: string) => Promise }) => + MobileNativeChatComposer: (props: { + onSend: (text: string) => Promise + disabled?: boolean + placeholder?: string + }) => React.createElement('Composer', { + ...props, accessibilityLabel: 'Send message', onPress: () => props.onSend('hi') }) @@ -136,6 +141,10 @@ describe('MobileNativeChatView', () => { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } + function composer(): ReactTestInstance { + return renderer!.root.find((node) => node.type === 'Composer') + } + function bannerText(): string { const [alert, ...rest] = banners() expect(rest).toHaveLength(0) @@ -197,4 +206,42 @@ describe('MobileNativeChatView', () => { expect(listIds()).toEqual(['a1', 'streaming']) }) + + it('keeps a visible lock through a subscribed-end lease blip', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + expect(composer().props.disabled).toBe(true) + + await update({ inputLockReason: null }) + expect(composer().props.disabled).toBe(true) + await act(async () => vi.advanceTimersByTime(300)) + await update({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + + expect(composer().props.disabled).toBe(true) + expect(composer().props.placeholder).toBe('Waiting for terminal…') + } finally { + vi.useRealTimers() + } + }) + + it('unlocks after the lease stays ready', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + await update({ inputLockReason: null }) + await act(async () => vi.advanceTimersByTime(599)) + expect(composer().props.disabled).toBe(true) + + await act(async () => vi.advanceTimersByTime(1)) + + expect(composer().props.disabled).toBe(false) + expect(composer().props.placeholder).toBe('Message, @files, /commands') + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index a35f3caed3c..478d36267a7 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -33,6 +33,8 @@ import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' +const INPUT_LOCK_SETTLE_MS = 600 + /** Why the composer input is locked: the transport is disconnected, or the * terminal subscription has not acknowledged its input lease yet. */ export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' @@ -59,6 +61,9 @@ type Props = { /** Optimistic queued sends (owned by the route so they survive view switches). */ /** Optimistic user echoes, including any ridden-along image preview URIs. */ pending: MobileNativeChatPendingItem[] + /** Local photo URIs retained when the authoritative transcript replaces an + * optimistic image bubble. */ + imagePreviewsByMessageId?: Record /** Controlled composer text (owned by the route so dictation can write to it). */ composerText: string onComposerTextChange: (text: string) => void @@ -123,6 +128,7 @@ export function MobileNativeChatView({ onLoadEarlier, onSend, pending, + imagePreviewsByMessageId, composerText, onComposerTextChange, onAttachImage, @@ -175,8 +181,14 @@ export function MobileNativeChatView({ // route-owned optimistic queued messages. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. const { data } = useMemo( - () => buildMobileNativeChatTransientData({ folded, streaming, pending }), - [folded, streaming, pending] + () => + buildMobileNativeChatTransientData({ + folded, + streaming, + pending, + imagePreviewsByMessageId + }), + [folded, streaming, pending, imagePreviewsByMessageId] ) // Follow the tail as the conversation grows and keep the newest message above @@ -250,20 +262,18 @@ export function MobileNativeChatView({ const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 - // Composer-lock flicker guard: on a remote link, brief connState blips or lease - // hand-offs would otherwise toggle the lock placeholder on and off. Only surface - // a lock once it has held ~600ms; drop it instantly so unlocking stays snappy. + // A dead PTY emits subscribed→end; settle both edges so its false lease cannot flash the composer enabled. const rawLockReason = inputLockReason ?? null + const rawLockHeld = rawLockReason !== null const [lockHeld, setLockHeld] = useState(false) useEffect(() => { - if (rawLockReason === null) { - setLockHeld(false) + if (rawLockHeld === lockHeld) { return } - const timer = setTimeout(() => setLockHeld(true), 600) + const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS) return () => clearTimeout(timer) - }, [rawLockReason]) - const lockReason = lockHeld ? rawLockReason : null + }, [lockHeld, rawLockHeld]) + const lockReason = lockHeld ? (rawLockReason ?? 'waiting') : null return ( diff --git a/mobile/src/session/MobileTerminalLiveInputStatus.tsx b/mobile/src/session/MobileTerminalLiveInputStatus.tsx index dc135c3eb60..a27b09f07d2 100644 --- a/mobile/src/session/MobileTerminalLiveInputStatus.tsx +++ b/mobile/src/session/MobileTerminalLiveInputStatus.tsx @@ -10,11 +10,13 @@ type DictationStatus = { type MobileTerminalLiveInputStatusProps = { readonly dictation: DictationStatus readonly isAttaching: boolean + readonly liveInputText: string } export function MobileTerminalLiveInputStatus({ dictation, - isAttaching + isAttaching, + liveInputText }: MobileTerminalLiveInputStatusProps) { const title = dictation.isRecording ? 'Listening' @@ -31,14 +33,14 @@ export function MobileTerminalLiveInputStatus({ ? 'Preparing microphone' : isAttaching ? 'Uploading image to host' - : 'Tap to show keyboard' + : liveInputText || 'Tap to show keyboard' return ( {title} - + {detail} diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts index fbdd9eb5dec..35e3cff3b60 100644 --- a/mobile/src/session/mobile-image-source-picker.test.ts +++ b/mobile/src/session/mobile-image-source-picker.test.ts @@ -12,13 +12,28 @@ vi.mock('expo-file-system', () => ({ File: vi.fn() })) -import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker' +import { + ImageLibraryPermissionError, + pickMobileImage, + pickMobileImages, + type PickedMobileImage +} from './mobile-image-source-picker' const granted = { granted: true } as Awaited< ReturnType > const denied = { granted: false } as typeof granted +async function collectImages( + images: AsyncIterable +): Promise { + const collected: PickedMobileImage[] = [] + for await (const image of images) { + collected.push(image) + } + return collected +} + function fileFactory( bytes: Uint8Array, options?: { fileSize?: number; handleSize?: number | null; readError?: Error } @@ -68,6 +83,57 @@ describe('pickMobileImage', () => { expect(file.close).toHaveBeenCalledTimes(1) }) + it('returns every selected library photo in order', async () => { + const bytesByUri = new Map([ + ['file:///a.jpg', new Uint8Array([1])], + ['file:///b.jpg', new Uint8Array([2])], + ['file:///c.jpg', new Uint8Array([3])] + ]) + const createFile = vi.fn((uri: string) => { + const bytes = bytesByUri.get(uri)! + let read = false + return { + size: bytes.length, + open: () => ({ + size: bytes.length, + readBytes: () => { + if (read) { + return new Uint8Array() + } + read = true + return bytes + }, + close: vi.fn() + }) + } + }) + const launchLibrary = vi.fn().mockResolvedValue({ + canceled: false, + assets: [...bytesByUri].map(([uri, bytes]) => ({ uri, fileSize: bytes.length })) + }) + + const result = await collectImages( + pickMobileImages('library', { + requestLibraryPermission: vi.fn().mockResolvedValue(granted), + launchLibrary, + createFile + }) + ) + + expect(result.map((image) => image.uri)).toEqual([ + 'file:///a.jpg', + 'file:///b.jpg', + 'file:///c.jpg' + ]) + expect(launchLibrary).toHaveBeenCalledWith( + expect.objectContaining({ + allowsMultipleSelection: true, + orderedSelection: true, + selectionLimit: 0 + }) + ) + }) + it('throws when photo library permission is denied', async () => { await expect( pickMobileImage('library', { diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts index bf9c529cc25..062085f7270 100644 --- a/mobile/src/session/mobile-image-source-picker.ts +++ b/mobile/src/session/mobile-image-source-picker.ts @@ -83,11 +83,12 @@ async function readUriAsBase64( } } -async function pickFromLibrary( +async function* pickFromLibrary( + multiple: boolean, requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync, launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const permission = await requestPermission() // Why: `granted` covers full + limited iOS access; only a hard denial blocks us. if (!permission.granted) { @@ -96,51 +97,84 @@ async function pickFromLibrary( const result = await launch({ mediaTypes: ['images'], base64: false, - allowsMultipleSelection: false, + allowsMultipleSelection: multiple, + ...(multiple ? { selectionLimit: 0, orderedSelection: true } : {}), quality: 1 }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null - if (!base64) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.fileSize, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) } } -async function pickFromFiles( +async function* pickFromFiles( + multiple: boolean, launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const result = await launch({ type: 'image/*', - multiple: false, + multiple, copyToCacheDirectory: true }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - if (!asset?.uri) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) - return base64 ? { base64, uri: asset.uri } : null +} + +type MobileImagePickerDeps = { + readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync + readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync + readonly launchFiles?: typeof DocumentPicker.getDocumentAsync + readonly createFile?: MobileImageFileFactory +} + +function pickMobileImagesWithMode( + source: MobileImageSource, + multiple: boolean, + deps?: MobileImagePickerDeps +): AsyncIterable { + if (source === 'library') { + return pickFromLibrary( + multiple, + deps?.requestLibraryPermission, + deps?.launchLibrary, + deps?.createFile + ) + } + return pickFromFiles(multiple, deps?.launchFiles, deps?.createFile) } export async function pickMobileImage( source: MobileImageSource, - deps?: { - readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync - readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync - readonly launchFiles?: typeof DocumentPicker.getDocumentAsync - readonly createFile?: MobileImageFileFactory - } + deps?: MobileImagePickerDeps ): Promise { - if (source === 'library') { - return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile) + for await (const image of pickMobileImagesWithMode(source, false, deps)) { + return image } - return pickFromFiles(deps?.launchFiles, deps?.createFile) + return null +} + +export function pickMobileImages( + source: MobileImageSource, + deps?: MobileImagePickerDeps +): AsyncIterable { + return pickMobileImagesWithMode(source, true, deps) } diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts new file mode 100644 index 00000000000..a817b47b3ee --- /dev/null +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -0,0 +1,64 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { detectAgentPermission } from './mobile-native-chat-permission' +import type { parseAgentQuestion } from './mobile-native-chat-question' +import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' +import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' +import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' + +export type MobileNativeChatController = { + /** Whether a tab's effective view is chat (per-tab override, else the default). */ + isTabChatView: (tabId: string) => boolean + toggleTabChatView: (tabId: string) => void + showNativeChat: boolean + showNativeChatRef: MutableRefObject + /** Resolved agent for the active chat tab (names the empty-state copy). */ + nativeChatAgent: string | null + chatComposerText: string + setChatComposerText: Dispatch> + chatPending: MobileNativeChatPendingMessage[] + chatImagePreviewsByMessageId: Record + nativeChatSession: ReturnType + nativeChatAgentWorking: boolean + nativeChatStreamingText?: string + /** Agent mid-turn, regardless of whether chat is the visible view. */ + nativeChatStreamLive: boolean + /** Host/workspace/tab/session scope for stateful streaming suppression. */ + nativeChatStreamScopeKey: string + nativeChatPermission: ReturnType + nativeChatQuestion: ReturnType + /** The pending ask, already null while dismissed (dismissal lives here so it + * survives the chat-view subtree unmounting on a view toggle). */ + nativeChatAsk: ReturnType + /** Stable key for the current ask card (keys the card component). */ + nativeChatAskKey: string | null + /** Hide the current ask until a genuinely different question arrives. */ + dismissNativeChatAsk: () => void + handleNativeChatAnswerAsk: ( + prompt: AskPrompt, + selections: AskAnswerSelection[] + ) => Promise + handleNativeChatCancelAsk: () => Promise + handleNativeChatRespondPermission: (text: string) => Promise + handleNativeChatStop: () => void + nativeChatFilePaths: string[] + loadNativeChatFiles: (query: string) => void + handleNativeChatQuestionAnswer: (text: string) => Promise + handleNativeChatSend: (text: string, images?: string[]) => Promise + /** Outcome-preserving send: callers that pasted terminal input beforehand + * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a + * caller passes its own `deadline` so the paste it already spent and this text + * body share one budget instead of holding the composer for two. */ + handleNativeChatSendWithOutcome: ( + text: string, + images?: string[], + deadline?: number + ) => Promise + /** 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 + /** Model/session-option pickers for the composer, or null when the active + * agent has no session-option catalog. */ + nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null +} diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts new file mode 100644 index 00000000000..4c0cee804c1 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + findLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, + type PendingImagePreviewEcho +} from './mobile-native-chat-draft-reconcile' + +function userText(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function pending(id: string, images: string[], expectedOccurrence = 1): PendingImagePreviewEcho { + return { id, text: '', images, expectedOccurrence, baselineTailMessageId: null } +} + +describe('mobile native chat image preview reconciliation', () => { + it('keeps separate adjacent image-only sends independently reconcilable', () => { + const landed = findLandedImagePreviewEchoes( + [ + userText('source-a', '[Image: source: /tmp/a.png]'), + userText('source-b', '[Image: source: /tmp/b.png]') + ], + [pending('pending-a', ['file:///a.jpg']), pending('pending-b', ['file:///b.jpg'], 2)] + ) + + expect(landed).toEqual([ + { pendingId: 'pending-a', messageId: 'source-a', images: ['file:///a.jpg'] }, + { pendingId: 'pending-b', messageId: 'source-b', images: ['file:///b.jpg'] } + ]) + }) + + it('waits for a complete multi-image turn as transcript source frames stream in', () => { + const entry = pending('pending', ['file:///a.jpg', 'file:///b.jpg']) + const sourceA = userText('source-a', '[Image: source: /tmp/a.png]') + const sourceB = userText('source-b', '[Image: source: /tmp/b.png]') + + expect(findLandedImagePreviewEchoes([sourceA], [entry])).toEqual([]) + expect(findLandedImagePreviewEchoes([sourceA, sourceB], [entry])).toEqual([]) + expect( + findLandedImagePreviewEchoes( + [sourceA, sourceB, userText('prompt', '[Image #1] [Image #2]')], + [entry] + ) + ).toEqual([ + { + pendingId: 'pending', + messageId: 'prompt', + images: ['file:///a.jpg', 'file:///b.jpg'] + } + ]) + }) + + it('moves an early standalone preview to the later folded prompt id', () => { + const sessionKey = 'host\0worktree\0tab\0session' + const previous = { [sessionKey]: { source: ['file:///a.jpg'] } } + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', '[Image #1]') + ] + + expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({ + [sessionKey]: { prompt: ['file:///a.jpg'] } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 1af010ff503..360fd22587b 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -1,6 +1,7 @@ -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { isImageSourceUserTurn, + normalizeImageTranscriptMessages, stripImagePromptMarker } from './mobile-native-chat-image-transcript-markers' @@ -61,6 +62,149 @@ export function countImageSourceTurnsAfter( return count } +export type PendingImagePreviewEcho = { + id: string + text: string + images?: string[] + expectedOccurrence: number + baselineTailMessageId: string | null +} + +export type LandedImagePreviewEcho = { + pendingId: string + messageId: string + images: string[] +} + +const SENT_IMAGE_PREVIEW_LIMIT = 32 +const SENT_IMAGE_PREVIEW_SESSION_LIMIT = 8 + +export function mergeLandedImagePreviewEchoes( + previous: Record>, + sessionKey: string, + landed: readonly LandedImagePreviewEcho[] +): Record> { + const entries = Object.entries(previous[sessionKey] ?? {}) + for (const preview of landed) { + const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId) + if (existingIndex >= 0) { + entries.splice(existingIndex, 1) + } + entries.push([preview.messageId, preview.images]) + } + const next = { ...previous } + delete next[sessionKey] + next[sessionKey] = Object.fromEntries(entries.slice(-SENT_IMAGE_PREVIEW_LIMIT)) + for (const key of Object.keys(next).slice(0, -SENT_IMAGE_PREVIEW_SESSION_LIMIT)) { + delete next[key] + } + return next +} + +function imagePreviewReplacementMessageId( + messages: readonly NativeChatMessage[], + sourceIndex: number +): string | null { + const source = messages[sourceIndex] + if (!source || !isImageSourceUserTurn(source)) { + return null + } + let nextIndex = sourceIndex + 1 + while ( + messages[nextIndex]?.source === source.source && + isImageSourceUserTurn(messages[nextIndex]!) + ) { + nextIndex++ + } + const prompt = messages[nextIndex] + const firstText = prompt?.blocks.find((block) => block.type === 'text') + return prompt?.role === 'user' && + prompt.source === source.source && + firstText?.type === 'text' && + stripImagePromptMarker(firstText.text) !== firstText.text + ? prompt.id + : null +} + +/** Moves previews forward when a progressive source-only transcript frame later + * folds into the marker-prefixed prompt with a different authoritative id. */ +export function migrateImagePreviewMessageIds( + previous: Record>, + sessionKey: string, + messages: readonly NativeChatMessage[] +): Record> { + const sessionPreviews = previous[sessionKey] + if (!sessionPreviews) { + return previous + } + const messageIndexById = new Map(messages.map((message, index) => [message.id, index])) + let nextSession: Record | null = null + for (const [messageId, images] of Object.entries(sessionPreviews)) { + const sourceIndex = messageIndexById.get(messageId) + if (sourceIndex === undefined) { + continue + } + const replacementId = imagePreviewReplacementMessageId(messages, sourceIndex) + if (!replacementId) { + continue + } + nextSession ??= { ...sessionPreviews } + delete nextSession[messageId] + nextSession[replacementId] = [...(nextSession[replacementId] ?? []), ...images] + } + return nextSession ? { ...previous, [sessionKey]: nextSession } : previous +} + +/** Binds local preview URIs to the authoritative transcript turn that replaced + * the optimistic bubble. Host paths and marker-only Codex turns cannot render + * the phone-local photo without this handoff. */ +export function findLandedImagePreviewEchoes( + messages: readonly NativeChatMessage[], + entries: readonly PendingImagePreviewEcho[] +): LandedImagePreviewEcho[] { + const normalized = normalizeImageTranscriptMessages(messages) + const messageIndexById = new Map(normalized.map((message, index) => [message.id, index])) + const claimedMessageIds = new Set() + const landed: LandedImagePreviewEcho[] = [] + + for (const entry of entries) { + if (!entry.images?.length) { + continue + } + const targetText = entry.text.trim() + const candidates = normalized.filter((message) => { + if (message.role !== 'user') { + return false + } + if (targetText) { + return normalizedUserText(message) === targetText + } + const imageCount = message.blocks.filter(isImageRefBlock).length + return message.blocks.length === 0 || imageCount >= entry.images!.length + }) + const tailIndex = entry.baselineTailMessageId + ? messageIndexById.get(entry.baselineTailMessageId) + : -1 + const occurrenceIndex = Math.max(0, entry.expectedOccurrence - 1) + const candidate = targetText + ? candidates[occurrenceIndex] + : candidates.filter( + (message) => + tailIndex === undefined || (messageIndexById.get(message.id) ?? -1) > tailIndex + )[occurrenceIndex] + if ( + !candidate || + claimedMessageIds.has(candidate.id) || + (tailIndex !== undefined && (messageIndexById.get(candidate.id) ?? -1) <= tailIndex) + ) { + continue + } + claimedMessageIds.add(candidate.id) + landed.push({ pendingId: entry.id, messageId: candidate.id, images: entry.images }) + } + return landed +} + export function findLandedUnconfirmedSends( messages: readonly NativeChatMessage[], entries: readonly UnconfirmedSend[] diff --git a/mobile/src/session/mobile-native-chat-image-attachment.test.ts b/mobile/src/session/mobile-native-chat-image-attachment.test.ts index 57c3f67bd1c..e61116f3caa 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.test.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' -import { uploadMobileNativeChatImage } from './mobile-native-chat-image-attachment' +import { uploadMobileNativeChatImages } from './mobile-native-chat-image-attachment' function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } } @@ -16,6 +16,10 @@ function methodNotFound(id: string): RpcResponse { } } +function failed(id: string, message: string): RpcResponse { + return { id, ok: false, error: { code: 'failed', message }, _meta: { runtimeId: 'r' } } +} + function clientWithResponses(responses: RpcResponse[]): Pick & { calls: { method: string; params: unknown }[] } { @@ -33,67 +37,137 @@ function clientWithResponses(responses: RpcResponse[]): Pick { +describe('uploadMobileNativeChatImages', () => { it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => { const client = clientWithResponses([ methodNotFound('start'), ok('save', '/tmp/orca-attach.png') ]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => 'conn-7', - pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA', uri: 'file:///photo.jpg' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'AAAA', uri: 'file:///photo.jpg' }]) }) - expect(result).toEqual({ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }) + expect(result).toEqual([{ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }]) // Native chat defers the paste to submit — nothing is sent to the terminal here. expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile') expect(saveCall?.params).toMatchObject({ connectionId: 'conn-7' }) }) + it('uploads all three selected images in picker order', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + ok('save-b', '/tmp/b.png'), + methodNotFound('start-c'), + ok('save-c', '/tmp/c.png') + ]) + + const order: string[] = [] + async function* pickImages() { + for (const image of [ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' }, + { base64: 'CCCC', uri: 'file:///c.jpg' } + ]) { + order.push(`read:${image.uri}`) + yield image + } + } + const result = await uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => 'conn-7', + pickImages, + onImageUploaded: (image) => order.push(`uploaded:${image.previewUri}`) + }) + + expect(result).toEqual([ + { path: '/tmp/a.png', previewUri: 'file:///a.jpg' }, + { path: '/tmp/b.png', previewUri: 'file:///b.jpg' }, + { path: '/tmp/c.png', previewUri: 'file:///c.jpg' } + ]) + expect(order).toEqual([ + 'read:file:///a.jpg', + 'uploaded:file:///a.jpg', + 'read:file:///b.jpg', + 'uploaded:file:///b.jpg', + 'read:file:///c.jpg', + 'uploaded:file:///c.jpg' + ]) + }) + it('returns null when the picker is cancelled and uploads nothing', async () => { const client = clientWithResponses([]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null) + pickImages: vi.fn().mockResolvedValue([]) }) - expect(result).toBeNull() + expect(result).toEqual([]) expect(client.calls).toEqual([]) }) + it('reports completed uploads before a later image fails', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + failed('save-b', 'upload failed') + ]) + const onImageUploaded = vi.fn() + + await expect( + uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => null, + pickImages: vi.fn().mockResolvedValue([ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' } + ]), + onImageUploaded + }) + ).rejects.toThrow('upload failed') + expect(onImageUploaded).toHaveBeenCalledOnce() + expect(onImageUploaded).toHaveBeenCalledWith({ + path: '/tmp/a.png', + previewUri: 'file:///a.jpg' + }) + }) + it('falls back to an inline data uri for the preview when the picker omits a uri', async () => { const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/x.png')]) - const result = await uploadMobileNativeChatImage('files', { + const result = await uploadMobileNativeChatImages('files', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'BBBB' }]) }) - expect(result).toEqual({ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }) + expect(result).toEqual([{ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }]) }) it('signals upload start only after a real image is picked', async () => { const onUploadStart = vi.fn() const cancelledClient = clientWithResponses([]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client: cancelledClient, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null), + pickImages: vi.fn().mockResolvedValue([]), onUploadStart }) expect(onUploadStart).not.toHaveBeenCalled() const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/y.png')]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC', uri: 'file:///y.jpg' }), + pickImages: vi.fn().mockResolvedValue([{ base64: 'CCCC', uri: 'file:///y.jpg' }]), onUploadStart }) expect(onUploadStart).toHaveBeenCalledTimes(1) diff --git a/mobile/src/session/mobile-native-chat-image-attachment.ts b/mobile/src/session/mobile-native-chat-image-attachment.ts index f81b9d2ad98..fc9a962bacf 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.ts @@ -13,34 +13,67 @@ export type PendingNativeChatImage = { readonly previewUri: string } -export type UploadNativeChatImageDeps = { +export function appendPendingNativeChatImages( + current: readonly PendingNativeChatImage[], + uploaded: readonly Omit[], + idCounter: { current: number } +): PendingNativeChatImage[] { + return [ + ...current, + ...uploaded.map((image) => { + idCounter.current += 1 + return { id: `img-${idCounter.current}`, ...image } + }) + ] +} + +export type UploadNativeChatImagesDeps = { readonly client: Pick readonly getConnectionId: () => Promise // Injected so this module stays free of expo/react-native imports (unit-testable). - readonly pickImage: (source: MobileImageSource) => Promise + readonly pickImages: ( + source: MobileImageSource + ) => + | Iterable + | AsyncIterable + | Promise | AsyncIterable> // Fired once the user has picked an image and the host upload is about to start — // lets the UI show the attach spinner only for the transfer, not the picker. readonly onUploadStart?: () => void + /** Retains each completed upload if a later image in the same selection fails. */ + readonly onImageUploaded?: (image: Omit) => void } /** Picks an image and uploads it to the host, returning the host path + a local * preview URI — but does NOT paste it into the terminal. Unlike the terminal * attach flow, native chat holds the image as a composer chip and rides it along * on submit (desktop parity), so the chip and the agent input never diverge. - * Returns null when the user cancels the picker. */ -export async function uploadMobileNativeChatImage( + * Returns an empty array when the user cancels the picker. */ +export async function uploadMobileNativeChatImages( source: MobileImageSource, - { client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps -): Promise | null> { - const picked = await pickImage(source) - if (!picked) { - return null + { + client, + getConnectionId, + pickImages, + onUploadStart, + onImageUploaded + }: UploadNativeChatImagesDeps +): Promise[]> { + const picked = await pickImages(source) + const uploaded: Omit[] = [] + let connectionId: string | null = null + for await (const image of picked) { + if (uploaded.length === 0) { + onUploadStart?.() + connectionId = await getConnectionId() + } + const path = await saveMobileClipboardImageAsTempFile(client, image.base64, { connectionId }) + // Prefer the picker's local URI for the thumbnail; fall back to an inline data + // URI when the source omitted one (RN renders both). + const previewUri = image.uri ?? `data:image/png;base64,${image.base64}` + const result = { path, previewUri } + uploaded.push(result) + onImageUploaded?.(result) } - onUploadStart?.() - const connectionId = await getConnectionId() - const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId }) - // Prefer the picker's local URI for the thumbnail; fall back to an inline data - // URI when the source omitted one (RN renders both). - const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}` - return { path, previewUri } + return uploaded } diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index 46ac4730f2e..cf1c59adf0f 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -27,17 +27,22 @@ function clientWithResponses(responses: RpcResponse[]): Pick { it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => { - const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)]) + const client = clientWithResponses([ + sendResult(true), + sendResult(true), + sendResult(true), + sendResult(true) + ]) const ok = await pasteMobileNativeChatImagePaths({ client, terminal: 'term-1', deviceToken: 'device-9', - imagePaths: ['/tmp/a.png', '/tmp/b.png'] + imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'] }) expect(ok).toBe(true) - expect(client.calls).toHaveLength(3) + expect(client.calls).toHaveLength(4) // Leading Ctrl+U clears any stale input so a retry can't duplicate the image. expect(client.calls[0]).toEqual({ method: 'terminal.send', @@ -50,6 +55,7 @@ describe('pasteMobileNativeChatImagePaths', () => { }) expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~') expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~') + expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~') }) it('stops and reports failure as soon as a paste is rejected', async () => { diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts new file mode 100644 index 00000000000..b18136a9d47 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -0,0 +1,91 @@ +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number + /** Local preview URIs carried by the send for its optimistic echo. */ + images?: string[] + baselineTailMessageId: string | null +} + +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number + baselineTailMessageId: string | null +} + +type PendingByKey = Record + +export function combineMobileNativeChatPending( + session: MobileNativeChatPendingMessage[], + waiting: readonly MobileNativeChatPendingMessage[] +): MobileNativeChatPendingMessage[] { + if (waiting.length === 0) { + return session + } + const sessionIds = new Set(session.map((item) => item.id)) + return [...session, ...waiting.filter((item) => !sessionIds.has(item.id))] +} + +export function appendMobileNativeChatPending( + previous: PendingByKey, + key: string, + id: string, + origin: MobileNativeChatSendOrigin, + text: string, + images?: string[] +): PendingByKey { + const current = previous[key] ?? [] + const earlierOutstanding = current.filter( + (pending) => + pending.text.trim() === origin.normalizedText && + pending.expectedOccurrence > origin.baselineOccurrences + ).length + const expectedImageEchoOrdinal = + current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1 + return { + ...previous, + [key]: [ + ...current, + { + id, + text, + expectedOccurrence: + origin.normalizedText === '' + ? expectedImageEchoOrdinal + : origin.baselineOccurrences + earlierOutstanding + 1, + baselineTailMessageId: origin.baselineTailMessageId, + ...(images?.length ? { images } : {}) + } + ] + } +} + +export function mergeWaitingSessionPending( + previous: PendingByKey, + sessionKey: string, + waiting: readonly MobileNativeChatPendingMessage[] +): PendingByKey { + const current = previous[sessionKey] ?? [] + const currentIds = new Set(current.map((item) => item.id)) + const moved = waiting.filter((item) => !currentIds.has(item.id)) + return moved.length > 0 ? { ...previous, [sessionKey]: [...current, ...moved] } : previous +} + +export function removeWaitingSessionPending( + previous: PendingByKey, + draftKey: string, + movedIds: ReadonlySet +): PendingByKey { + const remaining = (previous[draftKey] ?? []).filter((item) => !movedIds.has(item.id)) + if (remaining.length > 0) { + return { ...previous, [draftKey]: remaining } + } + if (!(draftKey in previous)) { + return previous + } + const next = { ...previous } + delete next[draftKey] + return next +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index b4a273d31ca..c67257ec24d 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -116,6 +116,36 @@ describe('buildMobileNativeChatTransientData', () => { expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) }) + it('keeps the phone-local image visible when the transcript replaces its optimistic echo', () => { + const folded = foldMobileNativeChatMessages([ + user('source', '[Image: source: /tmp/a.png]'), + user('prompt', '[Image #1] look at this') + ]) + const result = buildMobileNativeChatTransientData({ + folded, + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data).toHaveLength(1) + expect(result.data[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' }, + { type: 'text', text: 'look at this' } + ]) + }) + + it('restores the local preview onto a marker-only transcript turn', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }]) + }) + it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { // Whether text streams at all is the gate's call // (`mobile-native-chat-streaming-gate.test.ts`); this only places it. diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index e11b9ac56f4..b3d4ba1c0fd 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -3,7 +3,7 @@ import { formatNativeChatEmptyStateCopy, type NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' import { stripNoiseMessages } from './mobile-native-chat-noise' @@ -55,15 +55,37 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat export function buildMobileNativeChatTransientData({ folded, streaming, - pending + pending, + imagePreviewsByMessageId }: { folded: NativeChatMessage[] /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ streaming: string | null pending: MobileNativeChatPendingItem[] + imagePreviewsByMessageId?: Record }): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const renderedFolded = folded.map((message) => { + const previews = imagePreviewsByMessageId?.[message.id] + if (message.role !== 'user' || !previews?.length) { + return message + } + let previewIndex = 0 + const blocks = message.blocks.map((block) => { + if (!isImageRefBlock(block)) { + return block + } + const url = previews[previewIndex] + previewIndex += 1 + return url ? { ...block, url } : block + }) + while (previewIndex < previews.length) { + blocks.push({ type: 'image-ref', url: previews[previewIndex] }) + previewIndex += 1 + } + return { ...message, blocks } + }) const data: NativeChatMessage[] = [ - ...folded, + ...renderedFolded, ...(streaming ? [ { @@ -88,5 +110,5 @@ export function buildMobileNativeChatTransientData({ source: 'transcript' as const })) ] - return { folded, streaming, data } + return { folded: renderedFolded, streaming, data } } diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index 56b9fac7aa4..d9126475bc3 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -81,7 +81,7 @@ describe('mobile session route', () => { name: 'Fix #1' }) - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', target) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index a1ed942316f..a9cd1201639 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -40,6 +40,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ composerText: '', setComposerText: vi.fn(), pending: [], + imagePreviewsByMessageId: {}, captureSendOrigin, readSeededLaunchDraft: () => null, readSeededLaunchDraftSeed: () => null, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 49d75409c05..46012dadbe4 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,99 +1,29 @@ -import { - useCallback, - useLayoutEffect, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction -} from 'react' +import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react' +import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import { useMobileSessionViewMode } from './use-mobile-session-view-mode' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' -import { - parseAskFromStatus, - type AskAnswerSelection, - type AskPrompt -} from './mobile-native-chat-ask' import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' -import { detectAgentPermission } from './mobile-native-chat-permission' -import { parseAgentQuestion } from './mobile-native-chat-question' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' -import { - useMobileNativeChatDrafts, - type MobileNativeChatPendingMessage -} from './use-mobile-native-chat-drafts' +import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatSession } from './use-mobile-native-chat-session' import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options' -import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' import { useThrottledLatestValue } from './use-throttled-latest-value' +import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' + +export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 -export type MobileNativeChatController = { - /** Whether a tab's effective view is chat (per-tab override, else the default). */ - isTabChatView: (tabId: string) => boolean - toggleTabChatView: (tabId: string) => void - showNativeChat: boolean - showNativeChatRef: MutableRefObject - /** Resolved agent for the active chat tab (names the empty-state copy). */ - nativeChatAgent: string | null - chatComposerText: string - setChatComposerText: Dispatch> - chatPending: MobileNativeChatPendingMessage[] - nativeChatSession: ReturnType - nativeChatAgentWorking: boolean - nativeChatStreamingText?: string - /** Agent mid-turn, regardless of whether chat is the visible view. */ - nativeChatStreamLive: boolean - /** Host/workspace/tab/session scope for stateful streaming suppression. */ - nativeChatStreamScopeKey: string - nativeChatPermission: ReturnType - nativeChatQuestion: ReturnType - /** The pending ask, already null while dismissed (dismissal lives here so it - * survives the chat-view subtree unmounting on a view toggle). */ - nativeChatAsk: ReturnType - /** Stable key for the current ask card (keys the card component). */ - nativeChatAskKey: string | null - /** Hide the current ask until a genuinely different question arrives. */ - dismissNativeChatAsk: () => void - handleNativeChatAnswerAsk: ( - prompt: AskPrompt, - selections: AskAnswerSelection[] - ) => Promise - handleNativeChatCancelAsk: () => Promise - handleNativeChatRespondPermission: (text: string) => Promise - handleNativeChatStop: () => void - nativeChatFilePaths: string[] - loadNativeChatFiles: (query: string) => void - handleNativeChatQuestionAnswer: (text: string) => Promise - handleNativeChatSend: (text: string, images?: string[]) => Promise - /** Outcome-preserving send: callers that pasted terminal input beforehand - * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a - * caller passes its own `deadline` so the paste it already spent and this text - * body share one budget instead of holding the composer for two. */ - handleNativeChatSendWithOutcome: ( - text: string, - images?: string[], - deadline?: number - ) => Promise - /** 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 - /** Model/session-option pickers for the composer, or null when the active - * agent has no session-option catalog. */ - nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null -} - /** Owns mobile native-chat state and teardown outside the already dense session * route. The route remains responsible only for choosing and rendering the view. */ export function useMobileNativeChatController(args: { @@ -152,6 +82,7 @@ export function useMobileNativeChatController(args: { const nativeChatSession = useMobileNativeChatSession({ client, + sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]), agent: activeChatResolution?.agent ?? null, sessionId: activeChatSessionId, transcriptPath: activeChatResolution?.transcriptPath ?? null @@ -160,6 +91,7 @@ export function useMobileNativeChatController(args: { composerText: chatComposerText, setComposerText: setChatComposerText, pending: chatPending, + imagePreviewsByMessageId: chatImagePreviewsByMessageId, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, @@ -201,7 +133,8 @@ export function useMobileNativeChatController(args: { } = useMobileNativeChatPrompts({ enabled: activeChatResolution != null, status: nativeChatStatus, - messages: nativeChatSession.messages + messages: nativeChatSession.messages, + transcriptLoading: nativeChatSession.transcriptLoading }) // A never-read transcript cannot prove that a dismissed prompt cleared. const nativeChatTranscriptSettled = @@ -326,6 +259,7 @@ export function useMobileNativeChatController(args: { chatComposerText, setChatComposerText, chatPending, + chatImagePreviewsByMessageId, nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index bf3f94bd239..154ef3fb870 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -269,6 +269,7 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it("keeps an image-only echo when an unrelated text send's echo lands", async () => { @@ -345,6 +346,29 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] }) + }) + + it('hands a marker-only image preview to the authoritative user bubble', async () => { + await mount('a') + const origin = state?.captureSendOrigin('') + act(() => { + if (origin) { + state?.acceptSend(origin, '', ['file:///a.jpg']) + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('u1', '[Image #1]')] + }) + ) + ) + + expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it('does not reconcile a repeated send against an older identical turn', async () => { @@ -722,21 +746,59 @@ describe('useMobileNativeChatDrafts', () => { } }) - it('accepts and clears the first send before a provider session id exists', async () => { + it('preserves first-send images through session assignment and transcript replacement', async () => { await mount('a') await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) - act(() => state?.setComposerText('start the session')) + const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + act(() => state?.setComposerText('look')) - const origin = state?.captureSendOrigin('start the session') + const origin = state?.captureSendOrigin('look') expect(origin).toMatchObject({ pendingKey: null }) act(() => { if (origin) { - state?.clearDraftForSend(origin, 'start the session') - state?.acceptSend(origin, 'start the session') + state?.clearDraftForSend(origin, 'look') + state?.acceptSend(origin, 'look', images) } }) expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', sessionId: 'assigned' })) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]') + ] + }) + ) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]'), + userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look') + ] + }) + ) + ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images }) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a43bc8b286f..9da4dea6a9b 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -3,35 +3,29 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, countUserTextOccurrences, + findLandedImagePreviewEchoes, findLandedUnconfirmedSends, + mergeLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, normalizedUserText, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' +import { + appendMobileNativeChatPending, + combineMobileNativeChatPending, + mergeWaitingSessionPending, + removeWaitingSessionPending, + type MobileNativeChatPendingMessage, + type MobileNativeChatSendOrigin +} from './mobile-native-chat-pending-echo' 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 - text: string - expectedOccurrence: number - /** Local preview URIs of images ridden along on the send, rendered as thumbnails - * on the echo bubble so the sent photo shows before the transcript catches up. */ - images?: string[] - /** Transcript tail when sent — an image-only echo (no text to match) reconciles - * against new `[Image: source: …]` echo turns after this id, so pagination, - * agent replies, and unrelated text echoes can't clear it early. */ - baselineTailMessageId: string | null -} -export type MobileNativeChatSendOrigin = { - draftKey: string - pendingKey: string | null - normalizedText: string - baselineOccurrences: number - baselineTailMessageId: string | null -} +export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] +const NO_IMAGE_PREVIEWS: Record = {} // How long an ack-lost send waits for its transcript echo before the UI surfaces // that delivery remains unconfirmed. @@ -57,6 +51,9 @@ export function useMobileNativeChatDrafts(args: { composerText: string setComposerText: Dispatch> pending: MobileNativeChatPendingMessage[] + /** Phone-local previews rebound to the transcript message that replaced the + * optimistic echo, keyed by authoritative message id. */ + imagePreviewsByMessageId: Record 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 @@ -91,6 +88,12 @@ export function useMobileNativeChatDrafts(args: { const [pendingBySession, setPendingBySession] = useState< Record >({}) + const [pendingWaitingForSession, setPendingWaitingForSession] = useState< + Record + >({}) + const [imagePreviewsBySession, setImagePreviewsBySession] = useState< + Record> + >({}) const pendingCounterRef = useRef(0) const messagesRef = useRef(messages) messagesRef.current = messages @@ -162,41 +165,21 @@ export function useMobileNativeChatDrafts(args: { const acceptSend = useCallback( (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { - // Why: the first prompt can be sent before the provider reports a session - // id; wait for an id before keying an optimistic echo. - if (!origin.pendingKey) { + if (!origin.pendingKey && !images?.length) { return } - const pendingKey = origin.pendingKey pendingCounterRef.current += 1 - setPendingBySession((previous) => { - const current = previous[pendingKey] ?? NO_PENDING_MESSAGES - const earlierOutstanding = current.filter( - (pending) => - pending.text.trim() === origin.normalizedText && - pending.expectedOccurrence > origin.baselineOccurrences - ).length - // An empty-text send reconciles by image-echo ordinal: every outstanding - // send's ridden-along images echo as `[Image: source: …]` turns after - // this send's baseline tail, ahead of this send's own echo. - const expectedImageEchoOrdinal = - current.reduce( - (sum, pending) => - sum + (pending.images?.length ?? (pending.text.trim() === '' ? 1 : 0)), - 0 - ) + 1 - const pending: MobileNativeChatPendingMessage = { - id: `pending-${pendingCounterRef.current}`, - text, - expectedOccurrence: - origin.normalizedText === '' - ? expectedImageEchoOrdinal - : origin.baselineOccurrences + earlierOutstanding + 1, - baselineTailMessageId: origin.baselineTailMessageId, - ...(images && images.length > 0 ? { images } : {}) - } - return { ...previous, [pendingKey]: [...current, pending] } - }) + const id = `pending-${pendingCounterRef.current}` + const key = origin.pendingKey + if (key) { + setPendingBySession((previous) => + appendMobileNativeChatPending(previous, key, id, origin, text, images) + ) + } else { + setPendingWaitingForSession((previous) => + appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) + ) + } }, [] ) @@ -255,9 +238,7 @@ export function useMobileNativeChatDrafts(args: { const landedSet = new Set(landed) unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) for (const entry of landed) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } }, [messages, draftKey, pendingKey]) @@ -266,21 +247,49 @@ export function useMobileNativeChatDrafts(args: { return () => { mountedRef.current = false for (const entry of unconfirmedRef.current) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } unconfirmedRef.current = [] } }, []) - const pending = pendingKey - ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + const waitingForSession = draftKey + ? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES useEffect(() => { - if (!pendingKey || pending.length === 0) { + if (!draftKey || !pendingKey || waitingForSession.length === 0) { return } + const movedIds = new Set(waitingForSession.map((item) => item.id)) + setPendingBySession((previous) => + mergeWaitingSessionPending(previous, pendingKey, waitingForSession) + ) + setPendingWaitingForSession((previous) => + removeWaitingSessionPending(previous, draftKey, movedIds) + ) + }, [draftKey, pendingKey, waitingForSession]) + + const sessionPending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + const pending = combineMobileNativeChatPending(sessionPending, waitingForSession) + useEffect(() => { + if (!pendingKey) { + return + } + setImagePreviewsBySession((previous) => + migrateImagePreviewMessageIds(previous, pendingKey, messages) + ) + if (pending.length === 0) { + return + } + const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending) + const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId)) + if (landedImagePreviews.length > 0) { + setImagePreviewsBySession((previous) => + mergeLandedImagePreviewEchoes(previous, pendingKey, landedImagePreviews) + ) + } setPendingBySession((previous) => { const current = previous[pendingKey] ?? [] const landedCounts = new Map() @@ -297,12 +306,20 @@ export function useMobileNativeChatDrafts(args: { // baseline tail — text echoes are excluded so an unrelated outstanding // text send cannot clear it. Ordinal-vs-count stays stable when the effect // re-runs on the shrunken list, and ignores paginated-in history. - const next = current.filter((item) => - item.text.trim() === '' + const next = current.filter((item) => { + if (landedImagePendingIds.has(item.id)) { + return false + } + // Image echoes hand their local URIs to the authoritative message above; + // never drop them through the text-only fallback before that handoff. + if (item.images?.length) { + return true + } + return item.text.trim() === '' ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < - item.expectedOccurrence + item.expectedOccurrence : (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence - ) + }) if (next.length === current.length) { return previous } @@ -319,6 +336,9 @@ export function useMobileNativeChatDrafts(args: { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, pending, + imagePreviewsByMessageId: pendingKey + ? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS) + : NO_IMAGE_PREVIEWS, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 13cb5d96c48..a57e1c7520c 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -11,13 +11,13 @@ import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-im // Fully stub the picker so the real expo/react-native chain never loads under // the vitest transform (react-native ships Flow syntax rolldown can't parse). vi.mock('./mobile-image-source-picker', () => ({ - pickMobileImage: vi.fn(), + pickMobileImages: vi.fn(), ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {} })) -import { pickMobileImage } from './mobile-image-source-picker' +import { pickMobileImages } from './mobile-image-source-picker' -const pick = vi.mocked(pickMobileImage) +const pick = vi.mocked(pickMobileImages) function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'r' } } @@ -124,7 +124,7 @@ describe('useMobileNativeChatImageAttachments', () => { } it('adds an uploaded image as a chip without pasting to the terminal', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) mount( baseArgs({ @@ -145,7 +145,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('rides pending images along on send: pastes the path, settles, then delegates the text', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -208,7 +208,7 @@ describe('useMobileNativeChatImageAttachments', () => { 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' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -242,7 +242,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('spends one budget across the image paste and the text body that follows', async () => { vi.useFakeTimers() try { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -280,7 +280,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -321,7 +321,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and does not submit when the image paste is rejected', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -345,7 +345,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and reports failure when the paste transport throws', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) // No terminal.send responses queued: the clear write throws (dropped transport). const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') @@ -365,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const onSendError = vi.fn() @@ -387,7 +387,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('scopes chips to the tab that attached them', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const args = baseArgs({ client: client as unknown as RpcClient, baseSend }) @@ -426,7 +426,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let firstAttach: Promise | null = null await act(async () => { firstAttach = hook!.attachImage('library') @@ -438,7 +438,7 @@ describe('useMobileNativeChatImageAttachments', () => { expect(hook!.isAttaching).toBe(true) // A concurrent cancelled pick — its finally must leave the counter alone. - pick.mockResolvedValue(null) + pick.mockResolvedValue([]) await act(async () => { await hook!.attachImage('library') }) @@ -454,7 +454,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('clears only the chips that were sent, keeping one attached mid-send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), // first attach @@ -488,7 +488,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(releaseSettle).not.toBeNull() - pick.mockResolvedValue({ base64: 'BBBB', uri: 'file:///b.jpg' }) + pick.mockResolvedValue([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) await act(async () => { await hook!.attachImage('library') }) @@ -511,7 +511,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('aborts the send when the active terminal changes during the settle window', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -560,7 +560,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('leads the next text-only send with Ctrl+U after a failed paste, even with the chip removed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -596,7 +596,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -634,7 +634,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('still heals after the session screen unmounts and remounts (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -688,7 +688,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('retains the stale marker when a rejected healing clear blocks text-only send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -730,7 +730,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('does not reroute text when the active terminal changes during a healing clear', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let releaseClear: ((response: RpcResponse) => void) | null = null const deferredClear = new Promise((resolve) => { releaseClear = resolve @@ -774,7 +774,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('defers the heal instead of burning a rejected clear while the lease is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -818,8 +818,8 @@ describe('useMobileNativeChatImageAttachments', () => { it('heals rejected image submits independently across terminals', async () => { pick - .mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' }) - .mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' }) + .mockResolvedValueOnce([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) + .mockResolvedValueOnce([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) const client = makeClient([ methodNotFound('start-a'), ok('save-a', '/tmp/a.png'), @@ -896,7 +896,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let attach: Promise | null = null await act(async () => { attach = hook!.attachImage('library') diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 38f9e462e46..dbcb97df527 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -5,11 +5,12 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { ImageLibraryPermissionError, - pickMobileImage, + pickMobileImages, type MobileImageSource } from './mobile-image-source-picker' import { - uploadMobileNativeChatImage, + appendPendingNativeChatImages, + uploadMobileNativeChatImages, type PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { @@ -82,10 +83,6 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - const NO_ATTACHMENTS: PendingNativeChatImage[] = [] function withScopeAttachments( @@ -146,48 +143,53 @@ export function useMobileNativeChatImageAttachments({ // pick or pre-upload error never ran `onUploadStart`, so decrementing the // shared counter would clear a concurrent upload's in-flight flag early. let started = false + const uploadedImages: Omit[] = [] + let uploadError: unknown = null try { - const uploaded = await uploadMobileNativeChatImage(source, { + await uploadMobileNativeChatImages(source, { client, getConnectionId: getActiveWorktreeConnectionId, - pickImage: pickMobileImage, + pickImages: pickMobileImages, + onImageUploaded: (image) => uploadedImages.push(image), onUploadStart: () => { started = true attachingCount.current += 1 setIsAttaching(true) } }) - // Cancelled picker: no error, no toast. - if (!uploaded) { - return - } - idCounter.current += 1 - const chip = { id: `img-${idCounter.current}`, ...uploaded } - setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] })) - onAttachSuccess?.() } catch (error) { + uploadError = error + } finally { + if (started) { + attachingCount.current -= 1 + if (attachingCount.current === 0) { + setIsAttaching(false) + } + } + } + if (uploadedImages.length > 0) { + setAttachmentsByScope((prev) => ({ + ...prev, + [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) + })) + onAttachSuccess?.() + } + if (uploadError !== null) { + const message = uploadError instanceof Error ? uploadError.message : String(uploadError) onError?.() if (connStateRef.current !== 'connected') { showToast('Attach failed (disconnected)', 1500) return } - if (error instanceof ImageLibraryPermissionError) { + if (uploadError instanceof ImageLibraryPermissionError) { showToast('Photo permission denied', 1500) return } - if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { + if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { showToast('Image too large to attach', 1500) return } showToast('Attach failed', 1500) - } finally { - if (started) { - attachingCount.current -= 1 - if (attachingCount.current <= 0) { - attachingCount.current = 0 - setIsAttaching(false) - } - } } }, [ diff --git a/mobile/src/session/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/use-mobile-native-chat-prompts.test.ts index f8a1d2a61e6..905b92155e6 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.test.ts @@ -15,14 +15,16 @@ const ASK = JSON.stringify({ function promptsFor( status: Partial | null, - messages: NativeChatMessage[] = [] + messages: NativeChatMessage[] = [], + transcriptLoading = false ): ReturnType { let captured: ReturnType | undefined function Probe(): null { captured = useMobileNativeChatPrompts({ enabled: true, status: status as AgentStatusEntry | null, - messages + messages, + transcriptLoading }) return null } @@ -118,6 +120,18 @@ describe('useMobileNativeChatPrompts ask state gate', () => { expect(promptsFor(null, askMessages).ask).not.toBeNull() }) + it('withholds retained transcript asks while the replacement read is unsettled', () => { + const prompts = promptsFor({ state: 'done' }, askMessages, true) + expect(prompts.ask).toBeNull() + expect(prompts.detectedAsk).toBeNull() + }) + + it('keeps a paused live status ask authoritative while the read is unsettled', () => { + const prompts = promptsFor({ state: 'waiting', interactivePrompt: ASK }, askMessages, true) + expect(prompts.ask).toMatchObject({ questions: [{ question: 'Which path?' }] }) + expect(prompts.detectedAsk).not.toBeNull() + }) + it('does not leak a paused-out sticky status prompt through the transcript fallback', () => { // The post-answer window: the status still carries the prompt while flipping // to `working`, and the transcript's tool-result row has not landed yet, so diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 717bd505aea..61b831dafd2 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' -import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask' +import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask' import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' @@ -17,8 +17,11 @@ export function useMobileNativeChatPrompts(args: { enabled: boolean status: AgentStatusEntry | null | undefined messages: readonly NativeChatMessage[] + /** True while `messages` is an unsettled read (including the cached list held + * across a reconnect). Required: an ask derived from it may already be answered. */ + transcriptLoading: boolean }): MobileNativeChatPrompts { - const { enabled, status, messages } = args + const { enabled, status, messages, transcriptLoading } = args const blocked = status?.state === 'waiting' || status?.state === 'blocked' // Both permission paths sit inside the paused gate: an approval envelope can // outlive its answer (the host keeps it sticky), so only a waiting/blocked @@ -38,11 +41,16 @@ export function useMobileNativeChatPrompts(args: { () => parseAskFromStatus(status?.interactivePrompt, status?.toolName), [status?.interactivePrompt, status?.toolName] ) - const askFromMessages = useMemo( - () => (askFromStatus ? null : extractPendingAsk(messages)), - [askFromStatus, messages] + const resolvedAsk = useMemo( + () => + resolveNativeChatAsk({ + liveAsk: askFromStatus, + messages, + transcriptSettled: !transcriptLoading + }), + [askFromStatus, transcriptLoading, messages] ) - + const askFromMessages = askFromStatus ? null : resolvedAsk const detectedAsk = askFromStatus ?? askFromMessages return { diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 844c311b827..8c685d00202 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -36,6 +36,7 @@ describe('useMobileNativeChatSession', () => { function Harness({ client }: { client: RpcClient | null }): null { state = useMobileNativeChatSession({ client, + sourceIdentity: 'host-a\0workspace-a', agent: 'claude', sessionId: 'session', transcriptPath: null @@ -456,14 +457,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => { function Harness({ client, sessionId, - agent = 'claude' + agent = 'claude', + sourceIdentity = 'host-a\0workspace-a' }: { client: RpcClient | null sessionId: string | null agent?: string | null + sourceIdentity?: string }): null { const session = useMobileNativeChatSession({ client, + sourceIdentity, agent, sessionId, transcriptPath: null @@ -506,7 +510,8 @@ describe('useMobileNativeChatSession transcriptLoading', () => { it('re-reads instead of resurfacing a settled read when the same identity returns', async () => { // Leaving chat view nulls the agent, then returning restores the identity a // settled read already matched — but its list was cleared, so trusting it - // would report 'ready' over an empty transcript. + // would report 'ready' over an empty transcript. The last settled list for + // this identity keeps rendering while the re-read is in flight. const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) return () => {} @@ -524,12 +529,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => { renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' })) ) - expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + expect(renders[0]).toMatchObject({ + status: 'loading', + transcriptLoading: true, + ids: ['a-1'] + }) }) - it('re-reads instead of resurfacing a settled read after a reconnect', async () => { - // A reconnect swaps the client without moving the identity; the effect - // re-subscribes and clears the list, so the old outcome must not stand. + it('keeps the last settled list rendered while a swapped client re-reads', async () => { + // A manual-retry reconnect swaps the client without moving the identity; the + // old outcome must not stand ('loading', not 'ready'), but the cached + // transcript keeps rendering instead of collapsing to a full-screen spinner. const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false }) return () => {} @@ -538,13 +548,61 @@ describe('useMobileNativeChatSession transcriptLoading', () => { await mountAt(client, 'session-a') expect(renders.at(-1)).toMatchObject({ status: 'ready' }) - const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + let emitFresh: (frame: unknown) => void = () => {} + const reconnected = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + emitFresh = onData + return () => {} + }) + } as unknown as RpcClient renders.length = 0 await act(async () => renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' })) ) - expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] }) + expect(renders[0]).toMatchObject({ + status: 'loading', + transcriptLoading: true, + ids: ['a-1'] + }) + // Every commit of the window, not just the first: the re-subscribe lands a + // commit after it, so clearing the cache there blanks the transcript the + // user actually sees while leaving a first-frame assertion green. + expect([...new Set(renders.map((entry) => entry.ids.join(',')))]).toEqual(['a-1']) + + // The fresh client's snapshot supersedes the held list. + await act(async () => + emitFresh({ type: 'snapshot', messages: [message('a-1'), message('a-2')], hasMore: false }) + ) + expect(renders.at(-1)).toMatchObject({ + status: 'ready', + transcriptLoading: false, + ids: ['a-1', 'a-2'] + }) + }) + + it('never holds a cached list across a host/workspace source change', async () => { + const firstClient = { + subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { + onData({ type: 'snapshot', messages: [message('source-a')], hasMore: false }) + return () => {} + }) + } as unknown as RpcClient + await mountAt(firstClient, 'session-a') + + const secondClient = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + renders.length = 0 + await act(async () => + renderer?.update( + createElement(Harness, { + client: secondClient, + sessionId: 'session-a', + sourceIdentity: 'host-b\0workspace-b' + }) + ) + ) + + expect(renders[0]).toMatchObject({ status: 'loading', ids: [] }) }) it('never hands out the previous session’s messages under the new session id', async () => { diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 56db7def584..6305d0418d9 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -1,4 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { + createNativeChatTranscriptRetention, + encodeNativeChatTranscriptIdentity +} from '../../../src/shared/native-chat-transcript-retention' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe' import type { RpcClient } from '../transport/rpc-client' @@ -28,9 +32,6 @@ export type MobileNativeChatSession = { loadEarlier: () => void } -// Stable empty reference so a not-yet-current read doesn't churn consumers. -const EMPTY_MESSAGES: NativeChatMessage[] = [] - // Small first page for a fast first paint; grows by a page as the user scrolls. const INITIAL_LIMIT = 40 const PAGE = 60 @@ -45,13 +46,20 @@ type ReadSessionResult = * an ordered tail); live appends merge by id so order stays stable. */ export function useMobileNativeChatSession(args: { client: RpcClient | null + /** Stable host/workspace source; unlike `client`, it survives manual reconnect. */ + sourceIdentity: string agent: string | null sessionId: string | null transcriptPath: string | null }): MobileNativeChatSession { - const { client, agent, sessionId, transcriptPath } = args + const { client, sourceIdentity, agent, sessionId, transcriptPath } = args const [messages, setMessages] = useState([]) - const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}` + const identity = encodeNativeChatTranscriptIdentity([ + sourceIdentity, + agent, + sessionId, + transcriptPath + ]) // Pre-read status is a pure function of the props, so derive it rather than // letting the effect write it a commit later. const initialStatus: MobileNativeChatStatus = @@ -95,6 +103,13 @@ export function useMobileNativeChatSession(args: { // Whether this subscription already delivered its base snapshot; later // snapshots on the same subscription are reconnect replays, not fresh bases. const snapshotSeenRef = useRef(false) + const transcriptRetentionRef = useRef(createNativeChatTranscriptRetention()) + const settledReady = settled?.status === 'ready' + useEffect(() => { + if (settledReady) { + transcriptRetentionRef.current.capture(identity, messages) + } + }, [identity, messages, settledReady]) // Replace the base list (read results are an ordered tail). Resets the merger // cache so the index is rebuilt once over the new base. @@ -258,10 +273,17 @@ export function useMobileNativeChatSession(args: { })() }, [client, agent, sessionId, transcriptPath, hasMore, setList]) + const visibleMessages = transcriptRetentionRef.current.visible({ + identity, + messages, + settled: settledReady, + loading: status === 'loading' + }) + return { // Withheld until the settled read belongs to this identity: the effect that // clears the previous tab's list is passive, so `messages` lags a commit. - messages: settled ? messages : EMPTY_MESSAGES, + messages: visibleMessages, status, transcriptLoading: status === 'loading', error, diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts index a9e2b554f91..007fb317ce9 100644 --- a/mobile/src/source-control/mobile-git-status.test.ts +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -29,11 +29,8 @@ describe('mobile source control status helpers', () => { it('builds sections in the mobile source control order', () => { const sections = buildMobileSourceControlSections(entries) - expect(sections.map((section) => section.title)).toEqual([ - 'Changes', - 'Untracked Files', - 'Staged Changes' - ]) + expect(sections.map((section) => section.title)).toEqual(['Changes', 'Staged Changes']) + expect(sections[0]?.data.map((entry) => entry.path)).toEqual(['a.ts', 'new.ts']) }) it('computes actionable path sets', () => { diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts index 65fcf61e211..2d7b0795918 100644 --- a/mobile/src/source-control/mobile-git-status.ts +++ b/mobile/src/source-control/mobile-git-status.ts @@ -19,7 +19,7 @@ export type MobileSourceControlSection = { unstaged: 'Changes', @@ -59,7 +59,13 @@ export function buildMobileSourceControlSections ({ area, title: AREA_TITLES[area], - data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries) + data: entries + .filter((entry) => + area === 'unstaged' + ? entry.area === 'unstaged' || entry.area === 'untracked' + : entry.area === area + ) + .sort(compareGitStatusEntries) })).filter((section) => section.data.length > 0) } diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts new file mode 100644 index 00000000000..bb76d2df75b --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' + +function success(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function clientWith(sendRequest: RpcClient['sendRequest']): Pick { + return { sendRequest } +} + +function options(sendRequest: RpcClient['sendRequest']) { + return { + client: clientWith(sendRequest), + worktreeId: 'worktree-1', + relativePath: 'src/target.ts', + tabMode: 'diff' as const, + staged: true + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('revealMobileSourceControlSessionDiff', () => { + it('activates the requested file when its diff tab is already open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + activeTabId: 'agent-tab', + tabs: [ + { id: 'agent-tab', type: 'terminal' }, + { + id: 'other-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/other.ts' + }, + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', { + worktree: 'id:worktree-1', + tabId: 'target-diff', + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + }) + + it('selects the staged diff when both versions of a file are open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + }, + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('waits for the requested diff source instead of activating the other one', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('activates a legacy edit tab when opening a diff falls back to files.open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'stale-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + }, + { + id: 'target-edit', + type: 'file', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-edit' })) + + await expect( + revealMobileSourceControlSessionDiff({ ...options(sendRequest), tabMode: 'edit' }) + ).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'target-edit' }) + ) + }) + + it('retries until the opened diff appears in the session snapshot', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce(success({ tabs: [{ id: 'agent-tab', type: 'terminal' }] })) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenCalledTimes(3) + }) + + it('delegates to the mounted session when the dock callback is available', async () => { + const sendRequest = vi.fn() + const onOpenedFileDiff = vi.fn() + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + onOpenedFileDiff + }) + ).resolves.toBe('revealed') + + expect(onOpenedFileDiff).toHaveBeenCalledWith('src/target.ts') + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels route-owned polling after the source-control screen unmounts', async () => { + let current = true + const sendRequest = vi.fn().mockImplementation(async () => { + current = false + return success({ tabs: [] }) + }) + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + isCurrent: () => current + }) + ).resolves.toBe('cancelled') + expect(sendRequest).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts new file mode 100644 index 00000000000..9391c0c2961 --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts @@ -0,0 +1,143 @@ +import type { RpcClient } from '../transport/rpc-client' +import { activateMobileSessionTab } from '../session/mobile-session-tab-activation' + +type ActivationClient = Pick + +type SessionFileTabCandidate = { + id: string + type: string + mode?: unknown + relativePath?: unknown + diffSource?: unknown +} + +type Options = { + client: ActivationClient + worktreeId: string + relativePath: string + tabMode: 'diff' | 'edit' + staged: boolean + onOpenedFileDiff?: (relativePath: string) => void + isCurrent?: () => boolean +} + +export type MobileSourceControlSessionDiffRevealResult = 'revealed' | 'cancelled' | 'timeout' + +const TAB_POLL_DELAYS_MS = [0, 300, 600, 900] as const + +export async function revealMobileSourceControlSessionDiff( + options: Options +): Promise { + if (options.onOpenedFileDiff) { + options.onOpenedFileDiff(options.relativePath) + return 'revealed' + } + + for (const delayMs of TAB_POLL_DELAYS_MS) { + await waitForDelay(delayMs) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + + const tab = await findOpenedSessionFileTab(options) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (!tab) { + continue + } + + const activated = await activateSessionFileTab(options, tab.id) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (activated) { + return 'revealed' + } + } + + return 'timeout' +} + +async function findOpenedSessionFileTab(options: Options): Promise { + try { + const response = await options.client.sendRequest('session.tabs.list', { + worktree: `id:${options.worktreeId}` + }) + if (!response.ok) { + return null + } + const snapshot = readTabSnapshot(response.result) + if (!snapshot) { + return null + } + + const matches = snapshot.tabs.filter( + (tab) => + tab.type !== 'browser' && + tab.type !== 'terminal' && + matchesTabMode(tab.mode, options.tabMode) && + tab.relativePath === options.relativePath + ) + if (options.tabMode === 'edit') { + return matches[0] ?? null + } + const source = options.staged ? 'staged' : 'unstaged' + return ( + matches.find((tab) => tab.diffSource === source) ?? + matches.find((tab) => tab.diffSource == null) ?? + null + ) + } catch { + return null + } +} + +function matchesTabMode(mode: unknown, expected: 'diff' | 'edit'): boolean { + return expected === 'diff' ? mode === 'diff' : mode === 'edit' || mode == null +} + +async function activateSessionFileTab(options: Options, tabId: string): Promise { + try { + const response = await activateMobileSessionTab(options.client, { + worktree: `id:${options.worktreeId}`, + tabId, + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + return response.ok && readActiveTabId(response.result) === tabId + } catch { + return false + } +} + +function readTabSnapshot(value: unknown): { tabs: SessionFileTabCandidate[] } | null { + if ( + !isRecord(value) || + !Array.isArray(value.tabs) || + !value.tabs.every(isSessionFileTabCandidate) + ) { + return null + } + return { tabs: value.tabs } +} + +function isSessionFileTabCandidate(value: unknown): value is SessionFileTabCandidate { + return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' +} + +function readActiveTabId(value: unknown): string | null { + return isRecord(value) && typeof value.activeTabId === 'string' ? value.activeTabId : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +async function waitForDelay(delayMs: number): Promise { + if (delayMs === 0) { + return + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} diff --git a/mobile/src/source-control/use-mobile-source-control-openers.ts b/mobile/src/source-control/use-mobile-source-control-openers.ts index bf0eff63cf6..f6c44825715 100644 --- a/mobile/src/source-control/use-mobile-source-control-openers.ts +++ b/mobile/src/source-control/use-mobile-source-control-openers.ts @@ -18,6 +18,7 @@ import { type MobileGitStatusEntry } from './mobile-git-status' import { buildMobileReviewFileRoute } from './mobile-review-route' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' import type { GitDiffTextResult, MobileBranchCompareState, @@ -115,11 +116,13 @@ export function useMobileSourceControlOpeners(params: Params) { relativePath: entry.path, staged: entry.area === 'staged' }) + let openedTabMode: 'diff' | 'edit' = 'diff' if (!response.ok && isMobileGitUnavailable(response.error?.code, response.error?.message)) { response = await client.sendRequest('files.open', { worktree: `id:${worktreeId}`, relativePath: entry.path }) + openedTabMode = 'edit' } if (!response.ok) { throw new Error(response.error?.message || 'Unable to open diff') @@ -127,8 +130,22 @@ export function useMobileSourceControlOpeners(params: Params) { if (!mountedRef.current) { return } + const revealResult = await revealMobileSourceControlSessionDiff({ + client, + worktreeId, + relativePath: entry.path, + tabMode: openedTabMode, + staged: entry.area === 'staged', + onOpenedFileDiff, + isCurrent: () => mountedRef.current && openingPathRef.current === entry.path + }) + if (revealResult === 'cancelled') { + return + } + if (revealResult === 'timeout') { + throw new Error("The file opened, but its tab isn't ready yet. Try again.") + } triggerSelection() - onOpenedFileDiff?.(entry.path) // Why: when launched from the session screen, opening a file dismisses // this surface back to the session. In embedded mode there is nothing // to pop (the panel docks beside the terminal), so close the dock diff --git a/mobile/src/tasks/mobile-task-navigation.test.ts b/mobile/src/tasks/mobile-task-navigation.test.ts index 5bc519578ba..bacc24be5b8 100644 --- a/mobile/src/tasks/mobile-task-navigation.test.ts +++ b/mobile/src/tasks/mobile-task-navigation.test.ts @@ -32,8 +32,9 @@ describe('mobile task navigation', () => { it('waits for the expected host commit before navigating to Tasks', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push }, 'host/1') + navigateToMobileTasks(harness.navigation, { push, replace }, 'host/1') expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan( push.mock.invocationCallOrder[0]! @@ -46,38 +47,25 @@ describe('mobile task navigation', () => { routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() - - harness.setState({ - index: 1, - routes: [ - { name: 'index' }, - { - name: 'h', - state: { - key: '/h', - index: 0, - routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host/1' } }] - } - } - ] - }) - expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan( - harness.navigation.dispatch.mock.invocationCallOrder[0]! + replace.mock.invocationCallOrder[0]! ) - expect(harness.navigation.dispatch).toHaveBeenCalledWith({ - type: 'REPLACE', - target: '/h', - source: 'host-index', - payload: { name: '[hostId]/tasks', params: { hostId: 'host/1' } } + expect(replace).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/tasks', + params: { hostId: 'host/1' } }) }) it('ignores unrelated state events and preserves the provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1', 'linear') + navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1', + 'linear' + ) harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) @@ -108,30 +96,37 @@ describe('mobile task navigation', () => { it('cleanup prevents a stale navigation from replacing', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') + const replace = vi.fn() + const controller = navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace }, + 'host-1' + ) controller.cancel() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('reuses a pending host push and applies the latest provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const router = { push, replace: vi.fn() } const first = coordinateMobileTasksNavigation( null, harness.navigation, - { push }, + router, 'host-1', 'github' ) const second = coordinateMobileTasksNavigation( first, harness.navigation, - { push }, + router, 'host-1', 'linear' ) @@ -161,11 +156,12 @@ describe('mobile task navigation', () => { ) }) - it('keeps waiting through host setup and cleans up when navigation leaves', () => { + it('keeps waiting through host setup without params and cleans up when navigation leaves', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) + navigateToMobileTasks(harness.navigation, { push: vi.fn(), replace }, 'host-1') + harness.setState({ index: 0, routes: [{ name: 'h' }] }) expect(harness.unsubscribeState).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState({ @@ -184,6 +180,7 @@ describe('mobile task navigation', () => { expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('unsubscribes when mounting the host throws synchronously', () => { @@ -196,7 +193,8 @@ describe('mobile task navigation', () => { { push: () => { throw error - } + }, + replace: vi.fn() }, 'host-1' ) diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 0125cb053e6..ce360091ce2 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -39,6 +39,7 @@ describe('terminal live input affordance', () => { expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') + expect(block).toContain('liveInputText={liveInputCapture}') expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') expect(sessionRouteSource).toContain('return resetLiveInputFocus') expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') @@ -52,6 +53,8 @@ describe('terminal live input affordance', () => { it('makes the live keyboard target visible instead of status-only chrome', () => { expect(liveInputStatusSource).toContain("'Tap to show keyboard'") + expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'") + expect(liveInputStatusSource).toContain('ellipsizeMode="head"') expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised') expect(commandInputStylesSource).toContain('borderWidth: 1') expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed') diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts index 88918c00681..a77d4315c29 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -1,15 +1,16 @@ import { describe, expect, it } from 'vitest' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, - waitForTerminalLivePendingFlush, - type TerminalLivePendingFlushState + waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' describe('terminal live pending flush state', () => { it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When / Then await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true) @@ -22,7 +23,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -48,7 +50,8 @@ describe('terminal live pending flush state', () => { const flushPromise = new Promise((resolve) => { resolveFlush = resolve }) - const state: TerminalLivePendingFlushState = { current: flushPromise } + const state = createTerminalLivePendingFlushState() + state.current = flushPromise // When const controlSend = sendTerminalLiveControlAfterPendingFlush( @@ -67,17 +70,45 @@ describe('terminal live pending flush state', () => { }) describe('terminal live mirror send queue', () => { + it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => { + // Given + const state = createTerminalLivePendingFlushState() + const payloads: string[] = [] + let resolveFirstSend: (value: boolean) => void = () => {} + const sender = async (_handle: string, payload: string): Promise => { + payloads.push(payload) + if (payloads.length === 1) { + return new Promise((resolve) => { + resolveFirstSend = resolve + }) + } + return true + } + + // When + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender) + await Promise.resolve() + + // Then + expect(payloads).toEqual(['a']) + resolveFirstSend(true) + await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true]) + expect(payloads).toEqual(['a', 'bc']) + }) + it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() const order: string[] = [] - const first = queueTerminalLiveMirrorSend(state, async () => { + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { order.push('first') return false }) // When - const second = queueTerminalLiveMirrorSend(state, async () => { + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => { order.push('second') return true }) @@ -90,13 +121,13 @@ describe('terminal live mirror send queue', () => { it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } - const first = queueTerminalLiveMirrorSend(state, async () => { + const state = createTerminalLivePendingFlushState() + const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => { throw new Error('boom') }) // When - const second = queueTerminalLiveMirrorSend(state, async () => true) + const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true) // Then await expect(first).resolves.toBe(false) @@ -105,13 +136,33 @@ describe('terminal live mirror send queue', () => { it('Given a settled mirror send When it was the newest Then the state resets to null', async () => { // Given - const state: TerminalLivePendingFlushState = { current: null } + const state = createTerminalLivePendingFlushState() // When - await queueTerminalLiveMirrorSend(state, async () => true) + await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true) await Promise.resolve() // Then expect(state.current).toBeNull() }) + + it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => { + // Given + const state = createTerminalLivePendingFlushState() + let resolveSend: (value: boolean) => void = () => {} + const sender = async (): Promise => + new Promise((resolve) => { + resolveSend = resolve + }) + const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender) + const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender) + + // When + cancelTerminalLivePendingFlush(state) + + // Then + await expect(Promise.all([active, pending])).resolves.toEqual([false, false]) + expect(state.current).toBeNull() + resolveSend(true) + }) }) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts index 405198964f3..fe0b4927f5a 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -1,5 +1,30 @@ +type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise + +type TerminalLivePendingRequest = { + readonly resolve: (sent: boolean) => void +} + +type TerminalLivePendingBatch = { + readonly handle: string + payload: string + readonly requests: TerminalLivePendingRequest[] + readonly sender: TerminalLiveMirrorSender +} + export type TerminalLivePendingFlushState = { current: Promise | null + activeRequests: TerminalLivePendingRequest[] + generation: number + pendingBatches: TerminalLivePendingBatch[] +} + +export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState { + return { + current: null, + activeRequests: [], + generation: 0, + pendingBatches: [] + } } export function waitForTerminalLivePendingFlush( @@ -8,26 +33,76 @@ export function waitForTerminalLivePendingFlush( return state.current ?? Promise.resolve(true) } -// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped -// delta desyncs every later diff, so this chain runs each send even when the -// previous one failed. state.current should never reject; the catch keeps a -// future raw assignment from skipping a delta. +export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void { + state.generation += 1 + const requests = [ + ...state.activeRequests, + ...state.pendingBatches.flatMap((batch) => batch.requests) + ] + state.activeRequests = [] + state.pendingBatches = [] + state.current = null + requests.forEach(({ resolve }) => resolve(false)) +} + +async function drainTerminalLiveMirrorSends( + state: TerminalLivePendingFlushState, + generation: number +): Promise { + let allSent = true + while (state.generation === generation) { + const batch = state.pendingBatches.shift() + if (!batch) { + state.current = null + return allSent + } + + state.activeRequests = batch.requests + const sent = await batch.sender(batch.handle, batch.payload).catch(() => false) + if (state.generation !== generation) { + return false + } + + state.activeRequests = [] + batch.requests.forEach(({ resolve }) => resolve(sent)) + allSent &&= sent + } + return false +} + +// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke. export function queueTerminalLiveMirrorSend( state: TerminalLivePendingFlushState, - sendMirrorPayload: () => Promise + handle: string, + payload: string, + sender: TerminalLiveMirrorSender ): Promise { - const previousSend = state.current - const sendPromise = (async () => { - if (previousSend) { - await previousSend.catch(() => false) - } - return sendMirrorPayload() - })().catch(() => false) - state.current = sendPromise - void sendPromise.then(() => { - if (state.current === sendPromise) { - state.current = null - } + let resolveRequest: (sent: boolean) => void = () => {} + const request = new Promise((resolve) => { + resolveRequest = resolve }) - return sendPromise + const pendingTail = state.pendingBatches.at(-1) + if (pendingTail?.handle === handle && pendingTail.sender === sender) { + pendingTail.payload += payload + pendingTail.requests.push({ resolve: resolveRequest }) + } else { + state.pendingBatches.push({ + handle, + payload, + requests: [{ resolve: resolveRequest }], + sender + }) + } + + if (!state.current) { + const generation = state.generation + const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false) + state.current = drain + void drain.then(() => { + if (state.current === drain) { + state.current = null + } + }) + } + return request } diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index f6239312947..05617442142 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -7,6 +7,8 @@ import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror' import { + cancelTerminalLivePendingFlush, + createTerminalLivePendingFlushState, queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' @@ -39,7 +41,7 @@ export function useTerminalLivePendingInputFlush({ setLiveInputCapture }: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { const heldCommitTimerRef = useRef | null>(null) - const pendingLiveInputFlushRef = useRef | null>(null) + const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState()) const heldLiveInputTextRef = useRef('') const sentLiveInputTextRef = useRef('') const pendingLiveInputHandleRef = useRef(null) @@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush({ const resetMirrorState = useCallback(() => { clearHeldCommitTimer() + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null @@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush({ }, [liveInputRef, resetMirrorState, setLiveInputCapture]) const waitForPendingLiveInputFlush = useCallback(async (): Promise => { - return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) + return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current) }, []) + const sendQueuedMirrorPayload = useCallback( + (handle: string, payload: string): Promise => + sendLiveTerminalInputRef.current(handle, payload), + [sendLiveTerminalInputRef] + ) + const runMirrorStep = useCallback( async (handle: string, fieldText: string, commitHeld: boolean): Promise => { if ( @@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush({ if (payload.length === 0) { return waitForPendingLiveInputFlush() } - return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () => - sendLiveTerminalInputRef.current(handle, payload) + return queueTerminalLiveMirrorSend( + pendingLiveInputFlushRef.current, + handle, + payload, + sendQueuedMirrorPayload ) }, [ @@ -117,7 +129,7 @@ export function useTerminalLivePendingInputFlush({ clearHeldCommitTimer, liveInputTerminalHandlesRef, resetMirrorState, - sendLiveTerminalInputRef, + sendQueuedMirrorPayload, waitForPendingLiveInputFlush ] ) @@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush({ heldLiveInputTextRef.current = '' sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null - pendingLiveInputFlushRef.current = null + cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current) } }, []) diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index e7c00008e05..b08899f5333 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -21,6 +21,8 @@ vi.mock('./connection-revival-triggers', () => ({ })) import { RpcClientProvider, useCloseHost, useForceReconnect, useHostClient } from './client-context' +import { useAllHostClients } from './use-all-host-clients' +import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' type FakeClient = RpcClient & { emitState: (state: ConnectionState) => void @@ -386,3 +388,179 @@ describe('useHostClient', () => { expect(connectMock).not.toHaveBeenCalled() }) }) + +describe('useAllHostClients', () => { + it('only opens the requested startup subset', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id, host2.id], { autoConnectHostIds: [host2.id] }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledOnce() + expect(connectMock).toHaveBeenCalledWith(host2, expect.any(Function)) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('keeps startup connection fanout constant for a large saved-host list', async () => { + const hosts = Array.from({ length: 1_000 }, (_, index) => ({ + ...HOST, + id: `host-${index}`, + name: `Host ${index}`, + lastConnected: index + })) + const hostIds = hosts.map((host) => host.id) + const autoConnectHostIds = selectHomeAutoConnectHostIds(hosts) + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue(hosts) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients(hostIds, { autoConnectHostIds }) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledTimes(3) + expect(connectMock.mock.calls.map(([host]) => host.id)).toEqual([ + 'host-999', + 'host-998', + 'host-997' + ]) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('closes a demoted Home client when the recent-host set rotates', async () => { + const hosts = [ + { ...HOST, id: 'host-a', lastConnected: 4 }, + { ...HOST, id: 'host-b', lastConnected: 3 }, + { ...HOST, id: 'host-c', lastConnected: 2 }, + { ...HOST, id: 'host-d', lastConnected: 1 } + ] + const clients = new Map() + connectMock.mockImplementation((profile: typeof HOST) => { + const client = makeFakeClient('connected') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(hosts) + + let activeHostIds: string[] = [] + let renderer: ReactTestRenderer | null = null + function Probe({ profiles }: { profiles: typeof hosts }): null { + const hostIds = profiles.map((host) => host.id) + activeHostIds = useAllHostClients(hostIds, { + autoConnectHostIds: selectHomeAutoConnectHostIds(profiles), + closeUnusedOnRelease: true + }).map(({ hostId }) => hostId) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create( + createElement(RpcClientProvider, null, createElement(Probe, { profiles: hosts })) + ) + await Promise.resolve() + }) + expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-c']) + + const rotatedHosts = hosts.map((host) => + host.id === 'host-d' ? { ...host, lastConnected: 5 } : host + ) + await act(async () => { + renderer?.update( + createElement(RpcClientProvider, null, createElement(Probe, { profiles: rotatedHosts })) + ) + await Promise.resolve() + }) + + expect(connectMock).toHaveBeenCalledTimes(4) + expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-d']) + expect(clients.get('host-a')?.closeMock).not.toHaveBeenCalled() + expect(clients.get('host-b')?.closeMock).not.toHaveBeenCalled() + expect(clients.get('host-c')?.closeMock).toHaveBeenCalledOnce() + expect(clients.get('host-d')?.closeMock).not.toHaveBeenCalled() + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('retains connect-all behavior when no startup subset is provided', async () => { + const host2 = { ...HOST, id: 'host-2', name: 'Host 2' } + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST, host2]) + + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id, host2.id]) + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + await Promise.resolve() + }) + expect(connectMock).toHaveBeenCalledTimes(2) + } finally { + restore() + act(() => renderer?.unmount()) + } + }) + + it('allows an excluded host to connect manually', async () => { + connectMock.mockReturnValue(makeFakeClient('connected')) + loadHostsMock.mockResolvedValue([HOST]) + + let reconnect: ((hostId: string) => Promise) | null = null + let renderer: ReactTestRenderer | null = null + function Probe(): null { + useAllHostClients([HOST.id], { autoConnectHostIds: [] }) + reconnect = useForceReconnect() + return null + } + + const restore = suppressReactTestRendererDeprecationWarning() + try { + await act(async () => { + renderer = create(createElement(RpcClientProvider, null, createElement(Probe))) + }) + expect(connectMock).not.toHaveBeenCalled() + if (!reconnect) { + throw new Error('reconnect harness did not initialize') + } + await act(async () => { + await reconnect?.(HOST.id) + }) + expect(connectMock).toHaveBeenCalledOnce() + } finally { + restore() + act(() => renderer?.unmount()) + } + }) +}) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 8c00a11b2be..18ee0b7782a 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -14,9 +14,20 @@ import type { RpcClient } from './rpc-client' import { connectionLogStore } from './connection-log-buffer' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { HostClientOpenRegistry } from './host-client-open-registry' +import { decrementPendingAcquisition } from './host-client-acquisition-count' +import { + clientActivePath, + listHostClients, + notifyAllHostListeners, + notifyHostStateListeners, + primeHostProfiles, + subscribeAllHostListener, + subscribeHostStateListener, + type CloseEntryOptions +} from './host-client-context-state' import { loadHosts } from './host-store' import { openHostLogicalClient } from './host-logical-client' -import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState, HostProfile } from './types' type StoreEntry = { @@ -29,6 +40,8 @@ type StoreEntry = { export type RpcClientContextValue = { acquire: (hostId: string, host?: HostProfile) => RpcClient | null release: (hostId: string) => void + releaseAndCloseIfUnused: (hostId: string) => void + closeIfUnused: (hostId: string) => void forceReconnect: (hostId: string) => Promise closeHost: (hostId: string) => void getState: (hostId: string) => ConnectionState @@ -55,30 +68,27 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { // Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup. const pendingOpensRef = useRef(new HostClientOpenRegistry()) + const pendingAcquisitionsRef = useRef>(new Map()) // Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start. const primedHostsRef = useRef>(new Map()) - function notifyHostState(hostId: string, state: ConnectionState) { - const set = stateListenersRef.current.get(hostId) - if (!set) { - return - } - for (const listener of set) { - listener(state) - } - } + const notifyHostState = (hostId: string, state: ConnectionState) => + notifyHostStateListeners(stateListenersRef.current, hostId, state) + const notifyAllHosts = () => notifyAllHostListeners(allHostsListenersRef.current) - function notifyAllHosts() { - for (const listener of allHostsListenersRef.current) { - listener() - } - } - - const closeEntry = useCallback((hostId: string) => { - pendingOpensRef.current.cancel(hostId) - primedHostsRef.current.delete(hostId) + const closeEntry = useCallback((hostId: string, options: CloseEntryOptions) => { const entry = storeRef.current.get(hostId) + const acquisitionCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0 + pendingOpensRef.current.cancel(hostId) + if (options.preserveAcquisitions && acquisitionCount > 0) { + pendingAcquisitionsRef.current.set(hostId, acquisitionCount) + } else { + pendingAcquisitionsRef.current.delete(hostId) + } + if (options.forgetPrimedHost) { + primedHostsRef.current.delete(hostId) + } entry?.unsubState() storeRef.current.delete(hostId) entry?.client.close() @@ -86,6 +96,13 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { notifyAllHosts() }, []) + const closeHost = useCallback( + (hostId: string) => { + closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: true }) + }, + [closeEntry] + ) + const openEntry = useCallback(async (hostId: string): Promise => { const existing = pendingOpensRef.current.getActivePromise(hostId) if (existing) { @@ -151,9 +168,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const entry: StoreEntry = { client, state: client.getState(), - refCount: 0, + refCount: pendingAcquisitionsRef.current.get(hostId) ?? 0, unsubState } + pendingAcquisitionsRef.current.delete(hostId) storeRef.current.set(hostId, entry) notifyHostState(hostId, entry.state) notifyAllHosts() @@ -175,50 +193,83 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { existing.refCount += 1 return existing.client } + pendingAcquisitionsRef.current.set( + hostId, + (pendingAcquisitionsRef.current.get(hostId) ?? 0) + 1 + ) // Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes. - void openEntry(hostId).then((entry) => { - if (!entry) { - return - } - entry.refCount += 1 - }) + void openEntry(hostId) return null }, [openEntry] ) - const primeHosts = useCallback((hosts: HostProfile[]) => { - for (const host of hosts) { - primedHostsRef.current.set(host.id, host) - } - }, []) + const primeHosts = useCallback( + (hosts: HostProfile[]) => primeHostProfiles(primedHostsRef.current, hosts), + [] + ) // Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded. const release = useCallback((hostId: string) => { const entry = storeRef.current.get(hostId) - if (!entry) { + if (entry) { + entry.refCount = Math.max(0, entry.refCount - 1) return } - entry.refCount = Math.max(0, entry.refCount - 1) + decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) }, []) + const releaseAndCloseIfUnused = useCallback( + (hostId: string) => { + const entry = storeRef.current.get(hostId) + if (entry) { + entry.refCount = Math.max(0, entry.refCount - 1) + if (entry.refCount === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + return + } + if (decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + }, + [closeEntry] + ) + + const closeIfUnused = useCallback( + (hostId: string) => { + const entry = storeRef.current.get(hostId) + const pendingCount = pendingAcquisitionsRef.current.get(hostId) + const hasPendingOpen = pendingOpensRef.current.getActivePromise(hostId) !== null + if (!entry && pendingCount === undefined && !hasPendingOpen) { + return + } + if ((entry?.refCount ?? pendingCount ?? 0) === 0) { + closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false }) + } + }, + [closeEntry] + ) + const forceReconnect = useCallback( async (hostId: string) => { const entry = storeRef.current.get(hostId) - // Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count. - const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0 - const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount) + // Why: ownership survives explicit close/re-pair while observers never become synthetic owners. + const savedRefCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0 if (entry) { entry.unsubState() entry.client.close() storeRef.current.delete(hostId) } + if (savedRefCount > 0) { + pendingAcquisitionsRef.current.set( + hostId, + Math.max(savedRefCount, pendingAcquisitionsRef.current.get(hostId) ?? 0) + ) + } // Why: Retry must read amber for the whole reopen, not grey-then-amber. notifyHostState(hostId, 'connecting') - const fresh = await openEntry(hostId) - if (fresh) { - fresh.refCount = savedRefCount - } + await openEntry(hostId) }, [openEntry] ) @@ -252,41 +303,17 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { }, []) const subscribeHostState = useCallback( - (hostId: string, listener: (state: ConnectionState) => void) => { - let set = stateListenersRef.current.get(hostId) - if (!set) { - set = new Set() - stateListenersRef.current.set(hostId, set) - } - set.add(listener) - return () => { - const s = stateListenersRef.current.get(hostId) - if (!s) { - return - } - s.delete(listener) - if (s.size === 0) { - stateListenersRef.current.delete(hostId) - } - } - }, + (hostId: string, listener: (state: ConnectionState) => void) => + subscribeHostStateListener(stateListenersRef.current, hostId, listener), [] ) - const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => { - const out: Array<{ hostId: string; client: RpcClient }> = [] - for (const [hostId, entry] of storeRef.current) { - out.push({ hostId, client: entry.client }) - } - return out - }, []) + const getAllClients = useCallback(() => listHostClients(storeRef.current), []) - const subscribeAllHosts = useCallback((listener: () => void) => { - allHostsListenersRef.current.add(listener) - return () => { - allHostsListenersRef.current.delete(listener) - } - }, []) + const subscribeAllHosts = useCallback( + (listener: () => void) => subscribeAllHostListener(allHostsListenersRef.current, listener), + [] + ) // Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -294,8 +321,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const store = storeRef.current return () => { pendingOpensRef.current.cancelAll() + pendingAcquisitionsRef.current.clear() for (const [hostId] of store) { - closeEntry(hostId) + closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: false }) } } }, []) @@ -313,8 +341,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { () => ({ acquire, release, + releaseAndCloseIfUnused, + closeIfUnused, forceReconnect, - closeHost: closeEntry, + closeHost, getState, getKnownState, getReconnectAttempt, @@ -328,8 +358,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { [ acquire, release, + releaseAndCloseIfUnused, + closeIfUnused, forceReconnect, - closeEntry, + closeHost, getState, getKnownState, getReconnectAttempt, @@ -437,13 +469,3 @@ export function usePrimeHosts(): (hosts: HostProfile[]) => void { const ctx = useRpcClientContext() return ctx.primeHosts } - -function clientActivePath(client: RpcClient | undefined): MobileConnectionPath { - const logical = client as Partial | undefined - if (typeof logical?.getActivePath !== 'function') { - return 'lan' - } - // Why: mid-migration the active path still names the session being replaced; the - // pending one is what the user is actually waiting on (F5). - return logical.getPendingPath?.() ?? logical.getActivePath() -} diff --git a/mobile/src/transport/home-host-auto-connect.test.ts b/mobile/src/transport/home-host-auto-connect.test.ts new file mode 100644 index 00000000000..ecf04934809 --- /dev/null +++ b/mobile/src/transport/home-host-auto-connect.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import type { HostProfile } from './types' +import { + HOME_AUTO_CONNECT_LIMIT, + resolveHomeHostConnectionState, + selectHomeAutoConnectHostIds +} from './home-host-auto-connect' + +function host(id: string, lastConnected: number, credentials = true): HostProfile { + return { + id, + name: id, + endpoint: `ws://${id}`, + deviceToken: credentials ? `token-${id}` : '', + publicKeyB64: credentials ? `key-${id}` : '', + lastConnected + } +} + +describe('home host auto-connect', () => { + it('limits startup connections to the most recently used credentialed hosts', () => { + const hosts = [ + host('old', 1), + host('newest', 5), + host('second', 4), + host('third', 3), + host('fourth', 2), + host('missing-credentials', 6, false) + ] + + expect(selectHomeAutoConnectHostIds(hosts)).toEqual(['newest', 'second', 'third']) + expect(selectHomeAutoConnectHostIds(hosts)).toHaveLength(HOME_AUTO_CONNECT_LIMIT) + }) + + it('does not mutate the host card order', () => { + const hosts = [host('old', 1), host('new', 2)] + + selectHomeAutoConnectHostIds(hosts) + + expect(hosts.map((item) => item.id)).toEqual(['old', 'new']) + }) + + it('only presents hosts in the startup subset as connecting before clients open', () => { + const autoConnectHostIds = ['recent'] + + expect(resolveHomeHostConnectionState('recent', undefined, autoConnectHostIds)).toBe( + 'connecting' + ) + expect(resolveHomeHostConnectionState('stale', undefined, autoConnectHostIds)).toBe( + 'disconnected' + ) + expect(resolveHomeHostConnectionState('stale', 'connected', autoConnectHostIds)).toBe( + 'connected' + ) + }) +}) diff --git a/mobile/src/transport/home-host-auto-connect.ts b/mobile/src/transport/home-host-auto-connect.ts new file mode 100644 index 00000000000..995d653a0df --- /dev/null +++ b/mobile/src/transport/home-host-auto-connect.ts @@ -0,0 +1,24 @@ +import type { ConnectionState, HostProfile } from './types' + +export const HOME_AUTO_CONNECT_LIMIT = 3 + +export function selectHomeAutoConnectHostIds( + hosts: readonly HostProfile[], + limit = HOME_AUTO_CONNECT_LIMIT +): string[] { + return [...hosts] + .filter((host) => host.deviceToken.length > 0 && host.publicKeyB64.length > 0) + .sort( + (left, right) => right.lastConnected - left.lastConnected || left.id.localeCompare(right.id) + ) + .slice(0, Math.max(0, limit)) + .map((host) => host.id) +} + +export function resolveHomeHostConnectionState( + hostId: string, + state: ConnectionState | undefined, + autoConnectHostIds: readonly string[] +): ConnectionState { + return state ?? (autoConnectHostIds.includes(hostId) ? 'connecting' : 'disconnected') +} diff --git a/mobile/src/transport/host-catalog-credential-join.ts b/mobile/src/transport/host-catalog-credential-join.ts new file mode 100644 index 00000000000..e914742ff4e --- /dev/null +++ b/mobile/src/transport/host-catalog-credential-join.ts @@ -0,0 +1,52 @@ +import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' +import type { HostListSnapshot } from './host-list-load-sharing' +import type { HostCatalogEntry, StoredHostProfile } from './types' + +export async function joinHostCatalogCredentials(args: { + storedHosts: readonly StoredHostProfile[] + overlays: ReadonlyMap + tokenCache: Map + readToken: (hostId: string) => Promise + getRevision: () => number +}): Promise { + const catalog: HostCatalogEntry[] = [] + const profiles: HostListSnapshot['profiles'] = [] + for (const stored of args.storedHosts) { + let token = args.tokenCache.get(stored.id) + let credentialStatus: HostCatalogEntry['credentialStatus'] = 'ready' + if (!token) { + const readRevision = args.getRevision() + let fetched: string | null + try { + fetched = await args.readToken(stored.id) + } catch { + credentialStatus = 'temporarily-unavailable' + fetched = null + } + if (!fetched && credentialStatus === 'ready') { + credentialStatus = 'missing' + } + token = fetched ?? undefined + if (token && readRevision === args.getRevision()) { + args.tokenCache.set(stored.id, token) + } + } + const overlay = args.overlays.get(stored.id) + const base = { + ...stored, + ...(overlay + ? { + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + } + : {}) + } + const profile = token ? { ...base, deviceToken: token } : null + catalog.push({ ...base, credentialStatus, profile }) + if (profile) { + profiles.push(profile) + } + } + return { catalog, profiles } +} diff --git a/mobile/src/transport/host-catalog-selection.test.ts b/mobile/src/transport/host-catalog-selection.test.ts new file mode 100644 index 00000000000..5f5d85ca787 --- /dev/null +++ b/mobile/src/transport/host-catalog-selection.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { selectConnectableHostProfiles } from './host-catalog-selection' +import type { HostCatalogEntry, HostProfile } from './types' + +const profile: HostProfile = { + id: 'host-ready', + name: 'Ready', + endpoint: 'ws://127.0.0.1:1', + deviceToken: 'token', + publicKeyB64: 'key-ready', + lastConnected: 0 +} + +function unavailable( + id: string, + credentialStatus: 'temporarily-unavailable' | 'missing' +): HostCatalogEntry { + return { + id, + name: id, + endpoint: `ws://127.0.0.1/${id}`, + publicKeyB64: `key-${id}`, + lastConnected: 0, + credentialStatus, + profile: null + } +} + +describe('selectConnectableHostProfiles', () => { + it('excludes unavailable catalog entries from connection priming', () => { + expect( + selectConnectableHostProfiles([ + { ...profile, credentialStatus: 'ready', profile }, + unavailable('host-locked', 'temporarily-unavailable'), + unavailable('host-missing', 'missing') + ]) + ).toEqual([profile]) + }) +}) diff --git a/mobile/src/transport/host-catalog-selection.ts b/mobile/src/transport/host-catalog-selection.ts new file mode 100644 index 00000000000..31477dd7f6d --- /dev/null +++ b/mobile/src/transport/host-catalog-selection.ts @@ -0,0 +1,7 @@ +import type { HostCatalogEntry, HostProfile } from './types' + +export function selectConnectableHostProfiles(catalog: readonly HostCatalogEntry[]): HostProfile[] { + return catalog.flatMap((entry) => + entry.credentialStatus === 'ready' && entry.profile ? [entry.profile] : [] + ) +} diff --git a/mobile/src/transport/host-client-acquisition-count.ts b/mobile/src/transport/host-client-acquisition-count.ts new file mode 100644 index 00000000000..1a1123e5c8b --- /dev/null +++ b/mobile/src/transport/host-client-acquisition-count.ts @@ -0,0 +1,9 @@ +export function decrementPendingAcquisition(pending: Map, hostId: string): number { + const next = Math.max(0, (pending.get(hostId) ?? 0) - 1) + if (next === 0) { + pending.delete(hostId) + } else { + pending.set(hostId, next) + } + return next +} diff --git a/mobile/src/transport/host-client-context-state.ts b/mobile/src/transport/host-client-context-state.ts new file mode 100644 index 00000000000..e0f7243baa8 --- /dev/null +++ b/mobile/src/transport/host-client-context-state.ts @@ -0,0 +1,72 @@ +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile } from './types' + +export type CloseEntryOptions = { + forgetPrimedHost: boolean + preserveAcquisitions: boolean +} + +export function notifyHostStateListeners( + listeners: Map void>>, + hostId: string, + state: ConnectionState +): void { + for (const listener of listeners.get(hostId) ?? []) { + listener(state) + } +} + +export function notifyAllHostListeners(listeners: Set<() => void>): void { + for (const listener of listeners) { + listener() + } +} + +export function subscribeHostStateListener( + listeners: Map void>>, + hostId: string, + listener: (state: ConnectionState) => void +): () => void { + let hostListeners = listeners.get(hostId) + if (!hostListeners) { + hostListeners = new Set() + listeners.set(hostId, hostListeners) + } + hostListeners.add(listener) + return () => { + hostListeners.delete(listener) + if (hostListeners.size === 0) { + listeners.delete(hostId) + } + } +} + +export function subscribeAllHostListener( + listeners: Set<() => void>, + listener: () => void +): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function listHostClients( + entries: ReadonlyMap +): { hostId: string; client: RpcClient }[] { + return [...entries].map(([hostId, entry]) => ({ hostId, client: entry.client })) +} + +export function primeHostProfiles(cache: Map, hosts: HostProfile[]): void { + for (const host of hosts) { + cache.set(host.id, host) + } +} + +export function clientActivePath(client: RpcClient | undefined): MobileConnectionPath { + const logical = client as Partial | undefined + if (typeof logical?.getActivePath !== 'function') { + return 'lan' + } + // Why: during migration the pending path is what the user is waiting on. + return logical.getPendingPath?.() ?? logical.getActivePath() +} diff --git a/mobile/src/transport/host-credential-cleanup.test.ts b/mobile/src/transport/host-credential-cleanup.test.ts index bd483a3280f..146a845a96b 100644 --- a/mobile/src/transport/host-credential-cleanup.test.ts +++ b/mobile/src/transport/host-credential-cleanup.test.ts @@ -1,8 +1,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + cancelPendingHostCredentialCleanup, loadPendingHostCredentialCleanup, loadPendingHostCredentialCleanupIds, + recordHostCredentialCleanupIntent, resetHostCredentialCleanupForTests, retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup, @@ -66,6 +68,12 @@ describe('host credential cleanup', () => { expect(deleteCredential).toHaveBeenCalledOnce() }) + it('records cleanup intent without starting native deletion', async () => { + await recordHostCredentialCleanupIntent('host-1') + + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + }) + it('persists cleanup intent before a rejected delete for manual retry', async () => { const deleteCredential = vi.fn().mockRejectedValue(new Error('keychain unavailable')) const listener = vi.fn() @@ -78,6 +86,18 @@ describe('host credential cleanup', () => { unsubscribe() }) + it('cancels stale cleanup intent after replacement publication', async () => { + const deleteCredential = vi.fn().mockRejectedValue(new Error('superseded')) + + await scheduleHostCredentialCleanup('host-1', deleteCredential, 20) + await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce()) + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1']) + + await cancelPendingHostCredentialCleanup('host-1') + + await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([]) + }) + it('records intent before a stalled delete times out', async () => { vi.useFakeTimers() const deleteCredential = vi.fn(() => new Promise(() => undefined)) diff --git a/mobile/src/transport/host-credential-cleanup.ts b/mobile/src/transport/host-credential-cleanup.ts index d64e37a98dc..96a2623b514 100644 --- a/mobile/src/transport/host-credential-cleanup.ts +++ b/mobile/src/transport/host-credential-cleanup.ts @@ -155,13 +155,17 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia async function recordCleanupIntent(hostId: string): Promise { try { - await addPendingId(hostId) + await recordHostCredentialCleanupIntent(hostId) return true } catch { return false } } +export async function recordHostCredentialCleanupIntent(hostId: string): Promise { + await addPendingId(hostId) +} + async function confirmNativeCleanup( hostId: string, deleteCredential: DeleteHostCredential, @@ -209,6 +213,11 @@ export function subscribePendingHostCredentialCleanup(listener: () => void): () return () => pendingListeners.delete(listener) } +export async function cancelPendingHostCredentialCleanup(hostId: string): Promise { + clearUnrecordedPending(hostId) + await removePendingId(hostId) +} + /** * Record cleanup intent, then fire-and-forget the native keychain delete so * removeHost never blocks on SecureStore. If the durable intent write fails, diff --git a/mobile/src/transport/host-credential-write-revision.ts b/mobile/src/transport/host-credential-write-revision.ts new file mode 100644 index 00000000000..12207aa6a12 --- /dev/null +++ b/mobile/src/transport/host-credential-write-revision.ts @@ -0,0 +1,21 @@ +let nextRevision = 1 +const revisionByHostId = new Map() + +export function getHostCredentialWriteRevision(hostId: string): number { + return revisionByHostId.get(hostId) ?? 0 +} + +export function markHostCredentialWrite(hostId: string): void { + revisionByHostId.set(hostId, nextRevision) + nextRevision += 1 +} + +export function clearHostCredentialWriteRevision(hostId: string): void { + revisionByHostId.delete(hostId) +} + +/** Test-only: clear session credential generations between cases. */ +export function resetHostCredentialWriteRevisionsForTests(): void { + nextRevision = 1 + revisionByHostId.clear() +} diff --git a/mobile/src/transport/host-device-token-store.ts b/mobile/src/transport/host-device-token-store.ts new file mode 100644 index 00000000000..297177e0dbe --- /dev/null +++ b/mobile/src/transport/host-device-token-store.ts @@ -0,0 +1,43 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { Platform } from 'react-native' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' + +// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. +const TOKEN_KEY_PREFIX = 'orca.host-token.' +const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' + +function tokenKey(hostId: string): string { + return `${TOKEN_KEY_PREFIX}${hostId}` +} + +function webTokenKey(hostId: string): string { + return `${WEB_TOKEN_KEY_PREFIX}${hostId}` +} + +export async function readHostDeviceToken(hostId: string): Promise { + // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. + if (Platform.OS === 'web') { + return AsyncStorage.getItem(webTokenKey(hostId)) + } + return readPairingKeychainItem(tokenKey(hostId)) +} + +export async function writeHostDeviceToken(hostId: string, token: string): Promise { + if (Platform.OS === 'web') { + await AsyncStorage.setItem(webTokenKey(hostId), token) + return + } + await writePairingKeychainItem(tokenKey(hostId), token) +} + +export async function deleteHostDeviceToken(hostId: string): Promise { + if (Platform.OS === 'web') { + await AsyncStorage.removeItem(webTokenKey(hostId)) + return + } + await deletePairingKeychainItem(tokenKey(hostId)) +} diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts index cd0de461288..db463413a46 100644 --- a/mobile/src/transport/host-edit-navigation.test.ts +++ b/mobile/src/transport/host-edit-navigation.test.ts @@ -52,7 +52,7 @@ describe('mobile host edit navigation', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToMobileHostEdit(harness.navigation, { push }, 'host/1') + navigateToMobileHostEdit(harness.navigation, { push, replace: vi.fn() }, 'host/1') expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -75,7 +75,7 @@ describe('mobile host edit navigation', () => { it('does not replace an unrelated host route', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') harness.setState(committedHostState('host-2')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -83,7 +83,7 @@ describe('mobile host edit navigation', () => { it('disposes itself when navigation leaves the host flow without cancel()', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') // Enter the host flow for a different host, then leave it entirely. harness.setState(committedHostState('host-2')) @@ -97,7 +97,11 @@ describe('mobile host edit navigation', () => { it('cancels a pending replacement when navigation leaves the host flow', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + const controller = navigateToMobileHostEdit( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1' + ) harness.setState(committedHostState('host-2')) controller.cancel() diff --git a/mobile/src/transport/host-list-load-sharing.ts b/mobile/src/transport/host-list-load-sharing.ts index ab1e9e2fe89..15995e3ba25 100644 --- a/mobile/src/transport/host-list-load-sharing.ts +++ b/mobile/src/transport/host-list-load-sharing.ts @@ -1,15 +1,22 @@ -import type { HostProfile } from './types' +import type { HostCatalogEntry, HostProfile } from './types' + +export type HostListSnapshot = { + catalog: HostCatalogEntry[] + profiles: HostProfile[] +} // Why: concurrent callers share a slow Keychain pass; durable writes invalidate // it so later loads cannot receive stale snapshots (#8791). -let inflight: Promise | null = null +let inflight: Promise | null = null let revision = 0 export function getHostListLoadRevision(): number { return revision } -export function shareHostListLoad(load: () => Promise): Promise { +export function shareHostListLoad( + load: () => Promise +): Promise { if (inflight) { return inflight } diff --git a/mobile/src/transport/host-metadata-store.ts b/mobile/src/transport/host-metadata-store.ts new file mode 100644 index 00000000000..7962f43dc37 --- /dev/null +++ b/mobile/src/transport/host-metadata-store.ts @@ -0,0 +1,51 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { StoredHostProfileSchema, type HostProfile, type StoredHostProfile } from './types' + +const STORAGE_KEY = 'orca:hosts' + +export async function loadStoredHostProfiles(): Promise { + return parseStoredHostProfiles(await AsyncStorage.getItem(STORAGE_KEY)) +} + +export async function readStoredHostProfilesForMutation(): Promise { + try { + const parsed = await loadStoredHostProfiles() + if (parsed) { + return parsed + } + } catch { + // Normalize storage and payload failures for fail-closed mutations. + } + throw new Error('host list storage unreadable') +} + +export function writeStoredHostProfiles(hosts: readonly StoredHostProfile[]): Promise { + return AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts)) +} + +export function toStoredHostProfile(host: HostProfile): StoredHostProfile { + const { id, name, endpoint, publicKeyB64, lastConnected } = host + return { id, name, endpoint, publicKeyB64, lastConnected } +} + +function parseStoredHostProfiles(raw: string | null): StoredHostProfile[] | null { + if (!raw) { + return [] + } + try { + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) { + return null + } + return parsed.flatMap((item) => { + // Why: pre-v0.0.3 records embedded secrets; users re-pair instead of migrating them. + if (item && typeof item === 'object' && 'deviceToken' in item) { + return [] + } + const result = StoredHostProfileSchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return null + } +} diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 997e8a3d42f..ddb8f92a92b 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -14,6 +14,8 @@ const secureStoreMock = vi.hoisted(() => ({ })) const scheduleCleanupMock = vi.hoisted(() => vi.fn()) +const cancelCleanupMock = vi.hoisted(() => vi.fn()) +const recordCleanupIntentMock = vi.hoisted(() => vi.fn()) const platformMock = vi.hoisted(() => ({ OS: 'ios' })) vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -30,11 +32,14 @@ vi.mock('react-native', () => ({ })) vi.mock('./host-credential-cleanup', () => ({ + cancelPendingHostCredentialCleanup: (...args: unknown[]) => cancelCleanupMock(...args), + recordHostCredentialCleanupIntent: (...args: unknown[]) => recordCleanupIntentMock(...args), scheduleHostCredentialCleanup: (...args: unknown[]) => scheduleCleanupMock(...args), retryPendingHostCredentialCleanups: vi.fn() })) import { + loadHostCatalog, loadHosts, MobileRelayUpgradeHostRemovedError, removeHost, @@ -46,6 +51,7 @@ import { updateLastConnected } from './host-store' import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' +import { writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' const HOSTS_STORAGE_KEY = 'orca:hosts' const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' @@ -63,6 +69,23 @@ const HOST_TWO = { publicKeyB64: 'key-2', lastConnected: 0 } +const HOST_ONE_RELAY_BUNDLE = { + v: 1 as const, + hostId: HOST_ONE.id, + deviceToken: 'replacement-token', + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 1, + expiresAt: 10_000 + } +} + +function scheduledCleanup(hostId: string): (id: string) => Promise { + const cleanup = scheduleCleanupMock.mock.calls.find(([id]) => id === hostId)?.[1] + expect(cleanup).toBeTypeOf('function') + return cleanup as (id: string) => Promise +} describe('host-store list mutations', () => { let storedHostsRaw: string @@ -75,6 +98,10 @@ describe('host-store list mutations', () => { resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) + cancelCleanupMock.mockReset() + cancelCleanupMock.mockResolvedValue(undefined) + recordCleanupIntentMock.mockReset() + recordCleanupIntentMock.mockResolvedValue(undefined) storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO]) storedOverlayRaw = null asyncStorageMock.getItem.mockImplementation(async (key: string) => { @@ -93,6 +120,7 @@ describe('host-store list mutations', () => { storedOverlayRaw = raw } }) + secureStoreMock.setItemAsync.mockResolvedValue(undefined) secureStoreMock.getItemAsync.mockImplementation(async (key: string) => key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null ) @@ -114,6 +142,55 @@ describe('host-store list mutations', () => { expect(asyncStorageMock.getItem).toHaveBeenCalledOnce() }) + it('keeps a host visible when its credential read temporarily fails', async () => { + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => { + if (key.endsWith(HOST_ONE.id)) { + throw new Error('keychain locked') + } + return key.endsWith(HOST_TWO.id) ? 'token-2' : null + }) + + const catalog = await loadHostCatalog() + const profiles = await loadHosts() + + expect(catalog.map(({ id }) => id)).toEqual([HOST_ONE.id, HOST_TWO.id]) + expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ + credentialStatus: 'temporarily-unavailable', + profile: null + }) + expect(profiles.map(({ id }) => id)).toEqual([HOST_TWO.id]) + }) + + it('keeps orphaned metadata visible when its credential is missing', async () => { + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => + key.endsWith(HOST_TWO.id) ? 'token-2' : null + ) + + const catalog = await loadHostCatalog() + + expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ + credentialStatus: 'missing', + profile: null + }) + }) + + it('keeps existing metadata when pairing a distinct host', async () => { + await saveHost({ + id: 'host-new', + name: 'Host 3', + endpoint: 'ws://127.0.0.1:3', + publicKeyB64: 'key-new', + deviceToken: 'token-new', + lastConnected: 0 + }) + + expect(JSON.parse(storedHostsRaw).map(({ id }: { id: string }) => id)).toEqual([ + HOST_ONE.id, + HOST_TWO.id, + 'host-new' + ]) + }) + it('fails closed when durable host identity storage is unreadable', async () => { asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable')) @@ -134,9 +211,173 @@ describe('host-store list mutations', () => { }) expect(JSON.parse(storedHostsRaw)).toEqual([{ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3' }]) + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + asyncStorageMock.setItem.mock.invocationCallOrder[0]! + ) expect(scheduleCleanupMock).toHaveBeenCalledWith('host-duplicate', expect.any(Function)) }) + it('does not remove same-key metadata when the replacement credential write fails', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + secureStoreMock.setItemAsync.mockRejectedValue(new Error('keychain write failed')) + + await expect( + saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' }) + ).rejects.toThrow('keychain write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate]) + expect(cancelCleanupMock).toHaveBeenCalledWith(duplicate.id) + expect(scheduleCleanupMock).not.toHaveBeenCalled() + }) + + it('serves a committed replacement token when same-key metadata persistence fails', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + await loadHosts() + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect( + saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' }) + ).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate]) + await expect( + loadHosts().then((hosts) => hosts.find(({ id }) => id === HOST_ONE.id)?.deviceToken) + ).resolves.toBe('replacement-token') + }) + + it('records cleanup when new same-key metadata fails after its credential commits', async () => { + const replacement = { + ...HOST_TWO, + id: 'host-replacement', + publicKeyB64: HOST_ONE.publicKeyB64, + deviceToken: 'replacement-token' + } + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect(saveHost(replacement)).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(recordCleanupIntentMock).toHaveBeenCalledWith(replacement.id) + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + secureStoreMock.setItemAsync.mock.invocationCallOrder[0]! + ) + expect(scheduleCleanupMock).toHaveBeenCalledWith(replacement.id, expect.any(Function)) + }) + + it('does not clean a duplicate id that a later same-key save made authoritative', async () => { + const duplicate = { + ...HOST_TWO, + id: 'host-duplicate', + publicKeyB64: HOST_ONE.publicKeyB64 + } + storedHostsRaw = JSON.stringify([HOST_ONE, duplicate]) + await saveHost({ ...HOST_ONE, deviceToken: 'token-a' }) + const staleCleanup = scheduledCleanup(duplicate.id) + + await saveHost({ ...duplicate, deviceToken: 'token-b' }) + await staleCleanup(duplicate.id) + + expect(JSON.parse(storedHostsRaw)).toEqual([duplicate]) + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('does not let stale removal cleanup delete a replacement relay bundle before publication', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + await writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + + await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('keeps stale cleanup pending when a replacement relay write fails', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + secureStoreMock.setItemAsync.mockRejectedValueOnce(new Error('keychain write failed')) + + await expect(writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)).rejects.toThrow( + 'keychain write failed' + ) + await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('stops stale cleanup when a replacement relay write starts during token deletion', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + let releaseTokenDelete: () => void = () => {} + secureStoreMock.deleteItemAsync.mockReturnValueOnce( + new Promise((resolve) => { + releaseTokenDelete = resolve + }) + ) + + const cleanup = staleCleanup(HOST_ONE.id) + await vi.waitFor(() => { + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-1', + expect.anything() + ) + }) + const bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + releaseTokenDelete() + await bundleWrite + await expect(cleanup).rejects.toThrow('credential write superseded cleanup') + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalledWith( + 'orca.mobile-relay.credentials.host-1', + expect.anything() + ) + }) + + it('rechecks the write generation immediately before starting credential deletion', async () => { + await removeHost(HOST_ONE.id) + const staleCleanup = scheduledCleanup(HOST_ONE.id) + let resolveCleanupRead: (raw: string) => void = () => {} + const cleanupRead = new Promise((resolve) => { + resolveCleanupRead = resolve + }) + let cleanupReadStarted = false + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === HOSTS_STORAGE_KEY && !cleanupReadStarted) { + cleanupReadStarted = true + return cleanupRead + } + if (key === HOSTS_STORAGE_KEY) { + return storedHostsRaw + } + if (key === OVERLAY_STORAGE_KEY) { + return storedOverlayRaw + } + return null + }) + + const cleanup = staleCleanup(HOST_ONE.id) + await vi.waitFor(() => expect(cleanupReadStarted).toBe(true)) + let bundleWrite: Promise | null = null + resolveCleanupRead(storedHostsRaw) + queueMicrotask(() => { + bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE) + }) + await expect(cleanup).rejects.toThrow('credential write superseded cleanup') + await vi.waitFor(() => expect(bundleWrite).not.toBeNull()) + await bundleWrite + + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + it('clears stale relay state when an existing host is re-paired direct-only', async () => { const overlay: MobileRelayHostOverlay = { v: 2, @@ -209,7 +450,63 @@ describe('host-store list mutations', () => { expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) }) - it('merges v2 endpoints only onto an existing legacy base host', async () => { + it('cancels write-ahead cleanup when host metadata removal fails', async () => { + asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed')) + + await expect(removeHost(HOST_ONE.id)).rejects.toThrow('metadata write failed') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(cancelCleanupMock).toHaveBeenCalledWith(HOST_ONE.id) + }) + + it('does not cancel cleanup from a removal committed during a stalled save', async () => { + let releaseTokenWrite: () => void = () => {} + secureStoreMock.setItemAsync.mockReturnValueOnce( + new Promise((resolve) => { + releaseTokenWrite = resolve + }) + ) + + const save = saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' }) + await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledOnce()) + await removeHost(HOST_ONE.id) + releaseTokenWrite() + await save + await loadHostCatalog() + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) + expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) + expect(cancelCleanupMock).not.toHaveBeenCalled() + }) + + it('refuses an early same-key token write without durable cleanup intent', async () => { + const replacement = { + ...HOST_TWO, + id: 'host-replacement', + publicKeyB64: HOST_ONE.publicKeyB64, + deviceToken: 'replacement-token' + } + recordCleanupIntentMock.mockRejectedValueOnce(new Error('intent storage unavailable')) + + await expect(saveHost(replacement)).rejects.toThrow('intent storage unavailable') + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO]) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + + it('deletes an unpaired credential when cleanup runs without a newer write', async () => { + await removeHost(HOST_ONE.id) + const cleanup = scheduledCleanup(HOST_ONE.id) + + await cleanup(HOST_ONE.id) + + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-1', + expect.anything() + ) + }) + + it('keeps a concurrently re-published orphan overlay authoritative', async () => { const overlay: MobileRelayHostOverlay = { v: 2, hostId: HOST_ONE.id, @@ -232,8 +529,26 @@ describe('host-store list mutations', () => { } } storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }]) + let releaseCleanup: () => void = () => {} + scheduleCleanupMock.mockReturnValueOnce( + new Promise((resolve) => { + releaseCleanup = resolve + }) + ) - const hosts = await loadHosts() + const hostsLoad = loadHosts() + await vi.waitFor(() => expect(scheduleCleanupMock).toHaveBeenCalled()) + await saveHost({ + ...HOST_ONE, + id: 'removed-by-old-build', + publicKeyB64: 'restored-key', + deviceToken: 'restored-token', + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + }) + releaseCleanup() + const hosts = await hostsLoad expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ endpoints: overlay.endpoints, @@ -241,6 +556,10 @@ describe('host-store list mutations', () => { relay: overlay.relay }) expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false) + expect(JSON.parse(storedOverlayRaw!)).toContainEqual({ + ...overlay, + hostId: 'removed-by-old-build' + }) }) it('refuses to resurrect a removed host during relay upgrade publication', async () => { @@ -270,7 +589,9 @@ describe('host-store list mutations', () => { expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) }) expect(settled).toBe(false) - expect(scheduleCleanupMock).toHaveBeenCalledOnce() + expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan( + asyncStorageMock.setItem.mock.invocationCallOrder[0]! + ) resolveSchedule?.() await removal @@ -522,6 +843,10 @@ describe('host-store pairing save after an Android encryption rejection', () => resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) + cancelCleanupMock.mockReset() + cancelCleanupMock.mockResolvedValue(undefined) + recordCleanupIntentMock.mockReset() + recordCleanupIntentMock.mockResolvedValue(undefined) storedHostsRaw = '[]' storedGenerationRaw = null asyncStorageMock.getItem.mockImplementation(async (key: string) => { diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 43657fffe76..99421ffd192 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -1,20 +1,13 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { Platform } from 'react-native' -import { - HostProfileSchema, - StoredHostProfileSchema, - type HostProfile, - type StoredHostProfile -} from './types' +import { HostProfileSchema } from './types' +import type { HostCatalogEntry, HostProfile, StoredHostProfile } from './types' import { getNextHostNameFromHosts } from './host-names' import * as hostListLoads from './host-list-load-sharing' +import { joinHostCatalogCredentials } from './host-catalog-credential-join' +import { resetPairingKeychainForTests } from './pairing-keychain' +import { readHostDeviceToken, writeHostDeviceToken } from './host-device-token-store' import { - deletePairingKeychainItem, - readPairingKeychainItem, - resetPairingKeychainForTests, - writePairingKeychainItem -} from './pairing-keychain' -import { + cancelPendingHostCredentialCleanup, + recordHostCredentialCleanupIntent, retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup } from './host-credential-cleanup' @@ -24,137 +17,67 @@ import { removeMobileRelayHostOverlays, saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' -import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' -import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' +import { + getHostCredentialWriteRevision, + markHostCredentialWrite, + resetHostCredentialWriteRevisionsForTests +} from './host-credential-write-revision' +import { createUnpairedHostCredentialDeletion } from './unpaired-host-credential-deletion' +import { + loadStoredHostProfiles, + readStoredHostProfilesForMutation, + toStoredHostProfile, + writeStoredHostProfiles +} from './host-metadata-store' -const STORAGE_KEY = 'orca:hosts' -// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator. -const TOKEN_KEY_PREFIX = 'orca.host-token.' -const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' - -function tokenKey(hostId: string): string { - return `${TOKEN_KEY_PREFIX}${hostId}` -} - -function webTokenKey(hostId: string): string { - return `${WEB_TOKEN_KEY_PREFIX}${hostId}` -} - -async function readDeviceToken(hostId: string): Promise { - // Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain. - if (Platform.OS === 'web') { - return AsyncStorage.getItem(webTokenKey(hostId)) - } - return readPairingKeychainItem(tokenKey(hostId)) -} - -async function writeDeviceToken(hostId: string, token: string): Promise { - if (Platform.OS === 'web') { - await AsyncStorage.setItem(webTokenKey(hostId), token) - return - } - await writePairingKeychainItem(tokenKey(hostId), token) -} - -async function deleteDeviceToken(hostId: string): Promise { - if (Platform.OS === 'web') { - await AsyncStorage.removeItem(webTokenKey(hostId)) - return - } - await deletePairingKeychainItem(tokenKey(hostId)) -} - -async function deleteHostCredentials(hostId: string): Promise { - await deleteDeviceToken(hostId) - await deleteMobileRelayCredentialBundle(hostId) - await deleteMobileRelayDirectUpgradeJournal(hostId) +async function commitDeviceToken(hostId: string, token: string): Promise { + markHostCredentialWrite(hostId) + await writeHostDeviceToken(hostId, token) + tokenCache.set(hostId, token) + hostListLoads.dropSharedHostListLoad() } // Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove. const tokenCache = new Map() -// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename). +// Why: serialize host metadata RMW so concurrent writers cannot drop updates. let hostListMutation: Promise = Promise.resolve() -function parseStoredHosts(raw: string | null): StoredHostProfile[] | null { - if (!raw) { - return [] - } - try { - const parsed = JSON.parse(raw) as unknown - if (!Array.isArray(parsed)) { - return null - } - return parsed.flatMap((item) => { - // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim. - if (item && typeof item === 'object' && 'deviceToken' in item) { - return [] - } - const result = StoredHostProfileSchema.safeParse(item) - return result.success ? [result.data] : [] - }) - } catch { - return null - } -} +export const loadHosts = async (): Promise => (await loadHostListSnapshot()).profiles +export const loadHostCatalog = async (): Promise => + (await loadHostListSnapshot()).catalog -export async function loadHosts(): Promise { +async function loadHostListSnapshot(): Promise { // Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list. await hostListMutation // Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass. - return hostListLoads.shareHostListLoad(doLoadHosts) + return hostListLoads.shareHostListLoad(doLoadHostListSnapshot) } -async function doLoadHosts(): Promise { - const raw = await AsyncStorage.getItem(STORAGE_KEY) - const storedHosts = parseStoredHosts(raw) +async function doLoadHostListSnapshot(): Promise { + const storedHosts = await loadStoredHostProfiles() if (!storedHosts) { - return [] + return { catalog: [], profiles: [] } } const overlayState = await loadMobileRelayHostOverlayState( new Set(storedHosts.map(({ id }) => id)) ) + const orphanWriteRevisions = new Map( + overlayState.orphanHostIds.map((hostId) => [hostId, getHostCredentialWriteRevision(hostId)]) + ) await scheduleOrphanedMobileRelayCleanup({ hostIds: overlayState.orphanHostIds, - deleteCredential: deleteHostCredentials + deleteCredential: (hostId) => + deleteUnpairedHostCredentials(hostId, orphanWriteRevisions.get(hostId) ?? 0), + removeOverlay: removeOrphanOverlayIfUnpaired + }) + return joinHostCatalogCredentials({ + storedHosts, + overlays: overlayState.overlays, + tokenCache, + readToken: readHostDeviceToken, + getRevision: hostListLoads.getHostListLoadRevision }) - const overlays = overlayState.overlays - - const out: HostProfile[] = [] - for (const stored of storedHosts) { - let token = tokenCache.get(stored.id) - if (!token) { - const readRevision = hostListLoads.getHostListLoadRevision() - let fetched: string | null - try { - fetched = await readDeviceToken(stored.id) - } catch { - // Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it. - continue - } - if (!fetched) { - // Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host. - continue - } - token = fetched - if (readRevision === hostListLoads.getHostListLoadRevision()) { - tokenCache.set(stored.id, token) - } - } - const overlay = overlays.get(stored.id) - out.push({ - ...stored, - deviceToken: token, - ...(overlay - ? { - endpoints: overlay.endpoints, - relayHostId: overlay.relayHostId, - relay: overlay.relay - } - : {}) - }) - } - return out } export async function resolvePairingHostIdentity( @@ -163,91 +86,146 @@ export async function resolvePairingHostIdentity( ): Promise<{ id: string; name: string }> { // Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards. await hostListMutation - const hosts = await readStoredHostsForMutation() + const hosts = await readStoredHostProfilesForMutation() const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64) return match ? { id: match.id, name: match.name } : { id: newHostId, name: getNextHostNameFromHosts(hosts) } } -async function readStoredHostsForMutation(): Promise { - try { - const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY)) - if (!parsed) { - // Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write. - throw new Error('host list storage unreadable') - } - return parsed - } catch (error) { - if (error instanceof Error && error.message === 'host list storage unreadable') { - throw error - } - throw new Error('host list storage unreadable') +const deleteUnpairedHostCredentials = createUnpairedHostCredentialDeletion({ + waitForHostMutations: () => hostListMutation, + hasStoredHost: async (hostId) => + (await readStoredHostProfilesForMutation()).some(({ id }) => id === hostId), + onDeleted: (hostId) => { + tokenCache.delete(hostId) + hostListLoads.dropSharedHostListLoad() } +}) + +function scheduleUnpairedHostCredentialCleanup(hostId: string): Promise { + const writeRevision = getHostCredentialWriteRevision(hostId) + return scheduleHostCredentialCleanup(hostId, (id) => + deleteUnpairedHostCredentials(id, writeRevision) + ) } -async function mutateStoredHosts( - update: (hosts: StoredHostProfile[]) => StoredHostProfile[] -): Promise { - const mutation = hostListMutation.then(async () => { - const current = await readStoredHostsForMutation() - const next = update(current) - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next)) - hostListLoads.dropSharedHostListLoad() +function cancelCleanupForStoredHost(hostId: string): void { + const cancellation = hostListMutation.then(async () => { + const hosts = await readStoredHostProfilesForMutation() + if (hosts.some(({ id }) => id === hostId)) { + // Register before later removals enqueue their intent, without blocking host loads on cleanup storage. + void cancelPendingHostCredentialCleanup(hostId).catch(() => undefined) + } }) + hostListMutation = cancellation.catch(() => {}) +} + +async function cancelCleanupForDurablyStoredHosts(hostIds: Iterable): Promise { + const targets = [...hostIds] + return enqueueHostListMutation(async () => { + const storedIds = new Set((await readStoredHostProfilesForMutation()).map(({ id }) => id)) + await Promise.all( + targets + .filter((hostId) => storedIds.has(hostId)) + .map((hostId) => cancelPendingHostCredentialCleanup(hostId).catch(() => undefined)) + ) + }).catch(() => undefined) +} + +function enqueueHostListMutation(operation: () => Promise): Promise { + const mutation = hostListMutation.then(operation) hostListMutation = mutation.catch(() => {}) return mutation } -function toStored(host: HostProfile): StoredHostProfile { - return { - id: host.id, - name: host.name, - endpoint: host.endpoint, - publicKeyB64: host.publicKeyB64, - lastConnected: host.lastConnected - } +function removeOrphanOverlayIfUnpaired(hostId: string): Promise { + return enqueueHostListMutation(async () => { + const hosts = await readStoredHostProfilesForMutation() + if (!hosts.some(({ id }) => id === hostId)) { + await removeMobileRelayHostOverlay(hostId) + } + }) +} + +async function mutateStoredHosts( + update: (hosts: StoredHostProfile[]) => StoredHostProfile[] | Promise +): Promise { + return enqueueHostListMutation(async () => { + const current = await readStoredHostProfilesForMutation() + const next = await update(current) + await writeStoredHostProfiles(next) + hostListLoads.dropSharedHostListLoad() + }) } export class MobileRelayUpgradeHostRemovedError extends Error {} -export async function saveHost(host: HostProfile): Promise { - await persistHost(host, false) -} +export const saveHost = (host: HostProfile): Promise => persistHost(host, false) -export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise { - await persistHost(host, true) -} +export const saveExistingHostRelayUpgrade = (host: HostProfile): Promise => + persistHost(host, true) async function persistHost(host: HostProfile, requireExisting: boolean): Promise { const validated = HostProfileSchema.parse(host) - const stored = toStored(validated) + const stored = toStoredHostProfile(validated) const duplicateHostIds = new Set() let updatedExistingHost = false - await mutateStoredHosts((hosts) => { - const index = hosts.findIndex((h) => h.id === stored.id) - for (const candidate of hosts) { - if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) { - duplicateHostIds.add(candidate.id) + let cleanupIntentRecordedBeforeMetadata = false + let tokenCommittedBeforeMetadata = false + try { + await mutateStoredHosts(async (hosts) => { + const index = hosts.findIndex((h) => h.id === stored.id) + for (const candidate of hosts) { + if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) { + duplicateHostIds.add(candidate.id) + } + } + let next: StoredHostProfile[] + if (index >= 0) { + updatedExistingHost = true + // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. + next = hosts + .filter(({ id }) => !duplicateHostIds.has(id)) + .map((candidate) => (candidate.id === stored.id ? stored : candidate)) + } else if (requireExisting) { + // Why: an in-flight relay upgrade must not resurrect a host the user removed. + throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') + } else { + next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] + } + if (duplicateHostIds.size > 0) { + if (index < 0) { + // Why: process death between the early token write and metadata publication must leave cleanup discoverable. + await recordHostCredentialCleanupIntent(stored.id) + cleanupIntentRecordedBeforeMetadata = true + } + for (const duplicateHostId of duplicateHostIds) { + await recordHostCredentialCleanupIntent(duplicateHostId) + } + // Why: never remove the only usable same-key row until its replacement credential is durable. + await commitDeviceToken(stored.id, validated.deviceToken) + tokenCommittedBeforeMetadata = true + } + return next + }) + } catch (error) { + await cancelCleanupForDurablyStoredHosts(duplicateHostIds) + if (cleanupIntentRecordedBeforeMetadata) { + try { + await scheduleUnpairedHostCredentialCleanup(stored.id) + } catch { + // The write-ahead cleanup intent remains available for retry. } } - if (index >= 0) { - updatedExistingHost = true - // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id. - return hosts - .filter(({ id }) => !duplicateHostIds.has(id)) - .map((candidate) => (candidate.id === stored.id ? stored : candidate)) - } - if (requireExisting) { - // Why: an in-flight relay upgrade must not resurrect a host the user removed. - throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') - } - return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored] - }) - // Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever. - await writeDeviceToken(stored.id, validated.deviceToken) - tokenCache.set(stored.id, validated.deviceToken) - hostListLoads.dropSharedHostListLoad() + throw error + } + if (!tokenCommittedBeforeMetadata) { + // Why: the catalog can now surface a failed token write for recovery instead of losing the host. + await commitDeviceToken(stored.id, validated.deviceToken) + } + // Why: a later removal owns its cleanup intent; cancel only while this publication remains authoritative. + cancelCleanupForStoredHost(stored.id) if (validated.endpoints) { await saveMobileRelayHostOverlay({ v: 2, @@ -268,9 +246,8 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise hostListLoads.dropSharedHostListLoad() } for (const duplicateHostId of duplicateHostIds) { - tokenCache.delete(duplicateHostId) try { - await scheduleHostCredentialCleanup(duplicateHostId, deleteHostCredentials) + await scheduleUnpairedHostCredentialCleanup(duplicateHostId) } catch { // Metadata is already deduplicated; orphan-token recovery is best-effort. } @@ -278,7 +255,23 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise } export async function removeHost(hostId: string): Promise { - await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId)) + let cleanupIntentRecorded = false + try { + await mutateStoredHosts(async (hosts) => { + try { + await recordHostCredentialCleanupIntent(hostId) + cleanupIntentRecorded = true + } catch { + // Removal remains authoritative when cleanup intent storage is unavailable. + } + return hosts.filter((h) => h.id !== hostId) + }) + } catch (error) { + if (cleanupIntentRecorded) { + await cancelCleanupForDurablyStoredHosts([hostId]) + } + throw error + } tokenCache.delete(hostId) try { await removeMobileRelayHostOverlay(hostId) @@ -288,7 +281,7 @@ export async function removeHost(hostId: string): Promise { } // Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI. try { - await scheduleHostCredentialCleanup(hostId, deleteHostCredentials) + await scheduleUnpairedHostCredentialCleanup(hostId) } catch { // Metadata is already committed; orphan-token recovery is best-effort. } @@ -299,7 +292,9 @@ export async function retryPendingHostCredentialCleanup(): Promise<{ remainingIds: string[] storageUnreadable: boolean }> { - return retryPendingHostCredentialCleanups(deleteHostCredentials) + return retryPendingHostCredentialCleanups((hostId) => + deleteUnpairedHostCredentials(hostId, getHostCredentialWriteRevision(hostId)) + ) } // Why: single mutation pass commits name + endpoint atomically so a mid-save failure can't persist one without the other. @@ -342,6 +337,7 @@ export async function updateLastConnected(hostId: string): Promise { export function resetHostStoreForTests(): void { hostListMutation = Promise.resolve() tokenCache.clear() + resetHostCredentialWriteRevisionsForTests() hostListLoads.dropSharedHostListLoad() resetPairingKeychainForTests() } diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts index a08fa052459..089c6d28c56 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -7,6 +7,7 @@ import { readPairingKeychainItem, writePairingKeychainItem } from './pairing-keychain' +import { markHostCredentialWrite } from './host-credential-write-revision' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) const ResumeCredentialSchema = z @@ -91,6 +92,7 @@ export async function writeMobileRelayCredentialBundle( ): Promise { requireNativeSecretStore() const validated = MobileRelayCredentialBundleSchema.parse(bundle) + markHostCredentialWrite(validated.hostId) await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated)) } diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 2493c9e78af..e21e75a6b61 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -6,6 +6,7 @@ import { readPairingKeychainItem, writePairingKeychainItem } from './pairing-keychain' +import { markHostCredentialWrite } from './host-credential-write-revision' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) @@ -60,6 +61,7 @@ export async function writeMobileRelayDirectUpgradeJournal( ): Promise { requireNativeSecretStore() const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) + markHostCredentialWrite(parsed.hostId) await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed)) } diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts index 41d53189880..96d71b37680 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -220,4 +220,53 @@ describe('mobile relay pairing recovery', () => { expect(saved.metadata.authorizationMode).toBe('authenticated-direct') expect(deps.updateJournal).toHaveBeenCalledTimes(2) }) + // Why: a journal stranded by a relay outage used to block every later pairing + // with "recovery pending" forever, because recovery only ever deferred. + it('abandons a journal once its invite expired and no credential can reconcile', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned') + expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId) + }) + + it('keeps a just-expired journal so a brief outage cannot discard it', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + + // Why: a committed install whose local persistence failed is the one case the + // journal must survive — it is the only record left to retry the write from. + it('keeps a journal when the server committed but the local write failed', async () => { + const saved = journal() + const directInstalled = installed(saved, 'authenticated-direct') + const committed = client(async () => + response(endpoints(saved, { state: 'committed', result: directInstalled })) + ) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => committed) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1, + writeCredentialBundle: vi.fn(async () => { + throw new Error('keychain unavailable') + }) + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index 0d5ba7e5e07..26dc08620de 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -27,7 +27,7 @@ import { import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' import type { HostProfile, RpcResponse } from './types' -export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' +export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned' type RecoveryDependencies = { loadJournal: typeof loadMobileRelayPairingJournal @@ -57,6 +57,10 @@ const defaultDependencies: RecoveryDependencies = { platform: Platform.OS } +// One full invite lifetime past expiry, so a momentary outage never discards a +// journal that a later launch could still reconcile. +const ABANDON_GRACE_MS = 10 * 60 * 1000 + let recoveryPromise: Promise | null = null export function recoverMobileRelayPairing( @@ -96,6 +100,10 @@ async function runRecovery( } const credentials = recoveryCredentials(journal, bundle, dependencies.now()) + // Why: publishCommitted runs inside the catch below, so a failed local write + // of an authoritatively committed install must not look like "nothing to + // reconcile" — that journal is the only record left to retry the write from. + let observedCommitted = false for (const credential of credentials) { let client: PairingCandidateClient | null = null try { @@ -113,6 +121,7 @@ async function runRecovery( }) const endpoints = await getRecoveryStatus(client, journal, credential.kind) if (endpoints.installStatus?.state === 'committed') { + observedCommitted = true await publishCommitted(journal, endpoints, dependencies) return 'recovered' } @@ -128,6 +137,7 @@ async function runRecovery( ) const reconciled = await getRecoveryStatus(client, journal, 'invite') assertCommitted(reconciled, installed) + observedCommitted = true await publishCommitted(journal, reconciled, dependencies) return 'recovered' } @@ -138,6 +148,19 @@ async function runRecovery( client?.close() } } + // Why: past invite expiry no credential can still establish what happened, so + // retaining the journal cannot reconcile anything — it only fails every later + // pairing with "recovery pending" forever. Re-pairing mints a fresh device and + // any uncommitted server-side install expires on its own. The extra invite + // lifetime of slack keeps a brief relay outage from discarding a journal whose + // resume credential would have reconciled it on the next launch. + if ( + !observedCommitted && + journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now() + ) { + await dependencies.clearJournal(journal.metadata.journalId).catch(() => {}) + return 'abandoned' + } return 'deferred' } diff --git a/mobile/src/transport/settings-host-client-connections.ts b/mobile/src/transport/settings-host-client-connections.ts new file mode 100644 index 00000000000..b2341471d2b --- /dev/null +++ b/mobile/src/transport/settings-host-client-connections.ts @@ -0,0 +1,19 @@ +import { useCallback, useState } from 'react' +import { useFocusEffect } from 'expo-router' +import { useAllHostClients } from './use-all-host-clients' + +export function useFocusedSettingsHostClients(hostIds: string[]) { + const [focused, setFocused] = useState(false) + + useFocusEffect( + useCallback(() => { + setFocused(true) + return () => setFocused(false) + }, []) + ) + + const clients = useAllHostClients(focused ? hostIds : [], { + closeUnusedOnRelease: true + }) + return { clients, focused } +} diff --git a/mobile/src/transport/settings-host-client-lifecycle.test.ts b/mobile/src/transport/settings-host-client-lifecycle.test.ts new file mode 100644 index 00000000000..7a23c86ac71 --- /dev/null +++ b/mobile/src/transport/settings-host-client-lifecycle.test.ts @@ -0,0 +1,776 @@ +import { createElement, Fragment, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAllHostClients } from './use-all-host-clients' +import { useFocusedSettingsHostClients } from './settings-host-client-connections' +import { + RpcClientProvider, + useHostClient, + usePrimeHosts, + useRpcClientContext, + type RpcClientContextValue +} from './client-context' +import { selectHomeAutoConnectHostIds } from './home-host-auto-connect' +import type { RpcClient } from './rpc-client' +import type { ConnectionState, HostProfile } from './types' + +const connectMock = vi.fn() +const loadHostsMock = vi.fn() +const routeFocus = vi.hoisted(() => ({ + effect: null as null | (() => void | (() => void)) +})) + +vi.mock('expo-router', () => ({ + useFocusEffect: (effect: () => void | (() => void)) => { + routeFocus.effect = effect + } +})) + +vi.mock('./host-logical-client', () => ({ + openHostLogicalClient: (...args: unknown[]) => connectMock(...args) +})) +vi.mock('./host-store', () => ({ + loadHosts: () => loadHostsMock() +})) +vi.mock('./connection-revival-triggers', () => ({ + subscribeConnectionRevivalTriggers: () => () => {} +})) + +type FakeClient = RpcClient & { + closeMock: ReturnType +} + +function makeFakeClient(initialState: ConnectionState): FakeClient { + const listeners = new Set<(state: ConnectionState) => void>() + const closeMock = vi.fn() + return { + sendRequest: vi.fn(), + subscribe: vi.fn(() => () => {}), + updateTerminalSubscriptionViewport: vi.fn(), + getState: () => initialState, + getReconnectAttempt: () => (initialState === 'reconnecting' ? 4 : 0), + getLastConnectedAt: () => null, + onStateChange: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + notifyForeground: vi.fn(), + close: closeMock, + closeMock + } as FakeClient +} + +function host(id: string, lastConnected: number, extra: Partial = {}): HostProfile { + return { + id, + name: id, + endpoint: `ws://${id}.internal:8787`, + deviceToken: `token-${id}`, + publicKeyB64: `key-${id}`, + lastConnected, + ...extra + } +} + +const HOSTS = [ + host('direct-recent', 50), + host('relay-recent', 40, { relayHostId: 'AbCdEf0123_-xyZ9' }), + host('ssh-provider-recent', 30), + host('folder-workspace-host', 20), + host('offline-host', 10) +] +const HOST_IDS = HOSTS.map((profile) => profile.id) +const HOME_HOST_IDS = selectHomeAutoConnectHostIds(HOSTS) + +let context: RpcClientContextValue | null = null + +function ContextProbe(): null { + context = useRpcClientContext() + return null +} + +function HomeProbe(): null { + useAllHostClients(HOST_IDS, { + autoConnectHostIds: HOME_HOST_IDS, + closeUnusedOnRelease: true + }) + const primeHosts = usePrimeHosts() + useEffect(() => { + primeHosts(HOSTS) + }, [primeHosts]) + return null +} + +function SettingsProbe(): null { + useAllHostClients(HOST_IDS, { + closeUnusedOnRelease: true + }) + return null +} + +function FocusedSettingsProbe(): null { + useFocusedSettingsHostClients(HOST_IDS) + return null +} + +function DynamicSettingsProbe({ hostIds }: { hostIds: string[] }): null { + useAllHostClients(hostIds, { closeUnusedOnRelease: true }) + return null +} + +function DetailProbe({ hostId }: { hostId: string }): null { + useHostClient(hostId) + return null +} + +type Screen = 'empty' | 'home' | 'settings' + +function TestApp({ + screen, + detailHostId +}: { + screen: Screen + detailHostId?: string +}): React.JSX.Element { + const screenProbe = + screen === 'home' + ? createElement(HomeProbe) + : screen === 'settings' + ? createElement(SettingsProbe) + : null + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + detailHostId ? createElement(DetailProbe, { hostId: detailHostId }) : null, + screenProbe + ) + ) +} + +function suppressRendererWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +async function renderScreen(screen: Screen, detailHostId?: string): Promise { + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(TestApp, { screen, detailHostId })) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('settings lifecycle harness did not render') + } + return renderer +} + +async function navigate( + renderer: ReactTestRenderer, + screen: Screen, + detailHostId?: string +): Promise { + await act(async () => { + renderer.update(createElement(TestApp, { screen, detailHostId })) + await Promise.resolve() + await Promise.resolve() + }) +} + +function activeHostIds(): string[] { + if (!context) { + throw new Error('client context was not captured') + } + return context + .getAllClients() + .map(({ hostId }) => hostId) + .sort() +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + context = null + routeFocus.effect = null + connectMock.mockReset() + loadHostsMock.mockReset() +}) + +describe('settings host client lifecycle', () => { + it('closes settings-only clients while a mounted Home owner keeps its bounded set', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient(profile.id === 'offline-host' ? 'reconnecting' : 'connected') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + function NavigationStack({ settingsVisible }: { settingsVisible: boolean }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + settingsVisible ? createElement(SettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(NavigationStack, { settingsVisible: false })) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('navigation stack harness did not render') + } + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + expect(connectMock).toHaveBeenCalledTimes(3) + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: true })) + await Promise.resolve() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + expect(connectMock).toHaveBeenCalledTimes(HOSTS.length) + expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length) + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: false })) + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + for (const hostId of HOME_HOST_IDS) { + expect(clients.get(hostId)).toHaveLength(1) + expect(clients.get(hostId)?.[0]?.closeMock).not.toHaveBeenCalled() + } + for (const hostId of ['folder-workspace-host', 'offline-host']) { + expect(clients.get(hostId)?.[0]?.closeMock).toHaveBeenCalledOnce() + } + + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: true })) + await Promise.resolve() + await Promise.resolve() + }) + expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length) + await act(async () => { + renderer?.update(createElement(NavigationStack, { settingsVisible: false })) + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + for (const hostId of HOME_HOST_IDS) { + expect(clients.get(hostId)).toHaveLength(1) + } + for (const hostId of ['folder-workspace-host', 'offline-host']) { + expect(clients.get(hostId)).toHaveLength(2) + expect(clients.get(hostId)?.every((client) => client.closeMock.mock.calls.length === 1)).toBe( + true + ) + } + + act(() => renderer.unmount()) + }) + + it('leaves no zero-reference clients after repeated settings navigation', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const renderer = await renderScreen('settings') + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + + await navigate(renderer, 'empty') + expect(activeHostIds()).toEqual([]) + for (const hostClients of clients.values()) { + expect(hostClients).toHaveLength(1) + expect(hostClients[0]?.closeMock).toHaveBeenCalledOnce() + } + + await navigate(renderer, 'settings') + await navigate(renderer, 'empty') + expect(activeHostIds()).toEqual([]) + expect(connectMock).toHaveBeenCalledTimes(HOSTS.length * 2) + for (const hostClients of clients.values()) { + expect(hostClients).toHaveLength(2) + expect(hostClients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true) + } + + act(() => renderer.unmount()) + }) + + it('reconciles host-list changes without restarting retained or shared clients', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const retainedHostId = 'direct-recent' + const sharedHostId = 'relay-recent' + const removedHostId = 'folder-workspace-host' + const addedHostId = 'offline-host' + function HostListApp({ settingsHostIds }: { settingsHostIds: string[] }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(DetailProbe, { hostId: sharedHostId }), + createElement(DynamicSettingsProbe, { hostIds: settingsHostIds }) + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(HostListApp, { + settingsHostIds: [retainedHostId, sharedHostId, removedHostId] + }) + ) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer) { + throw new Error('host-list lifecycle harness did not render') + } + + await act(async () => { + renderer?.update( + createElement(HostListApp, { + settingsHostIds: [retainedHostId, addedHostId] + }) + ) + await Promise.resolve() + await Promise.resolve() + }) + + expect(connectMock).toHaveBeenCalledTimes(4) + expect(activeHostIds()).toEqual([addedHostId, retainedHostId, sharedHostId].sort()) + expect(clients.get(retainedHostId)?.closeMock).not.toHaveBeenCalled() + expect(clients.get(sharedHostId)?.closeMock).not.toHaveBeenCalled() + expect(clients.get(removedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(addedHostId)?.closeMock).not.toHaveBeenCalled() + + act(() => renderer.unmount()) + expect(clients.get(retainedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(sharedHostId)?.closeMock).toHaveBeenCalledOnce() + expect(clients.get(addedHostId)?.closeMock).toHaveBeenCalledOnce() + }) + + it('does not close a settings client still held by an active consumer', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('connected') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + + const detailHostId = 'folder-workspace-host' + const renderer = await renderScreen('settings', detailHostId) + await navigate(renderer, 'home', detailHostId) + + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + expect(clients.get(detailHostId)?.closeMock).not.toHaveBeenCalled() + + act(() => renderer.unmount()) + expect(clients.get(detailHostId)?.closeMock).toHaveBeenCalledOnce() + }) + + it('keeps a reconnect alive when another consumer remains after settings leaves', async () => { + const retryHost = host('ssh-retry-host', 1) + let resolveInitial: ((hosts: HostProfile[]) => void) | null = null + let resolveRetry: ((hosts: HostProfile[]) => void) | null = null + const initialLookup = new Promise((resolve) => { + resolveInitial = resolve + }) + const retryLookup = new Promise((resolve) => { + resolveRetry = resolve + }) + loadHostsMock.mockReturnValueOnce(initialLookup).mockReturnValueOnce(retryLookup) + const initialClient = makeFakeClient('connected') + const retryClient = makeFakeClient('reconnecting') + connectMock.mockReturnValueOnce(initialClient).mockReturnValueOnce(retryClient) + + function RetrySettingsProbe(): null { + useAllHostClients([retryHost.id], { + closeUnusedOnRelease: true + }) + return null + } + function RetryApp({ settingsVisible }: { settingsVisible: boolean }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(DetailProbe, { hostId: retryHost.id }), + settingsVisible ? createElement(RetrySettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(RetryApp, { settingsVisible: true })) + }) + } finally { + restore() + } + if (!renderer || !resolveInitial || !resolveRetry) { + throw new Error('retry lifecycle harness did not initialize') + } + await act(async () => { + resolveInitial?.([retryHost]) + await initialLookup + }) + if (!context) { + throw new Error('client context was not captured') + } + + const reconnect = context.forceReconnect(retryHost.id) + expect(initialClient.closeMock).toHaveBeenCalledOnce() + act(() => renderer?.update(createElement(RetryApp, { settingsVisible: false }))) + await act(async () => { + resolveRetry?.([retryHost]) + await retryLookup + await reconnect + }) + + expect(activeHostIds()).toEqual([retryHost.id]) + expect(retryClient.closeMock).not.toHaveBeenCalled() + act(() => renderer?.unmount()) + expect(retryClient.closeMock).toHaveBeenCalledOnce() + }) + + it('cancels a released open without cancelling a rapid replacement acquisition', async () => { + const settingsOnlyHost = host('settings-only-offline', 0, { + deviceToken: '', + publicKeyB64: '' + }) + let resolveFirst: ((hosts: HostProfile[]) => void) | null = null + let resolveSecond: ((hosts: HostProfile[]) => void) | null = null + const firstLookup = new Promise((resolve) => { + resolveFirst = resolve + }) + const secondLookup = new Promise((resolve) => { + resolveSecond = resolve + }) + loadHostsMock.mockReturnValueOnce(firstLookup).mockReturnValueOnce(secondLookup) + const client = makeFakeClient('reconnecting') + connectMock.mockReturnValue(client) + + function PendingSettingsProbe(): null { + useAllHostClients([settingsOnlyHost.id], { + closeUnusedOnRelease: true + }) + return null + } + function PendingApp({ visible }: { visible: boolean }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + visible ? createElement(PendingSettingsProbe) : null + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(PendingApp, { visible: true })) + }) + } finally { + restore() + } + if (!renderer || !resolveFirst || !resolveSecond) { + throw new Error('pending settings harness did not initialize') + } + + act(() => renderer?.update(createElement(PendingApp, { visible: false }))) + act(() => renderer?.update(createElement(PendingApp, { visible: true }))) + await act(async () => { + resolveFirst?.([settingsOnlyHost]) + await firstLookup + }) + expect(connectMock).not.toHaveBeenCalled() + + await act(async () => { + resolveSecond?.([settingsOnlyHost]) + await secondLookup + }) + expect(connectMock).toHaveBeenCalledOnce() + + act(() => renderer?.update(createElement(PendingApp, { visible: false }))) + expect(client.closeMock).toHaveBeenCalledOnce() + act(() => renderer?.unmount()) + }) + + it('preserves Home ownership when re-pairing replaces a selected host client', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('connected') + clients.set(profile.id, [...(clients.get(profile.id) ?? []), client]) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + const replacedHostId = HOME_HOST_IDS[0]! + + function ReplacementApp({ + detailVisible, + settingsVisible + }: { + detailVisible: boolean + settingsVisible: boolean + }): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + detailVisible ? createElement(DetailProbe, { hostId: replacedHostId }) : null, + settingsVisible ? createElement(SettingsProbe) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) + ) + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer || !context) { + throw new Error('replacement lifecycle harness did not initialize') + } + + const originalClient = clients.get(replacedHostId)?.[0] + act(() => context?.closeHost(replacedHostId)) + expect(originalClient?.closeMock).toHaveBeenCalledOnce() + + await act(async () => { + renderer?.update( + createElement(ReplacementApp, { detailVisible: true, settingsVisible: false }) + ) + await Promise.resolve() + await Promise.resolve() + }) + const replacementClient = clients.get(replacedHostId)?.[1] + expect(replacementClient).toBeDefined() + + await act(async () => { + renderer?.update( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: true }) + ) + await Promise.resolve() + await Promise.resolve() + }) + act(() => + renderer?.update( + createElement(ReplacementApp, { detailVisible: false, settingsVisible: false }) + ) + ) + + expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort()) + expect(replacementClient?.closeMock).not.toHaveBeenCalled() + act(() => renderer?.unmount()) + expect(replacementClient?.closeMock).toHaveBeenCalledOnce() + }) + + it('releases a manual host after Home demotes or stops tracking it', async () => { + const manualHost = host('manual-relay-host', 1, { relayHostId: 'AbCdEf0123_-xyZ9' }) + const clients: FakeClient[] = [] + connectMock.mockImplementation(() => { + const client = makeFakeClient('reconnecting') + clients.push(client) + return client + }) + loadHostsMock.mockResolvedValue([manualHost]) + + function ManualHomeProbe({ selected }: { selected: boolean }): null { + useAllHostClients([manualHost.id], { + autoConnectHostIds: selected ? [manualHost.id] : [], + closeUnusedOnRelease: true + }) + return null + } + function ManualApp({ + selected, + homeVisible = true + }: { + selected: boolean + homeVisible?: boolean + }) { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + homeVisible ? createElement(ManualHomeProbe, { selected }) : null + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + act(() => { + renderer = create(createElement(ManualApp, { selected: false })) + }) + } finally { + restore() + } + if (!renderer || !context) { + throw new Error('manual connection harness did not initialize') + } + await act(async () => { + await context?.forceReconnect(manualHost.id) + }) + expect(activeHostIds()).toEqual([manualHost.id]) + + await act(async () => { + renderer?.update(createElement(ManualApp, { selected: true })) + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([manualHost.id]) + + await act(async () => { + renderer?.update(createElement(ManualApp, { selected: false })) + await Promise.resolve() + }) + + expect(activeHostIds()).toEqual([]) + expect(clients[0]?.closeMock).toHaveBeenCalledOnce() + + await act(async () => { + await context?.forceReconnect(manualHost.id) + }) + expect(activeHostIds()).toEqual([manualHost.id]) + expect(clients).toHaveLength(2) + + act(() => renderer?.update(createElement(ManualApp, { selected: false, homeVisible: false }))) + expect(activeHostIds()).toEqual([]) + expect(clients[1]?.closeMock).toHaveBeenCalledOnce() + + act(() => renderer?.unmount()) + expect(clients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true) + }) + + it('releases settings-only clients on blur while shared owners stay mounted', async () => { + const clients = new Map() + connectMock.mockImplementation((profile: HostProfile) => { + const client = makeFakeClient('reconnecting') + clients.set(profile.id, client) + return client + }) + loadHostsMock.mockResolvedValue(HOSTS) + const detailHostId = 'folder-workspace-host' + + function FocusStack(): React.JSX.Element { + return createElement( + RpcClientProvider, + null, + createElement( + Fragment, + null, + createElement(ContextProbe), + createElement(HomeProbe), + createElement(DetailProbe, { hostId: detailHostId }), + createElement(FocusedSettingsProbe) + ) + ) + } + + let renderer: ReactTestRenderer | null = null + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(FocusStack)) + await Promise.resolve() + await Promise.resolve() + }) + } finally { + restore() + } + if (!renderer || !routeFocus.effect) { + throw new Error('settings focus harness did not initialize') + } + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + + let blur: (() => void) | undefined + await act(async () => { + const cleanup = routeFocus.effect?.() + if (typeof cleanup === 'function') { + blur = cleanup + } + await Promise.resolve() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOST_IDS].sort()) + + await act(async () => { + blur?.() + await Promise.resolve() + }) + expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort()) + for (const hostId of [...HOME_HOST_IDS, detailHostId]) { + expect(clients.get(hostId)?.closeMock).not.toHaveBeenCalled() + } + expect(clients.get('offline-host')?.closeMock).toHaveBeenCalledOnce() + + act(() => renderer?.unmount()) + }) +}) diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index 400deee90a6..9d96f0561ad 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -75,6 +75,13 @@ export type HostProfile = { relay?: MobileRelayHostOverlay['relay'] } +export type HostCredentialStatus = 'ready' | 'temporarily-unavailable' | 'missing' + +export type HostCatalogEntry = Omit & { + credentialStatus: HostCredentialStatus + profile: HostProfile | null +} + export const HostProfileSchema = z.object({ id: z.string().min(1), name: z.string().min(1), diff --git a/mobile/src/transport/unpaired-host-credential-deletion.ts b/mobile/src/transport/unpaired-host-credential-deletion.ts new file mode 100644 index 00000000000..cc9c27e49ad --- /dev/null +++ b/mobile/src/transport/unpaired-host-credential-deletion.ts @@ -0,0 +1,58 @@ +import { deleteHostDeviceToken } from './host-device-token-store' +import { + clearHostCredentialWriteRevision, + getHostCredentialWriteRevision +} from './host-credential-write-revision' +import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' + +type DeletionDependencies = { + waitForHostMutations: () => Promise + hasStoredHost: (hostId: string) => Promise + onDeleted: (hostId: string) => void +} + +export function createUnpairedHostCredentialDeletion(dependencies: DeletionDependencies) { + function writeRevisionChanged(hostId: string, writeRevision: number): boolean { + return getHostCredentialWriteRevision(hostId) !== writeRevision + } + + function assertWriteRevisionUnchanged(hostId: string, writeRevision: number): void { + if (writeRevisionChanged(hostId, writeRevision)) { + throw new Error('credential write superseded cleanup') + } + } + + async function shouldSkip(hostId: string, writeRevision: number): Promise { + await dependencies.waitForHostMutations() + if (await dependencies.hasStoredHost(hostId)) { + return true + } + assertWriteRevisionUnchanged(hostId, writeRevision) + return false + } + + return async (hostId: string, writeRevision: number): Promise => { + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteHostDeviceToken(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteMobileRelayCredentialBundle(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + await deleteMobileRelayDirectUpgradeJournal(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) + clearHostCredentialWriteRevision(hostId) + dependencies.onDeleted(hostId) + } +} diff --git a/mobile/src/transport/use-all-host-clients.ts b/mobile/src/transport/use-all-host-clients.ts index a1e429b45b0..1000916113b 100644 --- a/mobile/src/transport/use-all-host-clients.ts +++ b/mobile/src/transport/use-all-host-clients.ts @@ -1,56 +1,126 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import type { RpcClient } from './rpc-client' import type { MobileConnectionPath } from './stable-logical-rpc-client' import type { ConnectionState } from './types' import { useRpcClientContext } from './client-context' -// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts. -export function useAllHostClients(hostIds: string[]) { +type UseAllHostClientsOptions = { + autoConnectHostIds?: readonly string[] + closeUnusedOnRelease?: boolean +} + +export function useAllHostClients(hostIds: string[], options?: UseAllHostClientsOptions) { const ctx = useRpcClientContext() - // Stable key so we don't tear down on every render of the array. - const key = useMemo(() => [...hostIds].sort().join(','), [hostIds]) + const autoConnectHostIds = options?.autoConnectHostIds ?? hostIds + const closeUnusedOnRelease = options?.closeUnusedOnRelease ?? false + const key = useMemo( + () => + [ + [...hostIds].sort().join(','), + [...autoConnectHostIds].sort().join(','), + closeUnusedOnRelease ? 'close' : 'keep' + ].join('|'), + [autoConnectHostIds, closeUnusedOnRelease, hostIds] + ) const [tick, setTick] = useState(0) + const acquiredHostIdsRef = useRef>(new Set()) + const hostUnsubscribesRef = useRef void>>(new Map()) + const closeUnusedRef = useRef(closeUnusedOnRelease) useEffect(() => { - if (hostIds.length === 0) { - return - } - for (const id of hostIds) { - ctx.acquire(id) - } - const unsubs: (() => void)[] = [] - for (const id of hostIds) { - unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1))) - } - unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1))) + closeUnusedRef.current = closeUnusedOnRelease + }, [closeUnusedOnRelease]) + + useEffect(() => { + const unsubscribeAllHosts = ctx.subscribeAllHosts(() => setTick((value) => value + 1)) return () => { - for (const u of unsubs) { - u() + unsubscribeAllHosts() + const trackedHostIds = [...hostUnsubscribesRef.current.keys()] + const acquiredHostIds = new Set(acquiredHostIdsRef.current) + for (const unsubscribe of hostUnsubscribesRef.current.values()) { + unsubscribe() } - for (const id of hostIds) { - ctx.release(id) + hostUnsubscribesRef.current.clear() + for (const id of acquiredHostIds) { + if (closeUnusedRef.current) { + ctx.releaseAndCloseIfUnused(id) + } else { + ctx.release(id) + } + } + if (closeUnusedRef.current) { + for (const id of trackedHostIds) { + if (!acquiredHostIds.has(id)) { + ctx.closeIfUnused(id) + } + } + } + acquiredHostIdsRef.current.clear() + } + }, [ctx]) + + useEffect(() => { + const trackedHostIds = new Set(hostIds) + const nextAcquiredHostIds = new Set(autoConnectHostIds.filter((id) => trackedHostIds.has(id))) + const removedTrackedHostIds: string[] = [] + + for (const [id, unsubscribe] of hostUnsubscribesRef.current) { + if (!trackedHostIds.has(id)) { + unsubscribe() + hostUnsubscribesRef.current.delete(id) + removedTrackedHostIds.push(id) } } - }, [key]) + for (const id of trackedHostIds) { + if (!hostUnsubscribesRef.current.has(id)) { + hostUnsubscribesRef.current.set( + id, + ctx.subscribeHostState(id, () => setTick((value) => value + 1)) + ) + } + } + + for (const id of acquiredHostIdsRef.current) { + if (!nextAcquiredHostIds.has(id)) { + if (closeUnusedOnRelease) { + ctx.releaseAndCloseIfUnused(id) + } else { + ctx.release(id) + } + } + } + for (const id of nextAcquiredHostIds) { + if (!acquiredHostIdsRef.current.has(id)) { + ctx.acquire(id) + } + } + if (closeUnusedOnRelease) { + for (const id of removedTrackedHostIds) { + ctx.closeIfUnused(id) + } + for (const id of trackedHostIds) { + if (!nextAcquiredHostIds.has(id)) { + ctx.closeIfUnused(id) + } + } + } + acquiredHostIdsRef.current = nextAcquiredHostIds + }, [ctx, key]) return useMemo(() => { - const out: { + const clientsByHostId = new Map( + ctx.getAllClients().map((entry) => [entry.hostId, entry.client]) + ) + return hostIds.flatMap<{ hostId: string client: RpcClient state: ConnectionState path: MobileConnectionPath - }[] = [] - for (const id of hostIds) { - const all = ctx.getAllClients().find((entry) => entry.hostId === id) - if (all) { - out.push({ - hostId: id, - client: all.client, - state: ctx.getState(id), - path: ctx.getActivePath(id) - }) - } - } - return out - }, [key, tick]) + }>((hostId) => { + const client = clientsByHostId.get(hostId) + return client + ? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }] + : [] + }) + }, [ctx, hostIds, tick]) } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift index 50ecae7d024..7236f7bab2c 100644 --- a/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift @@ -740,25 +740,30 @@ final class Provider { } if let point = center(record.localFrame, in: snapshot.windowBounds) { try Input.click( - pid: snapshot.app.pid, at: point, button: mouseButton(button), count: count, modifiers: modifiers ) - return actionMetadata(path: "synthetic", fallbackReason: "actionUnsupported") + return actionMetadata( + path: "synthetic", + fallbackReason: "actionUnsupported", + verification: unverifiedAction(reason: "synthetic_input") + ) } throw ProviderError.coded("element_not_clickable", "element \(record.index) has no clickable frame") } let point = try coordinatePoint(params: params, xKey: "x", yKey: "y", snapshot: snapshot) try Input.click( - pid: snapshot.app.pid, at: point, button: mouseButton(button), count: count, modifiers: modifiers ) - return actionMetadata(path: "synthetic") + return actionMetadata( + path: "synthetic", + verification: unverifiedAction(reason: "synthetic_input") + ) } private func performClickAction(record: ElementRecord, mouseButton: String) throws -> String? { @@ -2286,7 +2291,6 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? { private enum Input { static func click( - pid: pid_t, at point: CGPoint, button: MouseButton, count: Int, @@ -2298,10 +2302,32 @@ private enum Input { let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in result.insert(modifier.flag) } - for _ in 0.. 0 { + event.setIntegerValueField(.mouseEventClickState, value: clickState) + } + event.post(tap: .cghidEventTap) + usleep(SyntheticMouseClickDelivery.interEventPauseMicroseconds) } } diff --git a/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift new file mode 100644 index 00000000000..d61e56af35c --- /dev/null +++ b/native/computer-use-macos/Sources/OrcaComputerUseMacOSCore/SyntheticMouseClickDelivery.swift @@ -0,0 +1,40 @@ +/// Event plan for synthetic mouse clicks (STA-3433). +/// +/// Clicks must be posted to the HID event tap, not `CGEventPostToPid`: +/// pid-targeted mouse events reach the app with no window association, so +/// AppKit never routes the press to a view (hover fires, activation never +/// happens). The window server also drops a mouseUp posted back-to-back +/// with its mouseDown, so consecutive events need a pause between them. +public enum SyntheticMouseClickDelivery { + public enum Step: Equatable { + case move + case buttonDown(pressIndex: Int) + case buttonUp(pressIndex: Int) + } + + /// Pause after posting each event; unpaced posts race the window + /// server's routing and the mouseUp is silently dropped. + public static let interEventPauseMicroseconds: UInt32 = 50_000 + + /// One move, then a paired down/up per press. `pressIndex` becomes the + /// event's click state so repeated presses register as double/triple + /// clicks instead of independent single clicks. + public static func steps(clickCount: Int) -> [Step] { + var steps: [Step] = [.move] + for press in 1...max(clickCount, 1) { + steps.append(.buttonDown(pressIndex: press)) + steps.append(.buttonUp(pressIndex: press)) + } + return steps + } + + /// Click state field value for a step; 0 leaves the field unset. + public static func clickState(for step: Step) -> Int64 { + switch step { + case .move: + return 0 + case let .buttonDown(pressIndex), let .buttonUp(pressIndex): + return Int64(pressIndex) + } + } +} diff --git a/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift new file mode 100644 index 00000000000..d80a5e9e8ee --- /dev/null +++ b/native/computer-use-macos/Tests/OrcaComputerUseMacOSTests/SyntheticMouseClickDeliveryTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import OrcaComputerUseMacOSCore + +final class SyntheticMouseClickDeliveryTests: XCTestCase { + func testSingleClickPlanPairsDownAndUpAfterMove() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: 1), + [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)] + ) + } + + func testMultiClickPlanNumbersEachPressForClickState() { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: 2), + [ + .move, + .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1), + .buttonDown(pressIndex: 2), .buttonUp(pressIndex: 2), + ] + ) + } + + func testNonPositiveClickCountStillDeliversOnePress() { + for count in [0, -3] { + XCTAssertEqual( + SyntheticMouseClickDelivery.steps(clickCount: count), + [.move, .buttonDown(pressIndex: 1), .buttonUp(pressIndex: 1)] + ) + } + } + + func testClickStateMatchesPressIndexAndSkipsMove() { + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .move), 0) + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .buttonDown(pressIndex: 1)), 1) + XCTAssertEqual(SyntheticMouseClickDelivery.clickState(for: .buttonUp(pressIndex: 2)), 2) + } + + func testInterEventPauseIsNonZero() { + // Unpaced posts race the window server and the mouseUp is dropped, + // turning the click into a hover-only no-op (STA-3433). + XCTAssertGreaterThan(SyntheticMouseClickDelivery.interEventPauseMicroseconds, 0) + } +} diff --git a/package.json b/package.json index cbc7862f8fd..d800838281b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.169-rc.0", + "version": "1.4.174-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index 7da8936ea1a..bc7b1cbbe3e 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -192,6 +192,14 @@ orca orchestration worker-start --task --worktree current --agent claud `current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `. +For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt: + +```bash +orca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json +``` + +`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option. + For a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal: ```bash diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index fadfd439030..1fcb8e0afd1 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.\n\nTreat the authority label on injected or formatted messages as definitive:\n\n- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.\n- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.\n- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.\n- An unlabeled current message uses the current guide and current grammar.\n\nAn explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.\n\nDatabase provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.\n\nCompatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.\n\nWhen a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.\n\nOn packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume ` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.\n\nLegacy inspection remains available without consuming mail:\n\n```bash\norca orchestration run-list --json\n# run_legacy_local is an empty audit tombstone after adoption.\norca orchestration run-show --id run_legacy_local --json\n# In run-list, find the ordinary Run whose objective is:\n# \"Recovered orchestration work from a contract update\"\norca orchestration run-show --id --json\norca orchestration task-list --run --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --format --json\norca terminal read --terminal --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\n```\n\nIf the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:\n\n```bash\norca orchestration run-use --id --takeover-legacy --json\norca orchestration check --run --json\n```\n\nTakeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.\n\nDo not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:\n\n```bash\norca orchestration worker-start --task --worktree current --agent claude --model aws-bedrock-opus-5 --effort high --json\n```\n\n`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message. For each accepted worker_done that is not immediately reused:\norca orchestration worker-release --dispatch --json\n# Acknowledge only after every message and required release decision is handled:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nAfter processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch --json`, then run `orca orchestration worker-start --task --terminal --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch --json`.\n\nRun `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.\n\nDo not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--include-visual-layouts] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. The coordinator may reuse or release this terminal after it processes your report; do not start more work, poll, or attempt to close the terminal yourself. If it reuses you, it re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index 95a0a7db313..a2777ec3fdb 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -8,6 +8,7 @@ vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) import { ORCHESTRATION_HANDLERS } from './orchestration' import { printResult } from '../format' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' describe('orchestration worker-start CLI contract', () => { beforeEach(() => { @@ -84,6 +85,63 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBeUndefined() }) + it('capability-gates and forwards per-invocation launch preferences', async () => { + callMock + .mockResolvedValueOnce({ + result: { + capabilities: [ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY] + } + }) + .mockResolvedValueOnce({ + result: { + runId: 'run_1', + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'ready', + effects: [], + residualResources: [] + } + }) + + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'claude'], + ['model', 'aws-bedrock-opus-5'], + ['effort', 'high'], + ['from', 'term_coord'] + ]) + ) + + expect(callMock).toHaveBeenNthCalledWith(1, 'status.get') + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'orchestration.workerStart', + expect.objectContaining({ + agent: 'claude', + model: 'aws-bedrock-opus-5', + effort: 'high' + }) + ) + }) + + it('fails before worker-start when the runtime would strip launch preferences', async () => { + callMock.mockResolvedValueOnce({ result: { capabilities: [] } }) + + await expect( + invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'codex'], + ['model', 'gpt-5.6-sol'], + ['from', 'term_coord'] + ]) + ) + ).rejects.toMatchObject({ code: 'incompatible_runtime' }) + + expect(callMock).toHaveBeenCalledTimes(1) + }) + it('sets an unsuccessful exit code for failed and unknown receipts', async () => { callMock.mockResolvedValue({ result: { diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index f9cbdb8a41a..6668b4e1906 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -20,7 +20,8 @@ import type { OrchestrationWorkerReadSource } from '../../shared/orchestration-worker-output' import type { NativeChatMessage } from '../../shared/native-chat-types' -import type { RuntimeTerminalRead } from '../../shared/runtime-types' +import type { RuntimeStatus, RuntimeTerminalRead } from '../../shared/runtime-types' +import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination' import { @@ -852,6 +853,21 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration worker-start': async ({ flags, client, cwd, json }) => { + const model = getOptionalStringFlag(flags, 'model') + const effort = getOptionalStringFlag(flags, 'effort') + if (model || effort) { + const status = await client.call('status.get') + if ( + !status.result.capabilities?.includes( + ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY + ) + ) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The connected Orca runtime does not support worker model or effort overrides. Update or restart Orca and try again.' + ) + } + } const result = await callMutation<{ runId: string taskId: string @@ -873,6 +889,8 @@ export const ORCHESTRATION_HANDLERS: Record = { comment: getOptionalStringFlag(flags, 'comment'), setup: getOptionalStringFlag(flags, 'setup'), agent: getOptionalStringFlag(flags, 'agent'), + model, + effort, terminal: getOptionalStringFlag(flags, 'terminal'), retryOf: getOptionalStringFlag(flags, 'retry-of'), timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), diff --git a/src/cli/help.ts b/src/cli/help.ts index e866a91ef22..3415ddad832 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -552,6 +552,8 @@ export function formatFlagHelp(flag: string): string { local: '--local Target the current project instead of the global install', skill: '--skill Bundled skill to act on; repeat for several', mode: '--mode Mode such as edit, diff, or both', + model: '--model Provider model id for a new agent launch', + effort: '--effort Reasoning effort for the selected model', 'mouse-button': '--mouse-button Mouse button: left, right, or middle', modifiers: '--modifiers Modifier keys held only for this click', name: '--name Name for the new worktree or automation', diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 2764a239021..7cba4d1cdc1 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -17,6 +17,7 @@ import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_PROTOCOL_VERSION, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY @@ -61,7 +62,10 @@ describe('CLI remote WebSocket transport', () => { expect(response.result.runtimeId).toBe('runtime-ws-1') expect(runtime.authFrames).toContainEqual( expect.objectContaining({ - clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + clientCapabilities: [ + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY + ] }) ) }) diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index a8d8b2411f5..b7f6b583a73 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -5,7 +5,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'worker-start'], summary: 'Start one supervised worker on the Run home or a connected Orca server', usage: - 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', + 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--model ] [--effort ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'task', @@ -18,6 +18,8 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'comment', 'setup', 'agent', + 'model', + 'effort', 'terminal', 'retry-of', 'timeout-ms', @@ -27,6 +29,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ ], notes: [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', + '--model supports Claude, Codex, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', '--on selects only the worker server; the Run and this command remain on the current Orca server.', diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index e0688b44345..a6d5d61fd41 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -161,7 +161,14 @@ export function wrapWindowsGitBashHookCommand(scriptPath: string): string { : wrapWindowsHookCommand(scriptPath) } -export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string { +/** + * Extra form lines inserted before the final `payload@-` line (each should end with ` ^`). + * Used by Grok to attach `grokHome` without fragile string replace on the shared template. + */ +export function buildWindowsAgentHookPostCommand( + source: AgentHookSource, + extraFormLines: readonly string[] = [] +): string { // Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path. // Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads. return [ @@ -175,6 +182,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin ' --data-urlencode "worktreeId=%ORCA_WORKTREE_ID%" ^', ' --data-urlencode "env=%ORCA_AGENT_HOOK_ENV%" ^', ' --data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%" ^', + ...extraFormLines, ' --data-urlencode "payload@-" >nul 2>nul' ].join('\r\n') } diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 96bb121c7e7..c1870d35a7c 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -2257,6 +2257,36 @@ describe('AgentHookServer listener replay', () => { } }) + it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const postHook = (payload: Record): Promise => + fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(buildBody(payload, { launchToken: 'retired-launch-token' })) + }) + + await postHook({ hook_event_name: 'UserPromptSubmit', prompt: 'first launch' }) + server.retirePaneAuthority(PANE) + + // Why: `claude --resume` in the reused shell pane emits only SessionStart while idle; + // without the un-retire the resumed session stays rowless until a prompt (STA-3386). + await postHook({ hook_event_name: 'SessionStart', source: 'resume' }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, state: 'done', sessionBoundary: true }) + ]) + } finally { + server.stop() + } + }) + it('accepts a live remote prompt but not replay after launch authority retires', () => { const server = new AgentHookServer() server.ingestRemote( diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 0f9e0a7812d..013b90d7143 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -415,7 +415,10 @@ function equivalentParsedAgentStatusPayload( a.toolInput === b.toolInput && a.interactivePrompt === b.interactivePrompt && a.lastAssistantMessage === b.lastAssistantMessage && - a.interrupted === b.interrupted + a.interrupted === b.interrupted && + // Why: a session-boundary done must never be deduped against a cached real done — + // the flag has to reach receivers deterministically (STA-3386). + a.sessionBoundary === b.sessionBoundary ) } @@ -996,7 +999,12 @@ export class AgentHookServer { return 'accept' } // Why: command completion retires launch authority but leaves its shell pane reusable. - if (event?.hookEventName === 'UserPromptSubmit' && event.isReplay !== true) { + // A live SessionStart proves a new agent process owns the retired pane just like a + // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + if ( + (event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart') && + event.isReplay !== true + ) { this.closedAgentStatusPaneKeys.delete(paneKey) this.closedAgentStatusPaneKeys.delete(ownerPaneKey) return 'restart' diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index a309f51077b..91f21eab720 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -3,7 +3,6 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote-session-file-stat' -import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' @@ -25,9 +24,7 @@ export async function discoverRemoteSourceCandidates(args: { const walked = args.source.fixedChildFileSegments ? await listRemoteFixedChildFiles(args.source, args.context, args.issues) : await walkRemoteSessionFiles(args.source, args.context, args.issues) - const partition = args.source.collectSubagentSiblingCounts - ? partitionSubagentTranscriptPaths(walked) - : null + const partition = args.source.partitionSubagentTranscripts?.(walked) ?? null const paths = partition ? partition.sessionFilePaths : walked const files = await mapRemoteScanBatches( paths, diff --git a/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts b/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts new file mode 100644 index 00000000000..8185765e4d2 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-omp-subagents.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' + +describe('scanRemoteAiVaultSessions OMP subagent partitioning', () => { + it('excludes remote OMP artifact-dir transcripts and counts them on the parent', async () => { + const stem = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' + const workspaceDir = '/home/ada/.omp/agent/sessions/home-app-85dfa2f0' + const provider = new MemoryRemoteProvider() + provider.addFile( + `${workspaceDir}/${stem}.jsonl`, + jsonLines([ + { + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/home/ada/repo', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Delegate some tasks' } + } + ]), + 70 + ) + for (const label of ['AuthAndPreflight', 'BitbucketDcApi']) { + provider.addFile( + `${workspaceDir}/${stem}/${label}.jsonl`, + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:01:00.000Z', + message: { role: 'user', content: `${label} task prompt` } + } + ]), + 71 + ) + } + // A grandchild belongs to its own parent: neither a row nor a count here. + provider.addFile( + `${workspaceDir}/${stem}/AuthAndPreflight/Nested.jsonl`, + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:02:00.000Z', + message: { role: 'user', content: 'Nested task prompt' } + } + ]), + 72 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'omp', + sessionId: 'cccccccc-dddd-4eee-8fff-000000000000', + subagentTranscriptCount: 2, + filePath: `${workspaceDir}/${stem}.jsonl` + }) + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index d704c2da671..debdc3881fc 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -12,6 +12,8 @@ import { parseGeminiSessionContent } from './session-scanner-gemini-parsers' import { parseCopilotSessionContent } from './session-scanner-copilot-parser' import { parseCursorSessionContent } from './session-scanner-cursor-parser' import { parseHermesSessionContent } from './session-scanner-hermes-parser' +import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' +import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index' @@ -49,7 +51,7 @@ export function remoteSessionSources( // subagent counts instead. Partitioning also prunes the subagent // transcripts themselves, which would otherwise list as phantom // top-level sessions carrying the parent's sessionId. - collectSubagentSiblingCounts: true + partitionSubagentTranscripts: partitionSubagentTranscriptPaths }, remoteAntigravitySource(remoteHome, hostPlatform), source( @@ -92,7 +94,13 @@ export function remoteSessionSources( parseDevinSessionContent ), jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser), - jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), + { + ...jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser), + // Same posture as Claude above: OMP stores task-subagent transcripts in + // the session's same-named artifact dir; the walk supplies counts and the + // partition keeps the children out of the top-level list (#9330). + partitionSubagentTranscripts: partitionOmpSubagentTranscriptPaths + }, jsonlSource( 'droid', remoteHome, diff --git a/src/main/ai-vault/remote-session-scanner-test-fixtures.ts b/src/main/ai-vault/remote-session-scanner-test-fixtures.ts new file mode 100644 index 00000000000..c3496590168 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-test-fixtures.ts @@ -0,0 +1,100 @@ +import type { DirEntry } from '../../shared/types' +import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' + +// Shared by the remote AI Vault scanner suites: an in-memory filesystem whose +// directory tree is derived from the file paths added to it. +export class MemoryRemoteProvider implements IFilesystemProvider { + private readonly files = new Map() + private readonly readDirErrors = new Map() + private readonly statErrors = new Map() + readonly readDirPaths: string[] = [] + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + failStat(path: string, error: Error): void { + this.statErrors.set(normalize(path), error) + } + + failReadDir(path: string, error: Error): void { + this.readDirErrors.set(normalize(path), error) + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + this.readDirPaths.push(dir) + const readDirError = this.readDirErrors.get(dir) + if (readDirError) { + throw readDirError + } + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) { + continue + } + const relative = path.slice(prefix.length) + if (!relative) { + continue + } + const [name, ...rest] = relative.split('/') + if (!name) { + continue + } + entries.set(name, { + name, + isDirectory: rest.length > 0, + isSymlink: false + }) + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) + } + + async readFile(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { content: file.content, isBinary: false } + } + + async stat(filePath: string): Promise { + const statError = this.statErrors.get(normalize(filePath)) + if (statError) { + throw statError + } + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } + } + + writeFile = unsupported + writeFileBase64 = unsupported + writeFileBase64Chunk = unsupported + deletePath = unsupported + createFile = unsupported + createDir = unsupported + createDirNoClobber = unsupported + rename = unsupported + renameNoClobber = unsupported + copy = unsupported + realpath = async (path: string): Promise => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +async function unsupported(): Promise { + throw new Error('unsupported') +} + +function normalize(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +export function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 58fb255e3cc..8c9774abe14 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -3,6 +3,7 @@ import type { ExecutionHostId } from '../../shared/execution-host' import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import type { FileWithMtime } from './session-scanner-types' +import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { @@ -36,9 +37,10 @@ export type RemoteSessionSource = { directoryPredicate?: (name: string, depth: number) => boolean // A canonical file directly beneath every top-level session directory. fixedChildFileSegments?: readonly string[] - // Claude layout: count `/subagents/*.jsonl` siblings from the walked - // listing and drop them from candidates instead of indexing them as sessions. - collectSubagentSiblingCounts?: boolean + // Sibling-subagent layouts (Claude `/subagents/`, OMP's same-named + // artifact dir): count subagent transcripts from the walked listing and drop + // them from candidates instead of indexing them as sessions. + partitionSubagentTranscripts?: (paths: readonly string[]) => SubagentTranscriptPartition parse: ( file: FileWithMtime, content: string, diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index a4705ee1bfb..462ced9aa72 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -1,104 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { DirEntry } from '../../shared/types' -import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from './remote-session-scanner' - -class MemoryRemoteProvider implements IFilesystemProvider { - private readonly files = new Map() - private readonly readDirErrors = new Map() - private readonly statErrors = new Map() - readonly readDirPaths: string[] = [] - - addFile(path: string, content: string, mtimeMs: number): void { - this.files.set(normalize(path), { content, mtimeMs }) - } - - failStat(path: string, error: Error): void { - this.statErrors.set(normalize(path), error) - } - - failReadDir(path: string, error: Error): void { - this.readDirErrors.set(normalize(path), error) - } - - async readDir(dirPath: string): Promise { - const dir = normalize(dirPath) - this.readDirPaths.push(dir) - const readDirError = this.readDirErrors.get(dir) - if (readDirError) { - throw readDirError - } - const prefix = dir.endsWith('/') ? dir : `${dir}/` - const entries = new Map() - for (const path of this.files.keys()) { - if (!path.startsWith(prefix)) { - continue - } - const relative = path.slice(prefix.length) - if (!relative) { - continue - } - const [name, ...rest] = relative.split('/') - if (!name) { - continue - } - entries.set(name, { - name, - isDirectory: rest.length > 0, - isSymlink: false - }) - } - return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) - } - - async readFile(filePath: string): Promise { - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { content: file.content, isBinary: false } - } - - async stat(filePath: string): Promise { - const statError = this.statErrors.get(normalize(filePath)) - if (statError) { - throw statError - } - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } - } - - writeFile = unsupported - writeFileBase64 = unsupported - writeFileBase64Chunk = unsupported - deletePath = unsupported - createFile = unsupported - createDir = unsupported - createDirNoClobber = unsupported - rename = unsupported - renameNoClobber = unsupported - copy = unsupported - realpath = async (path: string): Promise => path - search = unsupported - listFiles = unsupported - watch = unsupported -} - -async function unsupported(): Promise { - throw new Error('unsupported') -} - -function normalize(path: string): string { - return path.replace(/\\/g, '/').replace(/\/+$/, '') -} - -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} +import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures' describe('scanRemoteAiVaultSessions', () => { it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index b4d0c171171..8aeb89089fc 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -5,6 +5,7 @@ import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' +import { withOmpSubagentTranscriptCount } from './session-scanner-omp-subagent-transcripts' import type { FileWithMtime, ResumableSessionParseState, @@ -239,10 +240,14 @@ export function createMessageGraphSessionResumeState( agent: MessageGraphAgent, file: FileWithMtime ): ResumableSessionParseState { - return accumulatorFoldResumeState( + const state = accumulatorFoldResumeState( createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }), consumeMessageGraphRecordLine ) + // Why: only OMP materializes task-subagent transcripts beside its sessions + // (in the same-named artifact dir); the row UI shows the count without + // expanding details. Pi/OpenClaw have no such layout — skip the readdir. + return agent === 'omp' ? withOmpSubagentTranscriptCount(state, file.path) : state } async function parseMessageGraphSessionLines(args: { diff --git a/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts new file mode 100644 index 00000000000..16511e14239 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-listing.test.ts @@ -0,0 +1,81 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listOmpSubagentSessions } from './session-scanner-omp-subagent-listing' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' +const PARENT_SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function childTranscript(id: string, timestamp: string, prompt: string): string { + return [ + JSON.stringify({ type: 'session', version: 3, id, cwd: '/repo/app', timestamp }), + JSON.stringify({ type: 'message', timestamp, message: { role: 'user', content: prompt } }) + ].join('\n') +} + +describe('listOmpSubagentSessions', () => { + it('lists artifact-dir transcripts under the parent, titled by task label', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'orca-omp-subagent-list-')) + tempRoots.push(workspace) + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + const artifactDir = join(workspace, SESSION_STEM) + await mkdir(join(artifactDir, 'local'), { recursive: true }) + await writeFile(parentPath, '') + await writeFile( + join(artifactDir, 'AuthAndPreflight.jsonl'), + childTranscript( + 'aaaaaaaa-bbbb-4ccc-8ddd-222222222222', + '2026-05-01T10:01:00.000Z', + 'Map the auth surface' + ) + ) + await writeFile( + join(artifactDir, 'BitbucketDcApi.jsonl'), + childTranscript( + 'aaaaaaaa-bbbb-4ccc-8ddd-333333333333', + '2026-05-01T10:02:00.000Z', + 'Read the DC API spec' + ) + ) + // Artifacts are not transcripts; nested files belong to their own parents. + await writeFile(join(artifactDir, 'notes.md'), 'not a transcript') + await writeFile(join(artifactDir, 'local', 'plan.jsonl'), '{}') + + const result = await listOmpSubagentSessions({ + parentFilePath: parentPath, + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + // Newest first, titled by the coordinator-given task label, each linked to + // the parent derived from the layout (not the child's own session record). + expect( + result.sessions.map((session) => ({ + title: session.title, + parent: session.subagent?.parentSessionId + })) + ).toEqual([ + { title: 'BitbucketDcApi', parent: PARENT_SESSION_ID }, + { title: 'AuthAndPreflight', parent: PARENT_SESSION_ID } + ]) + }) + + it('resolves empty for a session that never delegated', async () => { + const workspace = await mkdtemp(join(tmpdir(), 'orca-omp-subagent-list-')) + tempRoots.push(workspace) + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + await writeFile(parentPath, '') + + await expect( + listOmpSubagentSessions({ parentFilePath: parentPath, platform: 'darwin' }) + ).resolves.toEqual({ sessions: [], issues: [] }) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-listing.ts b/src/main/ai-vault/session-scanner-omp-subagent-listing.ts new file mode 100644 index 00000000000..0efcdbdc92c --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-listing.ts @@ -0,0 +1,113 @@ +import { readdir, stat } from 'node:fs/promises' +import { basename, extname, join } from 'node:path' +import type { + AiVaultScanIssue, + AiVaultSession, + AiVaultSubagentListResult +} from '../../shared/ai-vault-types' +import { sessionIdFromFileName, sessionSortTime } from './session-scanner-accumulator' +import { parseMessageGraphSessionFile } from './session-scanner-graph-parsers' +import { + isOmpSubagentTranscriptFileName, + ompArtifactDirFor +} from './session-scanner-omp-subagent-transcripts' +import { errorMessage } from './session-scanner-values' + +// Match the Claude subagent lister's deliberate parse batching: opening every +// read stream at once stalls over WSL UNC paths. +const OMP_SUBAGENT_PARSE_CONCURRENCY = 8 + +/** + * List the task-subagent transcripts of one OMP session, on demand. The main + * scan prunes session artifact directories for speed, so this is the only path + * that reads them — and only when the user expands a session's details. + * (Lives apart from session-scanner-omp-subagent-transcripts.ts so the graph + * parser can import the count decorator without a parser↔lister import cycle — + * the same split as Claude's transcripts/lister pair.) + */ +export async function listOmpSubagentSessions(args: { + parentFilePath: string + platform?: NodeJS.Platform +}): Promise { + const platform = args.platform ?? process.platform + const issues: AiVaultScanIssue[] = [] + const artifactDir = ompArtifactDirFor(args.parentFilePath) + + let entries + try { + entries = await readdir(artifactDir, { withFileTypes: true }) + } catch { + return { sessions: [], issues } + } + + const transcriptNames = entries + .filter((entry) => isOmpSubagentTranscriptFileName(entry.name, entry.isFile())) + .map((entry) => entry.name) + if (transcriptNames.length === 0) { + return { sessions: [], issues } + } + + // Why: the layout fixes the parent's sessionId (_.jsonl → the + // artifact dir); deriving it here keeps a child linking to its parent even + // when the child transcript carries its own distinct session record. + const parentSessionId = sessionIdFromFileName(args.parentFilePath) + const parsed: (AiVaultSession | null)[] = [] + for (let index = 0; index < transcriptNames.length; index += OMP_SUBAGENT_PARSE_CONCURRENCY) { + const batch = transcriptNames.slice(index, index + OMP_SUBAGENT_PARSE_CONCURRENCY) + const batchResults = await Promise.all( + batch.map((name) => + parseOmpSubagentTranscript({ artifactDir, name, parentSessionId, platform, issues }) + ) + ) + parsed.push(...batchResults) + } + + return { + sessions: parsed + .filter((session): session is AiVaultSession => session !== null) + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)), + issues + } +} + +async function parseOmpSubagentTranscript(args: { + artifactDir: string + name: string + parentSessionId: string + platform: NodeJS.Platform + issues: AiVaultScanIssue[] +}): Promise { + const filePath = join(args.artifactDir, args.name) + try { + const fileStat = await stat(filePath) + // The shared OMP parser decorates every parse with an artifact-dir count, so + // a child row carries its own grandchild count. It is accurate but has no + // renderer — subagent rows don't expand — and this lister is local-only, so + // the remote partition never reaches it. + const session = await parseMessageGraphSessionFile( + 'omp', + { path: filePath, mtimeMs: fileStat.mtimeMs, modifiedAt: fileStat.mtime.toISOString() }, + args.platform + ) + if (!session) { + return null + } + return { + ...session, + // Why: OMP names each child transcript after the task's label — the name + // the coordinator gave it. That beats the transcript-derived fallback + // (the raw task prompt). + title: basename(args.name, extname(args.name)), + subagent: { + parentSessionId: args.parentSessionId, + agentType: null, + // OMP parents don't record per-child terminal statuses in a shape this + // lister can attribute; unknown beats a stale guess. + status: null + } + } + } catch (err) { + args.issues.push({ agent: 'omp', path: filePath, message: errorMessage(err) }) + return null + } +} diff --git a/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts new file mode 100644 index 00000000000..7a8314962b7 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-prune.test.ts @@ -0,0 +1,149 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' +const SESSION_ID = 'cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +describe('scanAiVaultSessions OMP subagent pruning', () => { + it('prunes artifact-dir transcripts instead of listing them as sessions', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-prune-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspaceDir = join(roots.ompSessionsDir, 'home-app-85dfa2f0') + const artifactDir = join(workspaceDir, SESSION_STEM) + await mkdir(artifactDir, { recursive: true }) + + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Delegate some tasks' } + } + ]) + ) + await writeFile( + join(artifactDir, 'AuthAndPreflight.jsonl'), + jsonLines([ + { + type: 'session', + version: 3, + id: 'aaaaaaaa-bbbb-4ccc-8ddd-222222222222', + cwd: '/repo/app', + timestamp: '2026-05-01T10:01:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:01:01.000Z', + message: { role: 'user', content: 'Subagent task prompt' } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.issues).toEqual([]) + // Only the coordinator surfaces; the task child is not a row, but it is + // still counted for the row's "N subagents" affordance (#9330). + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + sessionId: SESSION_ID, + subagentTranscriptCount: 1 + }) + }) + + it('still scans a workspace dir whose name looks like a session stem', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-depth-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + // Depth 0 is the workspace dir. Pruning by name alone would hide every + // session in a workspace that happened to be named like a session stem. + const workspaceDir = join(roots.ompSessionsDir, SESSION_STEM) + await mkdir(workspaceDir, { recursive: true }) + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }, + { + type: 'message', + timestamp: '2026-05-01T10:00:01.000Z', + message: { role: 'user', content: 'Hello from a stem-named workspace' } + } + ]) + ) + + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + + expect(result.sessions.map((entry) => entry.sessionId)).toContain(SESSION_ID) + }) + + it('picks up a task transcript written after the coordinator last changed', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-omp-late-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const workspaceDir = join(roots.ompSessionsDir, 'home-app-85dfa2f0') + await mkdir(workspaceDir, { recursive: true }) + // A coordinator that persisted no conversation turns: its file never + // changes again, so only the cached-reuse recount can see a late child. + await writeFile( + join(workspaceDir, `${SESSION_STEM}.jsonl`), + jsonLines([ + { + type: 'session', + version: 3, + id: SESSION_ID, + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + } + ]) + ) + + const first = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect(first.sessions.find((entry) => entry.sessionId === SESSION_ID)).toMatchObject({ + messageCount: 0, + subagentTranscriptCount: 0 + }) + + await mkdir(join(workspaceDir, SESSION_STEM), { recursive: true }) + await writeFile( + join(workspaceDir, SESSION_STEM, 'AuthAndPreflight.jsonl'), + jsonLines([ + { + type: 'message', + timestamp: '2026-05-01T10:01:01.000Z', + message: { role: 'user', content: 'Late task prompt' } + } + ]) + ) + + const second = await scanAiVaultSessions({ ...roots, platform: 'darwin' }) + expect( + second.sessions.find((entry) => entry.sessionId === SESSION_ID)?.subagentTranscriptCount + ).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts new file mode 100644 index 00000000000..b8c802a021f --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.test.ts @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers' +import { + OMP_SESSION_ARTIFACT_DIR_PATTERN, + countOmpSubagentTranscripts, + partitionOmpSubagentTranscriptPaths +} from './session-scanner-omp-subagent-transcripts' + +const SESSION_STEM = '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeWorkspaceDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-omp-subagents-')) + tempRoots.push(root) + return root +} + +describe('OMP_SESSION_ARTIFACT_DIR_PATTERN', () => { + it('matches session artifact dir names and nothing else in the layout', () => { + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test(SESSION_STEM)).toBe(true) + // Task children are label-named. + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test('AuthAndPreflight')).toBe(false) + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test('local')).toBe(false) + // Workspace dir names OMP encodes from a cwd (relative to home, relative to + // tmp, absolute, or slug-hashed) never collide with a session stem. The + // local prune doesn't depend on this — it skips depth 0 — but the remote + // partition has no root to measure depth against, so it does. + for (const workspaceDirName of [ + `-${SESSION_STEM}`, + `-tmp-${SESSION_STEM}`, + `--${SESSION_STEM}--`, + 'home-app-85dfa2f063812c58976deb581167a634a4' + ]) { + expect(OMP_SESSION_ARTIFACT_DIR_PATTERN.test(workspaceDirName)).toBe(false) + } + }) +}) + +describe('countOmpSubagentTranscripts', () => { + it('counts only direct-child transcripts of the artifact dir', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + const artifactDir = join(workspace, SESSION_STEM) + await mkdir(join(artifactDir, 'local'), { recursive: true }) + await writeFile(parentPath, '') + await writeFile(join(artifactDir, 'AuthAndPreflight.jsonl'), '') + await writeFile(join(artifactDir, 'BitbucketDcApi.jsonl'), '') + // Artifacts are not transcripts; nested files belong to their own parents. + await writeFile(join(artifactDir, 'notes.md'), '') + await writeFile(join(artifactDir, 'local', 'plan.jsonl'), '') + + await expect(countOmpSubagentTranscripts(parentPath)).resolves.toBe(2) + }) + + it('returns 0 when the session never delegated (no artifact dir)', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + await writeFile(parentPath, '') + + await expect(countOmpSubagentTranscripts(parentPath)).resolves.toBe(0) + }) +}) + +describe('partitionOmpSubagentTranscriptPaths', () => { + it('drops artifact-dir transcripts from candidates and counts them per parent', () => { + const workspace = `/home/user/.omp/agent/sessions/home-app-85dfa2f0` + const parent = `${workspace}/${SESSION_STEM}.jsonl` + const sibling = `${workspace}/2026-05-02T09-00-00-000Z_dddddddd-eeee-4fff-8aaa-111111111111.jsonl` + const partition = partitionOmpSubagentTranscriptPaths([ + parent, + `${workspace}/${SESSION_STEM}/AuthAndPreflight.jsonl`, + `${workspace}/${SESSION_STEM}/BitbucketDcApi.jsonl`, + // A grandchild attributes to its own parent, not the top-level session — + // and never surfaces as a candidate. + `${workspace}/${SESSION_STEM}/AuthAndPreflight/Nested.jsonl`, + sibling + ]) + + expect(partition.sessionFilePaths).toEqual([parent, sibling]) + expect(partition.subagentTranscriptCounts.get(parent)).toBe(2) + expect(partition.subagentTranscriptCounts.size).toBe(1) + }) + + it('attributes a child to its nearest stamped ancestor, not the outermost one', () => { + const workspace = '/home/user/.omp/agent/sessions/home-app-85dfa2f0' + const nested = '2026-05-02T09-00-00-000Z_dddddddd-eeee-4fff-8aaa-111111111111' + const partition = partitionOmpSubagentTranscriptPaths([ + `${workspace}/${SESSION_STEM}/${nested}/Grandchild.jsonl` + ]) + + expect(partition.sessionFilePaths).toEqual([]) + expect([...partition.subagentTranscriptCounts]).toEqual([ + [`${workspace}/${SESSION_STEM}/${nested}.jsonl`, 1] + ]) + }) + + it('handles Windows separators', () => { + const workspace = `C:\\Users\\u\\.omp\\agent\\sessions\\home-app-85dfa2f0` + const parent = `${workspace}\\${SESSION_STEM}.jsonl` + const partition = partitionOmpSubagentTranscriptPaths([ + parent, + `${workspace}\\${SESSION_STEM}\\AuthAndPreflight.jsonl` + ]) + + expect(partition.sessionFilePaths).toEqual([parent]) + expect(partition.subagentTranscriptCounts.get(parent)).toBe(1) + }) +}) + +describe('withOmpSubagentTranscriptCount host gating', () => { + const content = JSON.stringify({ + type: 'session', + version: 3, + id: 'cccccccc-dddd-4eee-8fff-000000000000', + cwd: '/repo/app', + timestamp: '2026-05-01T10:00:00.000Z' + }) + + it('never counts a local artifact dir for a remote-host transcript', async () => { + const workspace = await makeWorkspaceDir() + const parentPath = join(workspace, `${SESSION_STEM}.jsonl`) + // A local artifact dir exists at the same path, but the content came from + // an SSH host — its real task children live on that host, not on this disk. + await mkdir(join(workspace, SESSION_STEM), { recursive: true }) + await writeFile(join(workspace, SESSION_STEM, 'AuthAndPreflight.jsonl'), '') + const file = { path: parentPath, mtimeMs: 0, modifiedAt: '2026-05-01T10:00:00.000Z' } + + const remote = await parseMessageGraphSessionContent('omp', file, content, 'linux', { + executionHostId: 'ssh:host' + }) + const local = await parseMessageGraphSessionContent('omp', file, content, 'darwin') + + expect(remote?.subagentTranscriptCount).toBe(0) + expect(local?.subagentTranscriptCount).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts new file mode 100644 index 00000000000..9136e8c0d34 --- /dev/null +++ b/src/main/ai-vault/session-scanner-omp-subagent-transcripts.ts @@ -0,0 +1,119 @@ +import { readdir } from 'node:fs/promises' +import { basename, dirname, extname, join } from 'node:path' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { SubagentTranscriptPartition } from './session-scanner-subagent-transcripts' +import type { ResumableSessionParseState } from './session-scanner-types' + +// OMP names every session `_` and stores that session's +// artifacts — including its task-subagent transcripts — in a sibling directory +// of the same name (…//_.jsonl + …/_/). +// The anchored name pattern is the classification signal: task-child +// transcripts are label-named, so they can't match. Workspace dirs are encoded +// from a cwd and can't realistically match either, but the local prune doesn't +// rely on that — it skips depth 0 outright. This deliberately reads structure +// rather than a transcript's own records: children carry their own session id +// and no parentSession field at all, so lineage is not available here (#9330). +const OMP_SESSION_DIR_NAME = String.raw`\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}` + +// Exported so discovery prunes exactly the subtrees this module reads on +// demand — the pruning predicate and the lister can't drift apart. +export const OMP_SESSION_ARTIFACT_DIR_PATTERN = new RegExp(`^${OMP_SESSION_DIR_NAME}$`, 'i') + +// Direct child of a session artifact dir: `…/_/