Merge remote-tracking branch 'origin/main' into OrcaWin/windows-worktree-agent-resume

This commit is contained in:
OrcaWin
2026-07-21 10:35:50 -07:00
570 changed files with 37010 additions and 5765 deletions
@@ -0,0 +1,71 @@
const { createHash } = require('node:crypto')
const { readFileSync, renameSync, rmSync, writeFileSync } = require('node:fs')
const { join, resolve } = require('node:path')
const EXPECTED_NODE_PTY_VERSION = '1.1.0'
const ORIGINAL_SOURCE_SHA256 = '0d010879bb6680a0253d44363183d53e631f42972594eb6dcb1fb842c8c85e52'
const PATCHED_SOURCE_SHA256 = '84df20cfe711a88d2bef35078615c58a6ce14f39348a4aef40e852b854dcd857'
const ORIGINAL_BODY = 'var consoleProcessList = getConsoleProcessList(shellPid);'
const PATCHED_BODY = `var consoleProcessList;
try {
consoleProcessList = getConsoleProcessList(shellPid);
}
catch (_a) {
// Why: AttachConsole can fail without a Win32 console; use node-pty's timeout fallback immediately.
consoleProcessList = [shellPid];
}`
function inspectNodePtyConsoleListAgent(relayDir = process.cwd()) {
const nodePtyDir = resolve(relayDir, 'node_modules', 'node-pty')
const packageJsonPath = join(nodePtyDir, 'package.json')
const agentPath = join(nodePtyDir, 'lib', 'conpty_console_list_agent.js')
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
if (packageJson.version !== EXPECTED_NODE_PTY_VERSION) {
throw new Error(
`Refusing to patch node-pty ${packageJson.version}; expected ${EXPECTED_NODE_PTY_VERSION}`
)
}
const source = readFileSync(agentPath, 'utf8')
return { agentPath, source }
}
function assertPatchedNodePtyConsoleListAgent(relayDir = process.cwd()) {
const inspected = inspectNodePtyConsoleListAgent(relayDir)
if (sourceSha256(inspected.source) !== PATCHED_SOURCE_SHA256) {
throw new Error('node-pty ConPTY console-list fallback is not installed')
}
}
function patchNodePtyConsoleListAgent(relayDir = process.cwd()) {
const inspected = inspectNodePtyConsoleListAgent(relayDir)
const sourceHash = sourceSha256(inspected.source)
if (sourceHash === PATCHED_SOURCE_SHA256) {
return
}
if (sourceHash !== ORIGINAL_SOURCE_SHA256) {
throw new Error('Refusing to patch unexpected node-pty console-list agent source')
}
const patchedSource = inspected.source.replace(ORIGINAL_BODY, PATCHED_BODY)
const temporaryPath = `${inspected.agentPath}.orca-patch-${process.pid}`
// Why: a terminated remote install must leave either known source version recoverable on reconnect.
try {
writeFileSync(temporaryPath, patchedSource)
renameSync(temporaryPath, inspected.agentPath)
} finally {
rmSync(temporaryPath, { force: true })
}
assertPatchedNodePtyConsoleListAgent(relayDir)
}
function sourceSha256(source) {
return createHash('sha256').update(source).digest('hex')
}
if (require.main === module) {
patchNodePtyConsoleListAgent()
}
module.exports = {
assertPatchedNodePtyConsoleListAgent,
patchNodePtyConsoleListAgent
}
+48 -15
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-19",
"updatedAt": "2026-07-20",
"policy": {
"maturityLevels": [
"experimental",
@@ -1224,7 +1224,8 @@
"PTY sizing",
"split layout",
"restore",
"hidden-to-visible transitions"
"hidden-to-visible transitions",
"window wake"
],
"platforms": [
"macos",
@@ -1241,7 +1242,7 @@
"macos"
],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Shell-visible size, SSH/remote geometry, Windows ConPTY readback, and resume-time reassertion coverage arrive with the pending stack and #7006.",
"coverageNotes": "Local macOS evidence on main@1282f5c2d, including #7192's runtime-mirror geometry authority slice. Deterministic provider-contract coverage now includes settled window-wake reassertion and SSH relay applied-size readback. Live shell-visible SSH/remote geometry and Windows ConPTY readback remain non-blocking gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6644",
"https://github.com/stablyai/orca/pull/6649",
@@ -1252,15 +1253,19 @@
"https://github.com/stablyai/orca/pull/7192"
],
"invariant": "A visible desktop-owned terminal cannot trust 0x0, stale requested size, or renderer-only size; xterm, fit/proposed size, applied PTY size, shell-visible size, and the runtime mirror's parse dimensions must converge or enter explicit degraded state, and mirror resize reflow must stay ordered with queued output writes.",
"oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, and hidden-to-visible activation, then asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, and visibility resume reasserts real drift without hot listSessions. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.",
"oracle": "The current executable slice uses deterministic frame schedulers and fake providers to force 0x0 first fit, delayed layout settle, dropped resize/readback drift, hidden-to-visible activation, and window wake. It asserts the renderer forwards a usable size, pty:getSize reports applied rather than merely requested size where available, visibility resume reasserts real drift without hot listSessions, one settled wake produces one geometry-only readback, and SSH relay readback is authoritative with bounded fallback. Shell-visible stty/echo-wrap convergence remains a live-gate follow-up.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts",
"src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts",
"src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts",
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
"src/main/ipc/pty.test.ts",
"src/main/runtime/orca-runtime.test.ts"
"src/main/providers/ssh-pty-provider.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/relay/pty-handler.test.ts"
],
"assertionRefs": [
{
@@ -1271,6 +1276,13 @@
"applied-size readback divergence is re-forwarded before handoff"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts",
"assertions": [
"only one applied-size read stays in flight while the grid changes",
"stale in-flight targets never resize the PTY back to an older grid"
]
},
{
"file": "src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts",
"assertions": [
@@ -1279,11 +1291,26 @@
"hidden 0x0 background spawn is not forced to phantom desktop size"
]
},
{
"file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
"assertions": [
"window focus reasserts PTY size after fitting",
"the immediate and settled wake fits produce exactly one geometry-only reassertion"
]
},
{
"file": "src/main/ipc/pty.test.ts",
"assertions": [
"accepted desktop resizes fan out to the runtime after provider resize",
"rejected desktop resizes do not fan out to the runtime"
"rejected desktop resizes do not fan out to the runtime",
"provider-owned null remains unverified instead of falling back to requested size"
]
},
{
"file": "src/main/providers/ssh-pty-provider.test.ts",
"assertions": [
"SSH applied-size readback uses the relay with a one-second timeout",
"old-relay method-not-found is cached while transient failures remain retryable"
]
},
{
@@ -1292,17 +1319,24 @@
"the headless mirror resizes after an accepted desktop PTY resize (#7192)",
"headless mirror resizes are ordered behind queued PTY writes (#7192)"
]
},
{
"file": "src/relay/pty-handler.test.ts",
"assertions": [
"relay readback reports the grid actually applied by node-pty",
"missing relay PTYs return an unverified null size"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-03",
"date": "2026-07-20",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/main/ipc/pty.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/runtime/orca-runtime.test.ts src/relay/pty-handler.test.ts",
"result": "passed",
"durationSeconds": 8.4,
"summary": "4 test file(s) passed, 777 tests passed on main@1282f5c2d in a clean checkout."
"durationSeconds": 18.54,
"summary": "8 test files passed, 1,272 tests passed on the PR branch."
}
],
"runtimeBudget": {
@@ -1319,16 +1353,15 @@
},
"performanceBudget": {
"required": true,
"evidence": "Must include resize/throughput budget so convergence loops do not add frame or SIGWINCH churn."
"evidence": "The wake hook count test permits two visual fits but exactly one applied-size read per visible pane and no broad visibility/agent lifecycle call. SSH readback has a one-second timeout, caches stable method-not-found, and retries only transient failures. No polling, listSessions scan, subprocess, or unbounded retry was added."
},
"promotionCriteria": [
"Build deterministic fault hooks before adding broad E2E coverage.",
"Keep stress variants non-blocking until runtime and flake history are known."
],
"knownGaps": [
"No live Electron geometry command yet beyond the local live PTY resize slice.",
"The runtime mirror geometry slice is unregistered: #7192's red tests (orca-runtime.test.ts headless-mirror resize and ordering, pty.test.ts desktop resize fan-out) exist on fresh main but not at this branch's merge-base, so they can only be registered during the rebase.",
"Does not yet prove shell-visible size, provider-applied size, and xterm size converge across SSH/remote paths.",
"Local and Docker-backed SSH Electron wake specs exist but remain manual/non-blocking pending runtime and flake history.",
"The deterministic gate proves the SSH provider/relay readback contract, not live shell-visible convergence across every remote path.",
"Does not yet prove Windows ConPTY geometry/readback.",
"Current command uses deterministic fake providers for the main/renderer contracts, not a real remote PTY."
],
+21 -4
View File
@@ -10,7 +10,7 @@
*/
import { build } from 'esbuild'
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const __dirname = import.meta.dirname
@@ -26,6 +26,13 @@ const MANAGED_HOOK_RUNTIME_ENTRY = join(
'managed-hook-runtime.ts'
)
const JSONC_PARSER_ESM_ENTRY = join(ROOT, 'node_modules', 'jsonc-parser', 'lib', 'esm', 'main.js')
const NODE_PTY_CONSOLE_LIST_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
const NODE_PTY_CONSOLE_LIST_PATCH_SOURCE = join(
ROOT,
'config',
'relay-assets',
NODE_PTY_CONSOLE_LIST_PATCH_FILENAME
)
const PLATFORMS = [
'linux-x64',
@@ -59,6 +66,13 @@ for (const platform of PLATFORMS) {
}
})
if (platform.startsWith('win32-')) {
copyFileSync(
NODE_PTY_CONSOLE_LIST_PATCH_SOURCE,
join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)
)
}
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
@@ -101,9 +115,12 @@ for (const platform of PLATFORMS) {
.update(relayContent)
.update(watcherContent)
.update(managedHookRuntimeContent)
.digest('hex')
.slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
if (platform.startsWith('win32-')) {
hash.update(readFileSync(join(outDir, NODE_PTY_CONSOLE_LIST_PATCH_FILENAME)))
}
const contentHash = hash.digest('hex').slice(0, 12)
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${contentHash}`)
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
}
@@ -0,0 +1,79 @@
import { createRequire } from 'node:module'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
assertPatchedNodePtyConsoleListAgent,
patchNodePtyConsoleListAgent
} = require('../relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs')
const projectDir = resolve(import.meta.dirname, '..', '..')
const cleanupDirs = []
afterEach(() => {
for (const dir of cleanupDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
describe('Windows SSH relay node-pty console-list patch', () => {
it('installs and verifies the fallback idempotently', () => {
const fixture = writeNodePtyFixture('1.1.0', publishedAgentSource())
patchNodePtyConsoleListAgent(fixture.root)
const once = readFileSync(fixture.agentPath, 'utf8')
expect(once).toContain('consoleProcessList = [shellPid];')
expect(existsSync(`${fixture.agentPath}.orca-patch-${process.pid}`)).toBe(false)
expect(() => assertPatchedNodePtyConsoleListAgent(fixture.root)).not.toThrow()
patchNodePtyConsoleListAgent(fixture.root)
expect(readFileSync(fixture.agentPath, 'utf8')).toBe(once)
})
it('refuses a different package version or unexpected agent source', () => {
const wrongVersion = writeNodePtyFixture('1.2.0-beta.11', publishedAgentSource())
expect(() => patchNodePtyConsoleListAgent(wrongVersion.root)).toThrow('expected 1.1.0')
const drifted = writeNodePtyFixture('1.1.0', `${publishedAgentSource()}\n// drift`)
expect(() => patchNodePtyConsoleListAgent(drifted.root)).toThrow('unexpected node-pty')
const tamperedPatch = writeNodePtyFixture('1.1.0', publishedAgentSource())
patchNodePtyConsoleListAgent(tamperedPatch.root)
writeFileSync(tamperedPatch.agentPath, `${readFileSync(tamperedPatch.agentPath)}\n// drift`)
expect(() => assertPatchedNodePtyConsoleListAgent(tamperedPatch.root)).toThrow('not installed')
})
})
function writeNodePtyFixture(version, agentSource) {
const root = mkdtempSync(join(projectDir, '.node-pty-console-list-patch-test-'))
cleanupDirs.push(root)
const nodePtyDir = join(root, 'node_modules', 'node-pty')
const libDir = join(nodePtyDir, 'lib')
const agentPath = join(libDir, 'conpty_console_list_agent.js')
mkdirSync(libDir, { recursive: true })
writeFileSync(join(nodePtyDir, 'package.json'), JSON.stringify({ version }))
writeFileSync(agentPath, agentSource)
return { root, agentPath }
}
function publishedAgentSource() {
return [
'"use strict";',
'/**',
' * Copyright (c) 2019, Microsoft Corporation (MIT License).',
' *',
' * This module fetches the console process list for a particular PID. It must be',
' * called from a different process (child_process.fork) as there can only be a',
' * single console attached to a process.',
' */',
'Object.defineProperty(exports, "__esModule", { value: true });',
'var utils_1 = require("./utils");',
"var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;",
'var shellPid = parseInt(process.argv[2], 10);',
'var consoleProcessList = getConsoleProcessList(shellPid);',
'process.send({ consoleProcessList: consoleProcessList });',
'process.exit(0);',
'//# sourceMappingURL=conpty_console_list_agent.js.map'
].join('\n')
}
@@ -217,6 +217,23 @@ describe('Electron runtime package contract', () => {
)
})
it('packages and verifies the Windows SSH node-pty console-list fallback', () => {
const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
const relayDeploy = readFileSync(join(projectDir, 'src/main/ssh/ssh-relay-deploy.ts'), 'utf8')
const patchAsset = readFileSync(
join(projectDir, 'config/relay-assets/node-pty-1.1.0-console-list-agent-patch.cjs'),
'utf8'
)
expect(relayBuild).toContain('copyFileSync(')
expect(relayBuild).toContain('hash.update(readFileSync')
expect(relayBuild).toContain('node-pty-1.1.0-console-list-agent-patch.cjs')
expect(relayDeploy).toContain('assertPatchedNodePtyConsoleListAgent')
expect(relayDeploy.match(/\$\{windowsNodePtyPatchCommand\(nodePath\)\}/g)).toHaveLength(2)
expect(patchAsset).toContain('consoleProcessList = [shellPid];')
expect(patchAsset).toContain('packageJson.version !== EXPECTED_NODE_PTY_VERSION')
})
it('pins the Windows release builder to the VS 2022 runner image', () => {
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
@@ -0,0 +1,41 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_WEB_CLIENT: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/multi-client-navigation-isolation.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
@@ -0,0 +1,435 @@
import { spawn, spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { copyFileSync, cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { createConnection } from 'node:net'
import { join, resolve } from 'node:path'
const projectDir = resolve(import.meta.dirname, '..', '..')
const relayBuildDir = join(projectDir, 'out', 'relay', 'win32-x64')
const SENTINEL = Buffer.from('ORCA-RELAY v0.1.0 READY\n')
const HEADER_LENGTH = 13
const REGRESSION_TIMEOUT_MS = 15_000
const NODE_PTY_PATCH_FILENAME = 'node-pty-1.1.0-console-list-agent-patch.cjs'
if (process.platform !== 'win32') {
console.log('SKIP: Windows ConPTY regression only runs on Windows.')
process.exit(0)
}
const options = parseArgs(process.argv.slice(2))
const nodePath = resolve(options.node ?? process.execPath)
const nodePtyDir = resolve(options.nodePty ?? join(projectDir, 'node_modules', 'node-pty'))
const expectFailure = options.expect === 'attach-console-failure'
for (const required of [
nodePath,
nodePtyDir,
join(relayBuildDir, 'relay.js'),
join(relayBuildDir, '.version')
]) {
if (!existsSync(required)) {
throw new Error(`Required regression input is missing: ${required}`)
}
}
const runDir = mkdtempSync(join(projectDir, '.issue-9586-relay-repro-'))
const relayPath = join(runDir, 'relay.js')
const stdoutLog = join(runDir, 'relay.log')
const stderrLog = join(runDir, 'relay.err.log')
const socketPath = `\\\\.\\pipe\\orca-issue-9586-${process.pid}-${Date.now()}`
let relayPid
try {
prepareRelayTree(runDir, nodePtyDir)
if (!options.skipRelayPatch) {
applyPackagedNodePtyPatch(nodePath, runDir)
}
relayPid = launchRelayWithoutConsole({
nodePath,
relayPath,
runDir,
socketPath,
stdoutLog,
stderrLog
})
await waitForPipe(socketPath, 5_000)
const observation = await exerciseRelayClient({
nodePath,
relayPath,
runDir,
socketPath,
shell: options.shell
})
await waitForExit(relayPid, 8_000)
const relayStdout = readIfPresent(stdoutLog)
const relayStderr = readIfPresent(stderrLog)
const attachConsoleFailed = relayStderr.includes('Error: AttachConsole failed')
const installedNodePtyDir = join(runDir, 'node_modules', 'node-pty')
const agentPath = join(installedNodePtyDir, 'lib', 'conpty_console_list_agent.js')
const nativeBindingPath = join(
installedNodePtyDir,
'prebuilds',
`${process.platform}-${process.arch}`,
'conpty.node'
)
const summary = {
node: observation.nodeVersion,
nodePty: JSON.parse(readFileSync(join(nodePtyDir, 'package.json'), 'utf8')).version,
shell: options.shell,
relayPid,
handshake: observation.handshake,
ptySpawn: observation.ptySpawn,
ptyShutdown: observation.ptyShutdown,
relayAliveAfterPtyShutdown: observation.relayAliveAfterPtyShutdown,
bridgeExit: observation.bridgeExit,
relayPatchApplied: !options.skipRelayPatch,
agentSha256: fileSha256(agentPath),
nativeBindingPresent: existsSync(nativeBindingPath),
attachConsoleFailed,
relayExitedAfterClientDisconnect: !isProcessAlive(relayPid)
}
console.log(JSON.stringify(summary, null, 2))
if (expectFailure !== attachConsoleFailed) {
throw new Error(
expectFailure
? `Expected the real console-list agent to fail AttachConsole. stderr:\n${relayStderr}`
: `The real console-list agent failed AttachConsole. stderr:\n${relayStderr}`
)
}
if (
!observation.handshake ||
!observation.ptySpawn ||
!observation.ptyShutdown ||
!observation.relayAliveAfterPtyShutdown
) {
throw new Error(
`Relay lifecycle did not complete. stdout:\n${relayStdout}\nstderr:\n${relayStderr}`
)
}
} finally {
if (relayPid && isProcessAlive(relayPid)) {
stopExactProcess(relayPid, relayPath)
}
rmSync(runDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 })
}
function parseArgs(args) {
const parsed = { expect: 'clean', shell: 'cmd.exe', skipRelayPatch: false }
for (let index = 0; index < args.length; index++) {
const flag = args[index]
if (flag === '--skip-relay-patch') {
parsed.skipRelayPatch = true
continue
}
const value = args[index + 1]
if (!value || !['--node', '--node-pty', '--expect', '--shell'].includes(flag)) {
throw new Error(
'Usage: node windows-ssh-attach-console-repro.mjs [--node PATH] [--node-pty DIR] [--shell PATH] [--skip-relay-patch] [--expect clean|attach-console-failure]'
)
}
if (flag === '--node') {
parsed.node = value
}
if (flag === '--node-pty') {
parsed.nodePty = value
}
if (flag === '--expect') {
parsed.expect = value
}
if (flag === '--shell') {
parsed.shell = value
}
index++
}
if (!['clean', 'attach-console-failure'].includes(parsed.expect)) {
throw new Error(`Unsupported expectation: ${parsed.expect}`)
}
return parsed
}
function prepareRelayTree(runDir, nodePtyDir) {
for (const filename of [
'relay.js',
'relay-watcher.js',
'managed-hook-runtime.js',
NODE_PTY_PATCH_FILENAME,
'.version'
]) {
copyFileSync(join(relayBuildDir, filename), join(runDir, filename))
}
cpSync(nodePtyDir, join(runDir, 'node_modules', 'node-pty'), { recursive: true })
}
function applyPackagedNodePtyPatch(nodePath, runDir) {
const result = spawnSync(nodePath, [join(runDir, NODE_PTY_PATCH_FILENAME)], {
cwd: runDir,
encoding: 'utf8',
windowsHide: true
})
if (result.status !== 0) {
throw new Error(`Packaged node-pty patch failed: ${result.stderr || result.stdout}`)
}
}
function launchRelayWithoutConsole({
nodePath,
relayPath,
runDir,
socketPath,
stdoutLog,
stderrLog
}) {
const relayArgs = [
quoteWindowsArg(nodePath),
quoteWindowsArg(relayPath),
'--detached',
'--grace-time',
'5',
'--sock-path',
quoteWindowsArg(socketPath),
'--endpoint-dir',
quoteWindowsArg(join(runDir, 'endpoint')),
'--log-file',
quoteWindowsArg(stdoutLog),
`1>${quoteWindowsArg(stdoutLog)}`,
`2>${quoteWindowsArg(stderrLog)}`
].join(' ')
const commandLine = `cmd.exe /d /s /c "${relayArgs}"`
const script = [
`$result = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ${powerShellLiteral(commandLine)}; CurrentDirectory = ${powerShellLiteral(runDir)} }`,
`if ($result.ReturnValue -ne 0) { throw "Win32_Process.Create failed with $($result.ReturnValue)" }`,
'$result.ProcessId'
].join('; ')
const launched = runPowerShell(script)
const pid = Number(launched.stdout.trim())
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error(`Could not parse detached relay pid from: ${launched.stdout}`)
}
return pid
}
function exerciseRelayClient({ nodePath, relayPath, runDir, socketPath, shell }) {
return new Promise((resolvePromise, rejectPromise) => {
const bridge = spawn(nodePath, [relayPath, '--connect', '--sock-path', socketPath], {
cwd: runDir,
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe']
})
const observation = {
nodeVersion: readNodeVersion(nodePath),
handshake: false,
ptySpawn: false,
ptyShutdown: false,
relayAliveAfterPtyShutdown: false,
bridgeExit: null
}
let stderr = ''
let buffer = Buffer.alloc(0)
let sentinelRead = false
let outgoingSequence = 1
let settled = false
const timeout = setTimeout(
() => finish(new Error(`Timed out waiting for relay client lifecycle. stderr:\n${stderr}`)),
REGRESSION_TIMEOUT_MS
)
bridge.stderr.on('data', (data) => {
stderr += data.toString()
})
bridge.on('error', finish)
bridge.on('close', (code, signal) => {
observation.bridgeExit = { code, signal }
if (!settled && observation.relayAliveAfterPtyShutdown) {
finish()
} else if (!settled) {
finish(new Error(`Relay bridge closed before lifecycle completed. stderr:\n${stderr}`))
}
})
bridge.stdout.on('data', (data) => {
try {
buffer = Buffer.concat([buffer, data])
if (!sentinelRead) {
const sentinelIndex = buffer.indexOf(SENTINEL)
if (sentinelIndex === -1) {
return
}
buffer = buffer.subarray(sentinelIndex + SENTINEL.length)
sentinelRead = true
observation.handshake = true
request(1, 'pty.spawn', {
shellOverride: shell,
cwd: projectDir,
cols: 80,
rows: 24,
env: {}
})
}
for (const message of drainMessages()) {
if (message.id === 1) {
throwResponseError(message)
observation.ptySpawn = true
request(2, 'pty.shutdown', { id: message.result.id, immediate: true })
} else if (message.id === 2) {
throwResponseError(message)
observation.ptyShutdown = true
request(3, 'relay.status', {})
} else if (message.id === 3) {
throwResponseError(message)
observation.relayAliveAfterPtyShutdown = message.result.ptys.active === 0
bridge.stdin.end()
}
}
} catch (error) {
finish(error)
}
})
function request(id, method, params) {
bridge.stdin.write(encodeRequestFrame({ id, method, params }, outgoingSequence++))
}
function drainMessages() {
const messages = []
while (buffer.length >= HEADER_LENGTH) {
const type = buffer[0]
const payloadLength = buffer.readUInt32BE(9)
if (buffer.length < HEADER_LENGTH + payloadLength) {
break
}
const payload = buffer.subarray(HEADER_LENGTH, HEADER_LENGTH + payloadLength)
buffer = buffer.subarray(HEADER_LENGTH + payloadLength)
if (type === 1) {
messages.push(JSON.parse(payload.toString('utf8')))
}
}
return messages
}
function finish(error) {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (error) {
bridge.kill()
rejectPromise(error)
} else {
resolvePromise(observation)
}
}
})
}
function waitForPipe(socketPath, timeoutMs) {
const deadline = Date.now() + timeoutMs
return new Promise((resolvePromise, rejectPromise) => {
const attempt = () => {
const socket = createConnection(socketPath)
let settled = false
const retry = () => {
if (settled) {
return
}
settled = true
socket.destroy()
if (Date.now() >= deadline) {
rejectPromise(new Error(`Detached relay did not listen on ${socketPath}`))
} else {
setTimeout(attempt, 50)
}
}
socket.once('connect', () => {
if (settled) {
return
}
settled = true
socket.destroy()
resolvePromise()
})
socket.once('error', retry)
socket.setTimeout(250, retry)
}
attempt()
})
}
function encodeRequestFrame({ id, method, params }, sequence) {
const payload = Buffer.from(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
const header = Buffer.alloc(HEADER_LENGTH)
header[0] = 1
header.writeUInt32BE(sequence, 1)
header.writeUInt32BE(0, 5)
header.writeUInt32BE(payload.length, 9)
return Buffer.concat([header, payload])
}
function throwResponseError(message) {
if (message.error) {
throw new Error(`Relay RPC failed: ${message.error.message}`)
}
}
function readNodeVersion(nodePath) {
return spawnSync(nodePath, ['--version'], { encoding: 'utf8' }).stdout.trim()
}
function waitForExit(pid, timeoutMs) {
const deadline = Date.now() + timeoutMs
return new Promise((resolvePromise) => {
const poll = () => {
if (!isProcessAlive(pid) || Date.now() >= deadline) {
resolvePromise()
return
}
setTimeout(poll, 50)
}
poll()
})
}
function isProcessAlive(pid) {
const result = runPowerShell(
`if (Get-Process -Id ${pid} -ErrorAction SilentlyContinue) { 'ALIVE' }`
)
return result.stdout.includes('ALIVE')
}
function stopExactProcess(pid, relayPath) {
const script = [
`$process = Get-CimInstance Win32_Process -Filter ${powerShellLiteral(`ProcessId = ${pid}`)}`,
`if ($process -and $process.CommandLine -like ${powerShellLiteral(`*${relayPath}*`)}) { Stop-Process -Id ${pid} -Force }`
].join('; ')
runPowerShell(script)
}
function runPowerShell(script) {
const encoded = Buffer.from(script, 'utf16le').toString('base64')
const result = spawnSync(
'powershell.exe',
['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new Error(`PowerShell failed (${result.status}): ${result.stderr || result.stdout}`)
}
return result
}
function quoteWindowsArg(value) {
return `"${value.replaceAll('"', '\\"')}"`
}
function powerShellLiteral(value) {
return `'${value.replaceAll("'", "''")}'`
}
function readIfPresent(path) {
return existsSync(path) ? readFileSync(path, 'utf8') : ''
}
function fileSha256(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
+1
View File
@@ -13,6 +13,7 @@
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.ts",
"../src/main/claude/statusline-script.ts",
"../src/main/codex/codex-app-server-capability-cache.ts",
"../src/main/codex/codex-app-server-capability-signal.ts",
"../src/main/codex/codex-app-server-client.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 6.7m">
<title>downloads: 6.7m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.0m">
<title>downloads: 7.0m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">6.7m</text>
<text x="93.5" y="14">6.7m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">7.0m</text>
<text x="93.5" y="14">7.0m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+439 -3
View File
@@ -15,7 +15,7 @@ Install the AppImage runtime dependency and Xvfb:
```bash
sudo apt-get update
sudo apt-get install -y curl libfuse2 xvfb
sudo apt-get install -y curl file libfuse2 xvfb
```
Download and make the AppImage executable:
@@ -56,11 +56,14 @@ The command prints the runtime endpoint and pairing URL. Stop it with `Ctrl+C`.
## Systemd Service
Create a dedicated service user and install directory. Run the service as this
user instead of root so the AppImage can keep Chromium's sandbox enabled.
user instead of root so the AppImage can keep Chromium's sandbox enabled. Keep
the install directory root-owned: the service needs to read and execute the
AppImage, but must not be able to replace it or the rollback artifacts.
```bash
sudo useradd --system --create-home --shell /usr/sbin/nologin orca
sudo chown -R orca:orca /opt/orca
sudo chown root:root /opt/orca /opt/orca/orca-linux.AppImage
sudo chmod 755 /opt/orca /opt/orca/orca-linux.AppImage
```
For most hosts, one `orca serve` service is enough because Orca starts Xvfb on
@@ -165,6 +168,436 @@ If you later install the desktop CLI from Orca settings, use that CLI for normal
shell workflows. Keep the AppImage path in systemd so service restarts do not
depend on an interactive shell profile.
## Upgrade
`orca serve` never updates itself. In headless mode Orca wires up no auto-updater
at all — the built-in updater only runs in the desktop GUI, and no paired mobile
or web client can trigger it remotely. Upgrading is always a deliberate step:
replace the AppImage and restart the service.
Two facts make this safe and predictable:
- **State lives in the service user's home, not next to the binary.** Persisted
data is under `/home/orca/.config/` (Orca uses both an `orca` and an `Orca`
directory there), fully independent of `/opt/orca/orca-linux.AppImage`.
Replacing the binary never touches projects, worktree metadata, terminal
history, orchestration state, or paired-device keys — so mobile and web
clients reconnect after an upgrade without re-pairing.
- **New builds migrate old state on load.** Orca loads older `orca-data.json`
state into the current schema and writes it back in the current shape, so a
forward upgrade needs no manual data step.
Rolling back is the case that needs care — see [Roll back](#roll-back).
### Record the version you deploy
Orca has no headless version command: there is no `--version` flag or `version`
subcommand, and `orca serve` prints only its endpoint. Choose a release tag
explicitly instead of following the `latest` URL, and record it next to the
binary so upgrades are auditable. The steps below keep that record in
`/opt/orca/VERSION`.
### Upgrade steps
Never download straight onto `/opt/orca/orca-linux.AppImage`. The AppImage is
FUSE-mounted, so overwriting it in place while the service runs can crash or
corrupt the live process — and even with the service stopped, a failed or partial
download would clobber the working binary. Instead download to a temporary name
on the same filesystem, verify it, then swap it in with an atomic rename.
Check capacity before starting:
```bash
sudo chown root:root /opt/orca
sudo chmod 755 /opt/orca
sudo test ! -L /opt/orca/orca-linux.AppImage
sudo chown root:root /opt/orca/orca-linux.AppImage
sudo chmod 755 /opt/orca/orca-linux.AppImage
# Clear predictable staging names left by an older attempt after locking the directory
sudo rm -f /opt/orca/orca-linux.AppImage.new /opt/orca/VERSION.new \
/opt/orca/orca-linux.AppImage.recovering /opt/orca/VERSION.recovering
sudo du -sh /home/orca/.config
df -h /opt/orca /home/orca
```
`/opt/orca` needs room for the compressed Orca profile archive, the staged
build, and the rollback binary. A rollback extracts the old profile and preserves
the post-upgrade Orca profile directories, so `/home` needs room for both copies.
Run the following block as one Bash script so its fail-fast and recovery traps
remain active for the whole operation:
```bash
set -euo pipefail
# Replace this example with the release tag you intend to deploy
ORCA_VERSION=v1.4.147
# Select the release asset on the server where Orca runs
case "$(uname -m)" in
x86_64)
ORCA_ASSET=orca-linux.AppImage
ORCA_FILE_MACHINE=x86-64
;;
aarch64 | arm64)
ORCA_ASSET=orca-linux-arm64.AppImage
ORCA_FILE_MACHINE='ARM aarch64'
;;
*)
echo "Unsupported architecture: $(uname -m)" >&2
exit 1
;;
esac
ORCA_ROLLBACK_NEW=
ORCA_ROLLBACK=
ORCA_SERVICE_STOPPED=0
ORCA_BINARY_PROMOTED=0
recover_failed_upgrade() {
exit_status=$?
trap - EXIT
set +e
if ((exit_status != 0)); then
sudo rm -f /opt/orca/orca-linux.AppImage.new /opt/orca/VERSION.new \
/opt/orca/orca-linux.AppImage.recovering /opt/orca/VERSION.recovering
fi
if ((exit_status != 0)) && [[ -n "$ORCA_ROLLBACK_NEW" ]] && \
sudo test -d "$ORCA_ROLLBACK_NEW"; then
sudo rm -rf -- "$ORCA_ROLLBACK_NEW"
fi
if ((exit_status != 0 && ORCA_SERVICE_STOPPED)); then
recovery_ok=1
if ((ORCA_BINARY_PROMOTED)); then
if ! sudo cp -a "$ORCA_ROLLBACK/orca-linux.AppImage" \
/opt/orca/orca-linux.AppImage.recovering || \
! sudo mv -f /opt/orca/orca-linux.AppImage.recovering \
/opt/orca/orca-linux.AppImage; then
recovery_ok=0
fi
if sudo test -f "$ORCA_ROLLBACK/VERSION"; then
if ! sudo cp -a "$ORCA_ROLLBACK/VERSION" /opt/orca/VERSION.recovering || \
! sudo mv -f /opt/orca/VERSION.recovering /opt/orca/VERSION; then
recovery_ok=0
fi
elif ! sudo rm -f /opt/orca/VERSION; then
recovery_ok=0
fi
fi
sudo rm -f /opt/orca/orca-linux.AppImage.recovering \
/opt/orca/VERSION.recovering
if ((recovery_ok)); then
sudo systemctl start orca-serve.service || true
else
echo 'Upgrade recovery failed; service remains stopped' >&2
fi
fi
exit "$exit_status"
}
trap recover_failed_upgrade EXIT
# 1. Stage and verify the new build while the server stays online
sudo curl -fL --retry 3 "https://github.com/stablyai/orca/releases/download/${ORCA_VERSION}/${ORCA_ASSET}" \
-o /opt/orca/orca-linux.AppImage.new
sudo chown root:root /opt/orca/orca-linux.AppImage.new
sudo chmod 755 /opt/orca/orca-linux.AppImage.new
# Both checks must match; either grep stops this fail-fast block otherwise
ORCA_FILE_INFO=$(LC_ALL=C file /opt/orca/orca-linux.AppImage.new)
grep 'ELF .* executable' <<<"$ORCA_FILE_INFO"
grep -F "$ORCA_FILE_MACHINE" <<<"$ORCA_FILE_INFO"
# 2. Assemble the prior binary and version in a root-only rollback bundle
ORCA_ROLLBACK_BASE=/opt/orca/orca-rollback-$(date +%F-%H%M%S-%N)
ORCA_ROLLBACK_NEW=${ORCA_ROLLBACK_BASE}.new
ORCA_ROLLBACK=${ORCA_ROLLBACK_BASE}.ready
sudo install -d -m 700 "$ORCA_ROLLBACK_NEW"
sudo cp -a /opt/orca/orca-linux.AppImage "$ORCA_ROLLBACK_NEW/orca-linux.AppImage"
if sudo test -f /opt/orca/VERSION; then
sudo cp -a /opt/orca/VERSION "$ORCA_ROLLBACK_NEW/VERSION"
fi
# Stage the new version record before the stop window
printf '%s\n' "$ORCA_VERSION" | sudo tee /opt/orca/VERSION.new >/dev/null
sudo chown root:root /opt/orca/VERSION.new
sudo chmod 644 /opt/orca/VERSION.new
# 3. Stop the server so the profile backup is consistent
ORCA_SERVICE_STOPPED=1
sudo systemctl stop orca-serve.service
# Add only Orca-owned profile directories, then publish the complete bundle
ORCA_PROFILE_DIRS=()
for profile_dir in orca Orca; do
if sudo test -L "/home/orca/.config/$profile_dir"; then
echo "Refusing symlinked Orca profile: /home/orca/.config/$profile_dir" >&2
exit 1
fi
if sudo test -d "/home/orca/.config/$profile_dir"; then
if [[ "$profile_dir" == Orca ]] && \
sudo test /home/orca/.config/orca -ef /home/orca/.config/Orca; then
continue
fi
ORCA_PROFILE_DIRS+=("$profile_dir")
fi
done
if ((${#ORCA_PROFILE_DIRS[@]} == 0)); then
echo 'No Orca profile directory found under /home/orca/.config' >&2
exit 1
fi
sudo tar czf "$ORCA_ROLLBACK_NEW/profile.tgz" \
-C /home/orca/.config "${ORCA_PROFILE_DIRS[@]}"
sudo chmod 600 "$ORCA_ROLLBACK_NEW/profile.tgz"
sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK"
# 4. Atomically replace the binary and version record, then start
ORCA_BINARY_PROMOTED=1
sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage
sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION
sudo systemctl start orca-serve.service
ORCA_SERVICE_STOPPED=0
trap - EXIT
```
The profile archive created in step 3 captures both Orca profile directory names
when present without rewinding unrelated tools under `/home/orca/.config`. The
`.ready` suffix is published only after the prior binary, version record, and
profile archive are complete. If you run the managed Xvfb unit, only
`orca-serve.service` needs restarting — leave `orca-xvfb.service` running.
### Verify
```bash
sudo journalctl -u orca-serve.service -f
```
A healthy start prints `Orca server ready: ws://0.0.0.0:6768` (with your port).
Confirm a client reconnects before you discard the backup. The timestamped
rollback bundles are not pruned automatically. After the new version satisfies
your retention policy, select and inspect the newest complete bundle before
removing it:
```bash
shopt -s nullglob
ORCA_ROLLBACK_SETS=(/opt/orca/orca-rollback-*.ready)
((${#ORCA_ROLLBACK_SETS[@]} > 0))
ORCA_ROLLBACK=${ORCA_ROLLBACK_SETS[${#ORCA_ROLLBACK_SETS[@]} - 1]}
printf 'Removing rollback bundle: %s\n' "$ORCA_ROLLBACK"
sudo test -d "$ORCA_ROLLBACK"
sudo rm -rf -- "$ORCA_ROLLBACK"
```
Each `.ready` directory is a self-contained rollback generation; never combine
files from different bundles.
### Roll back
A rollback is **not** binary-only safe. Once a newer build has started, it can
rewrite `orca-data.json` in the current schema. If an older build then writes
that file, it can discard fields it does not recognize. The rolling
`orca-data.json.bak.*` files are corruption-recovery snapshots, not a dedicated
pre-upgrade copy, and normal writes can rotate them away. To roll back cleanly,
restore the backup from step 3 **and** swap the binary back. Run this block as one
Bash script:
```bash
set -euo pipefail
# Select and validate one complete generation before taking the service offline
shopt -s nullglob
ORCA_ROLLBACK_SETS=(/opt/orca/orca-rollback-*.ready)
((${#ORCA_ROLLBACK_SETS[@]} > 0))
ORCA_ROLLBACK=${ORCA_ROLLBACK_SETS[${#ORCA_ROLLBACK_SETS[@]} - 1]}
sudo test -f "$ORCA_ROLLBACK/orca-linux.AppImage"
sudo tar tzf "$ORCA_ROLLBACK/profile.tgz" >/dev/null
# Extract and validate the old profile while the current server stays online
sudo test ! -L /home
ORCA_HOME_OWNER=$(sudo stat -c %u /home)
ORCA_HOME_MODE=$(sudo stat -c %a /home)
if [[ "$ORCA_HOME_OWNER" != 0 ]] || ((8#$ORCA_HOME_MODE & 0022)) || \
sudo -u orca test -w /home; then
echo 'Refusing rollback because /home is not root-controlled' >&2
exit 1
fi
ORCA_RESTORE=$(sudo mktemp -d /home/.orca-restore.XXXXXX)
ORCA_SERVICE_STOPPED=0
ORCA_MOVED_CURRENT_DIRS=()
ORCA_INSTALLED_RESTORE_DIRS=()
ORCA_CURRENT_BINARY_MOVED=0
ORCA_CURRENT_VERSION_MOVED=0
ORCA_VERSION_REPLACEMENT_STARTED=0
ORCA_POST_UPGRADE=
ORCA_ROLLBACK_BINARY_STAGED=
ORCA_ROLLBACK_VERSION_STAGED=
ORCA_ROLLBACK_HAS_VERSION=0
restart_after_rollback_error() {
exit_status=$?
trap - EXIT
set +e
if ((exit_status != 0 && ORCA_SERVICE_STOPPED)); then
recovery_ok=1
if ((${#ORCA_INSTALLED_RESTORE_DIRS[@]})); then
for profile_dir in "${ORCA_INSTALLED_RESTORE_DIRS[@]}"; do
if sudo test -d "/home/orca/.config/$profile_dir"; then
if ! sudo mv "/home/orca/.config/$profile_dir" \
"$ORCA_RESTORE/$profile_dir.failed"; then
recovery_ok=0
fi
fi
done
fi
if ((${#ORCA_MOVED_CURRENT_DIRS[@]})); then
for profile_dir in "${ORCA_MOVED_CURRENT_DIRS[@]}"; do
if sudo test -d "$ORCA_POST_UPGRADE/$profile_dir"; then
if ! sudo mv "$ORCA_POST_UPGRADE/$profile_dir" /home/orca/.config/; then
recovery_ok=0
fi
elif ! sudo test -d "/home/orca/.config/$profile_dir"; then
recovery_ok=0
fi
done
fi
if [[ -n "$ORCA_POST_UPGRADE" ]]; then
sudo rmdir "$ORCA_POST_UPGRADE" 2>/dev/null || true
fi
if ((ORCA_CURRENT_BINARY_MOVED)); then
if sudo test -f "$ORCA_CURRENT_BINARY"; then
if ! sudo mv -f "$ORCA_CURRENT_BINARY" /opt/orca/orca-linux.AppImage; then
recovery_ok=0
fi
elif ! sudo test -f /opt/orca/orca-linux.AppImage; then
recovery_ok=0
fi
fi
if ((ORCA_CURRENT_VERSION_MOVED)); then
if sudo test -f "$ORCA_CURRENT_VERSION"; then
if ! sudo mv -f "$ORCA_CURRENT_VERSION" /opt/orca/VERSION; then
recovery_ok=0
fi
elif ! sudo test -f /opt/orca/VERSION; then
recovery_ok=0
fi
elif ((ORCA_VERSION_REPLACEMENT_STARTED)); then
if ! sudo rm -f /opt/orca/VERSION; then
recovery_ok=0
fi
fi
if ((recovery_ok)); then
sudo systemctl start orca-serve.service || true
else
echo 'Rollback recovery failed; service remains stopped' >&2
fi
fi
if [[ -n "$ORCA_ROLLBACK_BINARY_STAGED" ]]; then
sudo rm -f -- "$ORCA_ROLLBACK_BINARY_STAGED"
fi
if [[ -n "$ORCA_ROLLBACK_VERSION_STAGED" ]]; then
sudo rm -f -- "$ORCA_ROLLBACK_VERSION_STAGED"
fi
sudo rm -rf -- "$ORCA_RESTORE"
exit "$exit_status"
}
trap restart_after_rollback_error EXIT
if [[ "$(sudo stat -c %d "$ORCA_RESTORE")" != \
"$(sudo stat -c %d /home/orca/.config)" ]]; then
echo 'Refusing rollback because staging and the Orca profile are on different filesystems' >&2
exit 1
fi
sudo tar xzf "$ORCA_ROLLBACK/profile.tgz" -C "$ORCA_RESTORE"
ORCA_RESTORE_DIRS=()
for profile_dir in orca Orca; do
if sudo test -L "$ORCA_RESTORE/$profile_dir"; then
echo "Rollback bundle contains a symlinked profile: $profile_dir" >&2
exit 1
fi
if sudo test -d "$ORCA_RESTORE/$profile_dir"; then
if [[ "$profile_dir" == Orca ]] && \
sudo test "$ORCA_RESTORE/orca" -ef "$ORCA_RESTORE/Orca"; then
continue
fi
ORCA_RESTORE_DIRS+=("$profile_dir")
fi
done
if ((${#ORCA_RESTORE_DIRS[@]} == 0)); then
echo "Rollback bundle has no Orca profile directories: $ORCA_ROLLBACK" >&2
exit 1
fi
for profile_dir in "${ORCA_RESTORE_DIRS[@]}"; do
sudo chown -R orca:orca "$ORCA_RESTORE/$profile_dir"
done
ORCA_ROLLBACK_STAMP=$(date +%F-%H%M%S-%N)
ORCA_ROLLBACK_BINARY_STAGED=/opt/orca/orca-linux.AppImage.rollback-staged-$ORCA_ROLLBACK_STAMP
sudo cp -a "$ORCA_ROLLBACK/orca-linux.AppImage" "$ORCA_ROLLBACK_BINARY_STAGED"
if sudo test -f "$ORCA_ROLLBACK/VERSION"; then
ORCA_ROLLBACK_HAS_VERSION=1
ORCA_ROLLBACK_VERSION_STAGED=/opt/orca/VERSION.rollback-staged-$ORCA_ROLLBACK_STAMP
sudo cp -a "$ORCA_ROLLBACK/VERSION" "$ORCA_ROLLBACK_VERSION_STAGED"
fi
ORCA_SERVICE_STOPPED=1
sudo systemctl stop orca-serve.service
# Preserve and replace only Orca-owned profile directories
ORCA_CURRENT_DIRS=()
for profile_dir in orca Orca; do
if sudo test -L "/home/orca/.config/$profile_dir"; then
echo "Refusing symlinked Orca profile: /home/orca/.config/$profile_dir" >&2
exit 1
fi
if sudo test -d "/home/orca/.config/$profile_dir"; then
if [[ "$profile_dir" == Orca ]] && \
sudo test /home/orca/.config/orca -ef /home/orca/.config/Orca; then
continue
fi
ORCA_CURRENT_DIRS+=("$profile_dir")
fi
done
ORCA_POST_UPGRADE=/home/orca/.config/orca-rollback-$ORCA_ROLLBACK_STAMP
sudo install -d -o orca -g orca -m 700 "$ORCA_POST_UPGRADE"
if ((${#ORCA_CURRENT_DIRS[@]})); then
for profile_dir in "${ORCA_CURRENT_DIRS[@]}"; do
ORCA_MOVED_CURRENT_DIRS+=("$profile_dir")
sudo mv "/home/orca/.config/$profile_dir" "$ORCA_POST_UPGRADE/"
done
fi
for profile_dir in "${ORCA_RESTORE_DIRS[@]}"; do
ORCA_INSTALLED_RESTORE_DIRS+=("$profile_dir")
sudo mv "$ORCA_RESTORE/$profile_dir" /home/orca/.config/
done
ORCA_CURRENT_BINARY=/opt/orca/orca-linux.AppImage.rollback-current-$ORCA_ROLLBACK_STAMP
ORCA_CURRENT_BINARY_MOVED=1
sudo mv /opt/orca/orca-linux.AppImage "$ORCA_CURRENT_BINARY"
sudo mv -f "$ORCA_ROLLBACK_BINARY_STAGED" /opt/orca/orca-linux.AppImage
ORCA_CURRENT_VERSION=/opt/orca/VERSION.rollback-current-$ORCA_ROLLBACK_STAMP
if sudo test -f /opt/orca/VERSION; then
ORCA_CURRENT_VERSION_MOVED=1
sudo mv /opt/orca/VERSION "$ORCA_CURRENT_VERSION"
fi
ORCA_VERSION_REPLACEMENT_STARTED=1
if ((ORCA_ROLLBACK_HAS_VERSION)); then
sudo mv -f "$ORCA_ROLLBACK_VERSION_STAGED" /opt/orca/VERSION
else
sudo rm -f /opt/orca/VERSION
fi
sudo systemctl start orca-serve.service
ORCA_SERVICE_STOPPED=0
sudo rm -rf -- "$ORCA_RESTORE"
trap - EXIT
```
Restoring the backup is required, not optional: swapping only the binary leaves
the newer `orca-data.json` in place, where an older build can discard state it
does not understand. Keep the pre-upgrade backup until the new version is proven
on your host. The `orca-rollback-*` directory inside `.config` is also retained
deliberately. The post-upgrade binary and version record are retained in
`/opt/orca` with the same `rollback-current-<timestamp>` suffix. Inspect these
artifacts and remove them according to your retention policy after the rollback
is resolved.
## Troubleshooting
- `dlopen(): error loading libfuse.so.2`: install `libfuse2`.
@@ -178,6 +611,9 @@ depend on an interactive shell profile.
`orca` user and that `/opt/orca` is readable by that user.
- Clients cannot connect: make sure `--pairing-address` is an address reachable
from the client, and make sure firewalls allow the selected `--port`.
- Service crash-loops right after an upgrade: use [Roll back](#roll-back) with
the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would
make the crashing version the next rollback binary.
- Diagnosing other missing libraries: extract the AppImage without launching it
with `./orca-linux.AppImage --appimage-extract`, then run
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
@@ -2,7 +2,26 @@
Date: 2026-07-19
Status: Final decision; implementation pending
Status: Implemented with the 2026-07-20 ownership amendment below
## 2026-07-20 Ownership Amendment
Fresh paired-runtime evidence showed that the display/controller platform is not a safe proxy for
the PTY backend. A macOS client can attach to a native ConPTY owned by a paired Windows runtime, so
renderer-side local/SSH/remote heuristics can transfer OSC 10/11 authority to the wrong responder.
The PTY-owning process now classifies the backend from its own platform and the shell that actually
won spawn: `windows-conpty`, `windows-wsl`, or `posix-pty`. Native ConPTY consumes complete OSC 10/11
queries before model, replay, or view delivery. During the bounded startup window it replies once
when validated theme colors are available; after the deadline, or without colors, it consumes the
query without replying. A consuming-view handshake does not transfer this authority. Split query
candidates remain bounded and private across authority-close, expiry, and snapshot barriers; a
candidate that proves malformed is released unchanged.
WSL and POSIX PTYs continue to transfer authority to the normal visible/hidden responder after the
startup window. The same owner-side rule applies to local, daemon, SSH-relay, and paired-runtime
PTYs. This amendment supersedes contrary transfer/fallback language below; the echo projection is
selected only by the authoritative owner backend, never by renderer or connection metadata.
## Problem
@@ -31,13 +50,13 @@ the failure requires an additional agent, timing, input-mode, or replay conditio
## Root Cause
The startup OSC responder currently runs in Electron main only after runtime ingestion
([`pty.ts`](../../../src/main/ipc/pty.ts)). `LocalPtyProvider` calls the runtime before its public
data listeners ([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while
daemon `Session` advances sequence state, writes its emulator, persists pending output, and fans out
data before Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A
renderer-only filter can therefore hide the symptom without removing it from the authoritative
model, daemon history, snapshots, or remote delivery.
OSC responders downstream of the PTY owner do not know whether the bytes came from native ConPTY,
WSL, or a POSIX PTY. `LocalPtyProvider` calls the runtime before its public data listeners
([`local-pty-provider.ts`](../../../src/main/providers/local-pty-provider.ts)), while daemon `Session`
advances sequence state, writes its emulator, persists pending output, and fans out data before
Electron main receives it ([`session.ts`](../../../src/main/daemon/session.ts)). A renderer-only
filter can therefore misclassify paired runtimes and hide the symptom without removing it from the
authoritative model, daemon history, snapshots, or remote delivery.
The corrupted echo is timing-dependent but its ordering failure is deterministic: any sanitizer
downstream of an authoritative consumer is too late. The independent `[I` symptom has not yet met
@@ -228,14 +247,15 @@ When registered for an agent spawn, it:
hidden model nor a delivered renderer can answer it a second time;
6. begins echo recognition as soon as each individual reply is written.
If attributes or a provider are unavailable, the query is not consumed. It passes through unchanged
to normal query authority. A reattach never registers startup response state, matching current
behavior.
If attributes or a provider are unavailable, native ConPTY consumes the query without replying;
WSL and POSIX PTYs pass it through unchanged to normal query authority. A reattach never registers
startup response state, matching current behavior, but native ConPTY ownership still prevents a
downstream reply.
### Exact authority transfer
Startup query interception opens only for a fresh session whose atomic creation installed the
transaction before buffered output release. It closes at the first of:
Startup query response authority opens only for a fresh session whose atomic creation installed the
transaction before buffered output release. For WSL and POSIX PTYs it closes at the first of:
- both OSC 10 and OSC 11 slots have been answered;
- the startup deadline expires;
@@ -243,7 +263,9 @@ transaction before buffered output release. It closes at the first of:
hidden-runtime ownership mark is established;
- the spawn fails, is cancelled, reattaches, or exits.
Closing query interception does not discard already-written reply candidates. Echo recognition has
Native ConPTY does not transfer OSC 10/11 authority at those boundaries: the deadline stops source
replies, while complete queries remain consumed for the life of the PTY. Closing response authority
does not discard already-written reply candidates. Echo recognition has
its own bounded lifetime and may finish or drain after normal authority takes over. A close and a
provider callback are ordered by the source owner's per-PTY ingress queue. Transport attachment to
a daemon/relay client is not a consuming-view signal. Main sends the close over a versioned control
+13
View File
@@ -239,6 +239,19 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
worker: {
format: 'es'
},
build: {
// Why: the pop-out dashboard is a second top-level window with its own
// React root. It gets its own HTML entry so it can boot independently of
// the main window while reusing the same preload/window.api. `index` must
// stay listed — overriding input otherwise drops electron-vite's default
// renderer entry.
rollupOptions: {
input: {
index: resolve('src/renderer/index.html'),
popout: resolve('src/renderer/popout.html')
}
}
}
}
})
+58 -59
View File
@@ -26,7 +26,8 @@ import {
Filter,
Check,
UserCircle,
PanelLeftClose
PanelLeftClose,
SquareTerminal
} from 'lucide-react-native'
import type { RpcClient } from '../../../src/transport/rpc-client'
import { loadHosts, updateLastConnected } from '../../../src/transport/host-store'
@@ -57,6 +58,7 @@ import type { RepoIcon } from '../../../../src/shared/repo-icon'
import { PickerModal } from '../../../src/components/PickerModal'
import { ActionSheetContent } from '../../../src/components/ActionSheetModal'
import { buildWorktreeNavigationActions } from '../../../src/agent-history/worktree-navigation-actions'
import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace'
import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
@@ -68,7 +70,7 @@ import { setCachedRepos } from '../../../src/cache/repo-cache'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
import { leaveHostRoute } from '../../../src/host-route-exit'
import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat'
import { useHostStatusGates } from '../../../src/transport/host-status-gates'
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
createInitialHostRouteActionState,
@@ -97,7 +99,7 @@ import {
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
} from '../../../src/worktree/workspace-list-picker-options'
import type { DesktopStatus, RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
import type { WorkspaceStatusDefinition } from '../../../../src/shared/types'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses'
@@ -155,7 +157,6 @@ export function HostScreen({
const [repoIconsByName, setRepoIconsByName] = useState<Map<string, RepoIcon>>(new Map())
const [hostName, setHostName] = useState('')
const [error, setError] = useState('')
const [compatVerdict, setCompatVerdict] = useState<CompatVerdict>({ kind: 'ok' })
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [search, setSearch] = useState('')
const [showSearch, setShowSearch] = useState(false)
@@ -175,7 +176,11 @@ export function HostScreen({
const [showGroupPicker, setShowGroupPicker] = useState(false)
const [showFilterModal, setShowFilterModal] = useState(false)
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
const [hostCapabilities, setHostCapabilities] = useState<string[]>([])
const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({
hostId,
client,
connState
})
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
const [routeActionState, setRouteActionState] = useState(() =>
@@ -315,7 +320,6 @@ export function HostScreen({
useEffect(() => {
setHostName('')
setError('')
setCompatVerdict({ kind: 'ok' })
setRepoColorsByName(new Map())
setRepoIconsByName(new Map())
repoMetadataFetchedAtRef.current = 0
@@ -483,52 +487,6 @@ export function HostScreen({
[client, connState, hostId]
)
// Why: re-evaluate protocol compat on connect; today's constants are wide-open so this never blocks yet.
useEffect(() => {
if (connState !== 'connected' || !client) {
// Why: drop capabilities while disconnected/switching so a capability-gated action can't linger for a new host.
setHostCapabilities([])
return
}
let cancelled = false
const requestClient = client
void (async () => {
try {
const response = await requestClient.sendRequest('status.get')
if (cancelled || clientRef.current !== requestClient) {
return
}
if (!response.ok) {
setHostCapabilities([])
return
}
const status = (response as RpcSuccess).result as DesktopStatus & {
capabilities?: string[]
}
setHostCapabilities(status.capabilities ?? [])
const verdict = evaluateCompat({
desktopProtocolVersion: status.protocolVersion,
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
})
setCompatVerdict(verdict)
if (verdict.kind === 'blocked') {
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
console.warn('[protocol-compat] blocked', {
reason: verdict.reason,
desktopVersion: verdict.desktopVersion,
requiredMobileVersion: verdict.requiredMobileVersion,
requiredDesktopVersion: verdict.requiredDesktopVersion
})
}
} catch {
// Why: sendRequest can throw on transport tear-down; treat as transient, keep the prior verdict.
}
})()
return () => {
cancelled = true
}
}, [connState, client])
useFocusEffect(
useCallback(() => {
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
@@ -674,8 +632,7 @@ export function HostScreen({
router.push(target)
return
}
const targetPath = target.split('?')[0] ?? target
if (pathname === targetPath) {
if (pathname === (target.split('?')[0] ?? target)) {
return
}
if (pathname === `/h/${hostId}`) {
@@ -691,11 +648,11 @@ export function HostScreen({
(item: Worktree) => {
setOptimisticActiveWorktreeId(item.worktreeId)
if (client && connState === 'connected') {
// Why: notifyClients:false hydrates host tabs without pulling desktop into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${item.worktreeId}`,
notifyClients: false
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
}
@@ -705,6 +662,12 @@ export function HostScreen({
[client, connState, hostId, navigateFromHostList]
)
const openFloatingWorkspace = useCallback(() => {
// Why: no worktree.activate here — the floating sentinel has no worktree
// record; session.tabs.list hydrates its host-owned tabs on open.
navigateFromHostList(floatingWorkspaceSessionPath(hostId))
}, [hostId, navigateFromHostList])
const handleSortChange = useCallback(
(value: MobileSortMode) => {
persistViewSettings({ sortMode: value })
@@ -870,6 +833,24 @@ export function HostScreen({
</>
)
})()}
{!embedded && floatingWorkspaceEnabled ? (
<Pressable
style={[
styles.floatingWorkspaceHeaderButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={openFloatingWorkspace}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Floating Workspace"
hitSlop={8}
>
<SquareTerminal
size={18}
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
/>
</Pressable>
) : null}
{embedded && onHideSidebar ? (
<Pressable
style={styles.sidebarCollapseButton}
@@ -976,6 +957,24 @@ export function HostScreen({
/>
</Pressable>
{floatingWorkspaceEnabled ? (
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={openFloatingWorkspace}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Floating Workspace"
>
<SquareTerminal
size={18}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
) : null}
<Pressable
style={[
styles.embeddedToolbarIconButton,
@@ -1554,12 +1553,12 @@ const styles = StyleSheet.create({
toolbarSpacer: {
flex: 1
},
toolbarIconButton: {
floatingWorkspaceHeaderButton: {
width: 32,
height: 28,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
marginLeft: spacing.xs
},
embeddedToolbarIconButton: {
flex: 1,
+102 -98
View File
@@ -72,6 +72,7 @@ import {
panelRouteDescriptor
} from '../../../../src/session/session-panel-host'
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
import { isFloatingWorkspaceWorktreeId } from '../../../../src/session/floating-workspace'
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton'
import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet'
@@ -171,11 +172,8 @@ import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from '../../../../src/session/mobile-terminal-tab-agent'
import {
buildMobileNewTabAgentOptions,
type MobileNewTabAgentOption,
type MobileNewTabAgentSettings
} from '../../../../src/session/mobile-new-tab-agent-options'
import type { MobileNewTabAgentOption } from '../../../../src/session/mobile-new-tab-agent-options'
import { loadMobileNewTabAgentOptions } from '../../../../src/session/mobile-new-tab-agent-loader'
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
@@ -211,10 +209,7 @@ import { getMobileTerminalActionSheetActions } from '../../../../src/session/mob
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import {
activateMobileSessionTab,
focusMobileTerminal
} from '../../../../src/session/mobile-session-tab-activation'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import {
getRepoIdFromMobileWorktreeId,
@@ -816,6 +811,8 @@ export default function SessionScreen() {
warning?: string
}>()
const isFolderWorkspaceRoute = worktreeId.startsWith('folder:') // Synthetic ids have no repo scope.
// Why: the floating sentinel has no repo/worktree, so repo-backed surfaces hide.
const isFloatingWorkspaceRoute = isFloatingWorkspaceWorktreeId(worktreeId)
const router = useRouter()
const insets = useSafeAreaInsets()
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
@@ -833,11 +830,13 @@ export default function SessionScreen() {
const { isWideLayout } = useResponsiveLayout()
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
const [sessionContentRowWidth, setSessionContentRowWidth] = useState(0)
const canDockPanel = canDockSessionPanel({
isWideLayout,
availableWidth: sessionContentRowWidth,
dockWidth: HOST_DOCK_MIN_WIDTH
})
const canDockPanel =
!isFloatingWorkspaceRoute &&
canDockSessionPanel({
isWideLayout,
availableWidth: sessionContentRowWidth,
dockWidth: HOST_DOCK_MIN_WIDTH
})
// Why: if rotation/split-screen makes the docked row too narrow, clear activePanel so it doesn't survive into overlay/push mode.
useEffect(() => {
if (!canDockPanel && activePanel !== null) {
@@ -847,7 +846,9 @@ export default function SessionScreen() {
// GitHub remote probe gates the PR dock icon so non-GitHub providers can't open the hosted-review surface; skip the unused identity RPCs.
const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } =
useMobilePrBranchContext({
client,
// Why: a null client parks the hook in its not-ready state — the floating
// sentinel has no repo to probe.
client: isFloatingWorkspaceRoute ? null : client,
connState,
worktreeId,
includeBranchIdentity: false
@@ -1933,7 +1934,7 @@ export default function SessionScreen() {
)
const loadDiffComments = useCallback(async (): Promise<void> => {
if (!client || connState !== 'connected' || !worktreeId) {
if (!client || connState !== 'connected' || !worktreeId || isFloatingWorkspaceRoute) {
setDiffComments([])
return
}
@@ -1947,7 +1948,7 @@ export default function SessionScreen() {
worktree?: { diffComments?: unknown }
}
setDiffComments(normalizeMobileDiffComments(result.worktree?.diffComments, worktreeId))
}, [client, connState, worktreeId])
}, [client, connState, worktreeId, isFloatingWorkspaceRoute])
const persistDiffComments = useCallback(
async (comments: readonly DiffComment[]): Promise<void> => {
@@ -2621,12 +2622,13 @@ export default function SessionScreen() {
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
if (client && created !== '1') {
if (client && created !== '1' && !isFloatingWorkspaceRoute) {
// Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false
notifyClients: false,
navigation: 'caller'
})
.then((response) => reportActivationOutcome(response.ok ? response : null))
.catch(() => null)
@@ -2644,7 +2646,7 @@ export default function SessionScreen() {
}
addTimer(() => void fetchTerminals({ allowEmptyLoaded: false }), 750)
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 1500)
if (client && created === '1') {
if (client && created === '1' && !isFloatingWorkspaceRoute) {
addTimer(() => {
if (activeHandleRef.current) {
return
@@ -2653,7 +2655,8 @@ export default function SessionScreen() {
const activationResponse = await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
@@ -2672,7 +2675,16 @@ export default function SessionScreen() {
clearTimeout(t)
}
}
}, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId])
}, [
client,
connState,
created,
fetchSessionTabs,
fetchTerminals,
isFloatingWorkspaceRoute,
showToast,
worktreeId
])
useEffect(() => {
if (!client || connState !== 'connected') {
@@ -2767,7 +2779,7 @@ export default function SessionScreen() {
}, [])
)
// Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one; terminal.focus makes desktop follow.
// Why: unsubscribe restores old dims (clears phone-fit banner); resubscribe phone-fits the new one.
const switchTab = useCallback(
(handle: string) => {
triggerSelection()
@@ -2795,13 +2807,12 @@ export default function SessionScreen() {
}
subscribeToTerminal(handle)
if (client) {
void focusMobileTerminal(client, handle).catch(() => {})
if (matchingTab) {
// Why: persist selection for headless hosts; snapshot gate stops this phone-local ack from impersonating desktop focus.
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id,
notifyClients: false
notifyClients: false,
navigation: 'caller'
}).catch(() => {})
}
}
@@ -2840,7 +2851,8 @@ export default function SessionScreen() {
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
notifyClients: false,
navigation: 'caller'
}).catch(() => {})
}
return
@@ -2863,7 +2875,8 @@ export default function SessionScreen() {
void activateMobileSessionTab(client, {
worktree: `id:${worktreeId}`,
tabId: tab.id,
notifyClients: false
notifyClients: false,
navigation: 'caller'
}).catch(() => {})
}
if (tab.type === 'browser') {
@@ -3231,13 +3244,15 @@ export default function SessionScreen() {
if (handle !== activeHandleRef.current) {
return
}
if (terminalLinkOpenMode === 'phone-browser') {
// Why: browser.tabCreate resolves a real worktree, which the floating
// sentinel doesn't have — open taps in the phone browser instead.
if (terminalLinkOpenMode === 'phone-browser' || isFloatingWorkspaceRoute) {
void Linking.openURL(url).catch(() => {})
return
}
void handleCreateBrowserRef.current?.(url)
},
[terminalLinkOpenMode]
[terminalLinkOpenMode, isFloatingWorkspaceRoute]
)
const toggleLiveInput = useCallback(() => {
@@ -3575,7 +3590,8 @@ export default function SessionScreen() {
}, [])
const getActiveWorktreeConnectionId = useCallback(async (): Promise<string | null> => {
if (!client) {
// Why: the floating workspace always runs on the paired host itself, never an SSH repo target.
if (!client || isFloatingWorkspaceRoute) {
return null
}
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
@@ -3586,7 +3602,7 @@ export default function SessionScreen() {
const repos =
((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
return repos.find((repo) => repo.id === repoId)?.connectionId?.trim() || null
}, [client, worktreeId])
}, [client, isFloatingWorkspaceRoute, worktreeId])
const refreshCanPaste = useCallback(() => {
void Promise.all([
@@ -3683,43 +3699,14 @@ export default function SessionScreen() {
setCreateTabAgentOptions([])
void (async () => {
const [settingsResponse, repoResponse] = await Promise.all([
client.sendRequest('settings.get'),
client.sendRequest('repo.list')
])
if (!settingsResponse.ok) {
throw new Error((settingsResponse as RpcFailure).error.message)
}
const settings = (
(settingsResponse as RpcSuccess).result as {
settings?: MobileNewTabAgentSettings
}
).settings
if (!repoResponse.ok) {
throw new Error((repoResponse as RpcFailure).error.message)
}
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
if (!repoId) {
throw new Error('worktree_repo_missing')
}
const repos =
((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
const repo = repos.find((candidate) => candidate.id === repoId)
if (!repo) {
throw new Error('worktree_repo_not_found')
}
const connectionId = repo.connectionId?.trim() || null
const detectedResponse = connectionId
? await client.sendRequest('preflight.detectRemoteAgents', { connectionId })
: await client.sendRequest('preflight.detectAgents')
if (!detectedResponse.ok) {
throw new Error((detectedResponse as RpcFailure).error.message)
}
const options = await loadMobileNewTabAgentOptions({
client,
worktreeId
})
if (stale) {
return
}
const detectedAgentIds = (detectedResponse as RpcSuccess).result as unknown[]
setCreateTabAgentOptions(buildMobileNewTabAgentOptions(settings, detectedAgentIds))
setCreateTabAgentOptions(options)
setCreateTabAgentLoadState('loaded')
})().catch(() => {
if (!stale) {
@@ -3763,7 +3750,10 @@ export default function SessionScreen() {
? { startupCommandDelivery: options.startupCommandDelivery }
: {}),
...(options?.agentPrompt ? { agentPrompt: options.agentPrompt } : {}),
...(agent ? { agent } : {})
...(agent ? { agent } : {}),
activate: false,
select: true,
navigation: 'caller'
})
if (response.ok) {
const result = (response as RpcSuccess).result as TerminalCreateResult
@@ -4158,7 +4148,8 @@ export default function SessionScreen() {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId,
notifyClients: false
notifyClients: false,
navigation: 'caller'
})
.then((response) => {
if (!response.ok) {
@@ -4380,9 +4371,9 @@ export default function SessionScreen() {
router.push(`/h/${hostId}/agent-history/${encodeURIComponent(worktreeId)}?${params.toString()}`)
}
const showAgentSessionHistoryAction =
!isFolderWorkspaceRoute && agentSessionHistorySupported === true
!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && agentSessionHistorySupported === true
const showChecksAction = shouldShowSessionHeaderChecksAction({
isFolderWorkspaceRoute,
isFolderWorkspaceRoute: isFolderWorkspaceRoute || isFloatingWorkspaceRoute,
repoContextLoaded: prRepoContextLoaded,
hostedChecksSupported: prIsGithubRepo
})
@@ -4423,13 +4414,15 @@ export default function SessionScreen() {
</Text>
</Pressable>
</View>
<MobileSessionHeaderIconButton
active={activePanel === 'files'}
accessibilityLabel="Open file explorer"
icon={Folder}
onPress={() => handlePanelTap('files')}
/>
{!isFolderWorkspaceRoute && (
{!isFloatingWorkspaceRoute && (
<MobileSessionHeaderIconButton
active={activePanel === 'files'}
accessibilityLabel="Open file explorer"
icon={Folder}
onPress={() => handlePanelTap('files')}
/>
)}
{!isFolderWorkspaceRoute && !isFloatingWorkspaceRoute && (
<MobileSessionHeaderIconButton
active={activePanel === 'sourceControl'}
accessibilityLabel="Open source control"
@@ -5075,7 +5068,11 @@ export default function SessionScreen() {
visible={showQuickCommands && quickCommandsSupported === true}
onClose={() => setShowQuickCommands(false)}
client={client}
repoId={isFolderWorkspaceRoute ? null : getRepoIdFromMobileWorktreeId(worktreeId) || null}
repoId={
isFolderWorkspaceRoute || isFloatingWorkspaceRoute
? null
: getRepoIdFromMobileWorktreeId(worktreeId) || null
}
repoName={worktreeName || null}
onLaunch={launchQuickCommand}
/>
@@ -5093,26 +5090,33 @@ export default function SessionScreen() {
void handleCreateTerminal()
}
},
{
label: 'Browser',
icon: Globe,
onPress: () => {
setShowCreateTabDrawer(false)
if (browserScreencastSupported !== true) {
showToast('Desktop update required for mobile browser streaming', 1600)
return
}
setShowCreateBrowserModal(true)
}
},
{
label: 'Markdown Note',
icon: FileText,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
}
// Why: browser/markdown creation resolve a real worktree on the host
// (browser.tabCreate, files.createFile); the floating sentinel is
// terminal-only over RPC, so those options hide there.
...(isFloatingWorkspaceRoute
? []
: [
{
label: 'Browser',
icon: Globe,
onPress: () => {
setShowCreateTabDrawer(false)
if (browserScreencastSupported !== true) {
showToast('Desktop update required for mobile browser streaming', 1600)
return
}
setShowCreateBrowserModal(true)
}
},
{
label: 'Markdown Note',
icon: FileText,
onPress: () => {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
}
])
]}
onClose={() => setShowCreateTabDrawer(false)}
/>
+130 -110
View File
@@ -77,6 +77,15 @@ import {
findRepoForGitHubProjectRepository,
type GitHubRepoSlugCacheEntry
} from '../../../src/tasks/github-project-repo-match'
import {
parseGitHubProjectInput as parseProjectInput,
type GitHubProjectOwnerType,
type GitHubProjectPartialFailure,
type GitHubProjectRef,
type GitHubProjectSettings,
type GitHubProjectSummary,
type GitHubProjectViewSummary
} from '../../../src/tasks/github-project-reference'
import {
extractGitHubIssueSourceFallback,
extractGitHubIssueSourceError,
@@ -130,11 +139,16 @@ import {
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
import type {
BaseRefSearchResult,
GitHubOwnerRepo,
PersistedTrustedOrcaHooks,
SparsePreset,
TuiAgent
} from '../../../../src/shared/types'
import type { SshConnectionState } from '../../../../src/shared/ssh-types'
import {
githubProjectHost,
githubProjectIdentityKey as githubProjectKey
} from '../../../../src/shared/github-project-identity'
type RepoSummary = {
id: string
@@ -148,11 +162,6 @@ type RepoSummary = {
type IssueSourcePreference = 'upstream' | 'origin' | 'auto'
type GitHubOwnerRepo = {
owner: string
repo: string
}
type GitHubWorkItem = {
id: string
type: 'issue' | 'pr'
@@ -444,34 +453,6 @@ type LinearStatusResponse = {
activeWorkspaceId?: string | null
}
type GitHubProjectOwnerType = 'organization' | 'user'
type GitHubProjectRef = {
owner: string
ownerType: GitHubProjectOwnerType
number: number
}
type GitHubProjectSettings = {
pinned: GitHubProjectRef[]
recent: Array<GitHubProjectRef & { lastOpenedAt: string }>
lastViewByProject: Record<string, { viewId: string }>
activeProject: GitHubProjectRef | null
}
type GitHubProjectSummary = GitHubProjectRef & {
id: string
title: string
url: string
source: string
}
type GitHubProjectPartialFailure = {
owner: string
message: string
}
type GitHubProjectViewSummary = {
id: string
number: number
name: string
layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT'
}
type GitHubIssueType = {
id: string
name: string
@@ -951,48 +932,6 @@ function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHu
: 'issues'
}
function githubProjectKey(project: GitHubProjectRef): string {
return `${project.ownerType}:${project.owner}:${project.number}`
}
function parseProjectInput(
input: string
): { owner: string; number: number; viewNumber?: number } | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed)
if (short) {
return { owner: short[1]!, number: Number(short[2]) }
}
try {
const url = new URL(trimmed)
if (url.hostname !== 'github.com') {
return null
}
const parts = url.pathname.split('/').filter(Boolean)
if ((parts[0] === 'orgs' || parts[0] === 'users') && parts[2] === 'projects' && parts[3]) {
const number = Number(parts[3])
if (!Number.isInteger(number) || number < 1) {
return null
}
const viewNumber =
parts[4] === 'views' && parts[5] && Number.isInteger(Number(parts[5]))
? Number(parts[5])
: undefined
return {
owner: parts[1]!,
number,
...(viewNumber && viewNumber > 0 ? { viewNumber } : {})
}
}
} catch {
return null
}
return null
}
function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null {
if (row.itemType === 'ISSUE') {
return 'issue'
@@ -1014,6 +953,11 @@ function splitRepositorySlug(slug: string | null): { owner: string; repo: string
return owner && repo ? { owner, repo } : null
}
function projectRowGitHubRepository(row: GitHubProjectRow, host: string): GitHubOwnerRepo | null {
const slug = splitRepositorySlug(row.content.repository)
return slug ? { ...slug, host } : null
}
const GITHUB_PROJECT_OPTION_COLORS: Record<string, string> = {
GRAY: '#8b949e',
RED: '#f85149',
@@ -2468,6 +2412,10 @@ export default function MobileTasksScreen() {
tasksSupportState.kind === 'unsupported' &&
tasksSupportState.client === client
const taskUiReady = tasksSupported && taskStateHydrated
const activeGitHubProject = githubProjectSettings.activeProject
const activeGitHubProjectHost = githubProjectHost(
githubProjectTable?.project.host ?? activeGitHubProject?.host
)
const hostedRepos = useMemo(() => repos.filter(isHostedTaskRepo), [repos])
const workspaceRepos = useMemo(() => repos.filter((repo) => repo.kind !== 'folder'), [repos])
const reposById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos])
@@ -2483,9 +2431,10 @@ export default function MobileTasksScreen() {
findRepoForGitHubProjectRepository(
row.content.repository,
hostedRepos,
githubRepoSlugCache
githubRepoSlugCache,
activeGitHubProjectHost
) as RepoSummary | null,
[githubRepoSlugCache, hostedRepos]
[activeGitHubProjectHost, githubRepoSlugCache, hostedRepos]
)
const githubProjectRepoSlugReady = useMemo(
() =>
@@ -2501,10 +2450,11 @@ export default function MobileTasksScreen() {
? (filterGitHubProjectRowsForRepos(
githubProjectTable.rows,
hostedRepos,
githubRepoSlugCache
githubRepoSlugCache,
activeGitHubProjectHost
) as GitHubProjectRow[])
: [],
[githubProjectTable, githubRepoSlugCache, hostedRepos]
[activeGitHubProjectHost, githubProjectTable, githubRepoSlugCache, hostedRepos]
)
const visibleGitHubProjectGroups = useMemo<ProjectGroup[]>(() => {
if (!githubProjectTable) {
@@ -2599,10 +2549,10 @@ export default function MobileTasksScreen() {
return {
repoId: repo.id,
path: repo.path,
slug: result ? `${result.owner}/${result.repo}` : null
repository: result
}
} catch {
return { repoId: repo.id, path: repo.path, slug: null }
return { repoId: repo.id, path: repo.path, repository: null }
}
}).then((entries) => {
if (cancelled) {
@@ -2611,7 +2561,7 @@ export default function MobileTasksScreen() {
setGithubRepoSlugCache((current) => {
const next = { ...current }
for (const entry of entries) {
next[entry.repoId] = { path: entry.path, slug: entry.slug }
next[entry.repoId] = { path: entry.path, repository: entry.repository }
}
return next
})
@@ -2630,7 +2580,6 @@ export default function MobileTasksScreen() {
taskStateHydrated,
tasksSupported
])
const activeGitHubProject = githubProjectSettings.activeProject
const activeGitHubProjectKey = activeGitHubProject ? githubProjectKey(activeGitHubProject) : null
const activeGitHubProjectViewId = activeGitHubProjectKey
? githubProjectSettings.lastViewByProject[activeGitHubProjectKey]?.viewId
@@ -3738,7 +3687,9 @@ export default function MobileTasksScreen() {
}
setGithubProjectError('')
setGithubProjectPartialFailures([])
const response = await client.sendRequest('github.project.listAccessible', {})
const response = await client.sendRequest('github.project.listAccessible', {
host: 'github.com'
})
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
@@ -3763,6 +3714,7 @@ export default function MobileTasksScreen() {
}
const response = await client.sendRequest('github.project.listViews', {
owner: project.owner,
host: githubProjectHost(project.host),
ownerType: project.ownerType,
projectNumber: project.number
})
@@ -3800,6 +3752,7 @@ export default function MobileTasksScreen() {
'github.project.viewTable',
{
owner: activeGitHubProject.owner,
host: activeGitHubProjectHost,
ownerType: activeGitHubProject.ownerType,
projectNumber: activeGitHubProject.number,
viewId: activeGitHubProjectViewId,
@@ -3838,7 +3791,14 @@ export default function MobileTasksScreen() {
setGithubProjectLoading(false)
}
},
[activeGitHubProject, activeGitHubProjectViewId, client, connState, tasksSupported]
[
activeGitHubProject,
activeGitHubProjectHost,
activeGitHubProjectViewId,
client,
connState,
tasksSupported
]
)
const commitGitHubProjectView = useCallback(
@@ -3932,7 +3892,8 @@ export default function MobileTasksScreen() {
return
}
const input = githubProjectPasteInput.trim()
if (!parseProjectInput(input)) {
const parsed = parseProjectInput(input)
if (!parsed) {
setGithubProjectPasteError('Expected a project URL or owner/number.')
return
}
@@ -3940,7 +3901,10 @@ export default function MobileTasksScreen() {
setGithubProjectPasteError('')
setGithubProjectError('')
try {
const response = await client.sendRequest('github.project.resolveRef', { input })
const response = await client.sendRequest('github.project.resolveRef', {
input,
host: githubProjectHost(parsed.host)
})
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
@@ -3951,6 +3915,7 @@ export default function MobileTasksScreen() {
ownerType: GitHubProjectOwnerType
number: number
title: string
host?: string
viewNumber?: number
}
| { ok: false; error: { message: string } }
@@ -3964,7 +3929,8 @@ export default function MobileTasksScreen() {
{
owner: result.owner,
ownerType: result.ownerType,
number: result.number
number: result.number,
host: githubProjectHost(result.host ?? parsed.host)
},
{ viewNumber: result.viewNumber }
)
@@ -4536,6 +4502,7 @@ export default function MobileTasksScreen() {
{
owner: slug.owner,
repo: slug.repo,
host: activeGitHubProjectHost,
number: projectRowItem.content.number,
type
},
@@ -4610,7 +4577,14 @@ export default function MobileTasksScreen() {
return () => {
stale = true
}
}, [client, githubProjectTable, projectRowDetailRefreshSeq, projectRowItem, tasksSupported])
}, [
activeGitHubProjectHost,
client,
githubProjectTable,
projectRowDetailRefreshSeq,
projectRowItem,
tasksSupported
])
useEffect(() => {
const slug = splitRepositorySlug(projectMetadataRepository)
@@ -4628,7 +4602,7 @@ export default function MobileTasksScreen() {
void client
.sendRequest(
'github.project.listLabelsBySlug',
{ owner: slug.owner, repo: slug.repo },
{ owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost },
{ timeoutMs: 30_000 }
)
.then((response) => {
@@ -4660,7 +4634,7 @@ export default function MobileTasksScreen() {
return () => {
stale = true
}
}, [client, projectMetadataRepository, tasksSupported])
}, [activeGitHubProjectHost, client, projectMetadataRepository, tasksSupported])
useEffect(() => {
const slug = splitRepositorySlug(projectMetadataRepository)
@@ -4681,6 +4655,7 @@ export default function MobileTasksScreen() {
{
owner: slug.owner,
repo: slug.repo,
host: activeGitHubProjectHost,
...(projectMetadataSeedLogins ? { seedLogins: projectMetadataSeedLogins.split(',') } : {})
},
{ timeoutMs: 30_000 }
@@ -4716,7 +4691,13 @@ export default function MobileTasksScreen() {
return () => {
stale = true
}
}, [client, projectMetadataRepository, projectMetadataSeedLogins, tasksSupported])
}, [
activeGitHubProjectHost,
client,
projectMetadataRepository,
projectMetadataSeedLogins,
tasksSupported
])
useEffect(() => {
const slug = splitRepositorySlug(projectIssueTypeRepository)
@@ -4734,7 +4715,7 @@ export default function MobileTasksScreen() {
void client
.sendRequest(
'github.project.listIssueTypesBySlug',
{ owner: slug.owner, repo: slug.repo },
{ owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost },
{ timeoutMs: 30_000 }
)
.then((response) => {
@@ -4768,7 +4749,7 @@ export default function MobileTasksScreen() {
return () => {
stale = true
}
}, [client, projectIssueTypeRepository, tasksSupported])
}, [activeGitHubProjectHost, client, projectIssueTypeRepository, tasksSupported])
const getWorkspaceTargetRepo = useCallback(
(item: ActionableTaskItem, repoIdOverride?: string): RepoSummary | null => {
@@ -5729,6 +5710,7 @@ export default function MobileTasksScreen() {
{
owner: slug.owner,
repo: slug.repo,
host: activeGitHubProjectHost,
number: row.content.number,
updates
},
@@ -5790,7 +5772,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, projectMutating]
[activeGitHubProjectHost, client, projectMutating]
)
const addProjectRowComment = useCallback(
@@ -5810,6 +5792,7 @@ export default function MobileTasksScreen() {
{
owner: slug.owner,
repo: slug.repo,
host: activeGitHubProjectHost,
number: row.content.number,
body
},
@@ -5838,7 +5821,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, projectCommentDraft, projectMutating]
[activeGitHubProjectHost, client, projectCommentDraft, projectMutating]
)
const updateProjectRowComment = useCallback(
@@ -5861,6 +5844,7 @@ export default function MobileTasksScreen() {
{
owner: slug.owner,
repo: slug.repo,
host: activeGitHubProjectHost,
commentId,
body
},
@@ -5898,7 +5882,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, projectEditingCommentDraft, projectMutating]
[activeGitHubProjectHost, client, projectEditingCommentDraft, projectMutating]
)
const deleteProjectRowComment = useCallback(
@@ -5920,6 +5904,7 @@ export default function MobileTasksScreen() {
{
owner: slug.owner,
repo: slug.repo,
host: activeGitHubProjectHost,
commentId
},
{ timeoutMs: 30_000 }
@@ -5956,7 +5941,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, projectEditingCommentId, projectMutating]
[activeGitHubProjectHost, client, projectEditingCommentId, projectMutating]
)
const toggleProjectGitHubReviewThread = useCallback(
@@ -5979,6 +5964,7 @@ export default function MobileTasksScreen() {
'github.resolveReviewThread',
{
repo: `id:${repo.id}`,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
threadId: comment.threadId,
resolve
},
@@ -6010,7 +5996,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, findProjectRowRepo, projectMutating]
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating]
)
const replyToProjectGitHubComment = useCallback(
@@ -6038,6 +6024,7 @@ export default function MobileTasksScreen() {
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
commentId: comment.id,
body,
threadId: comment.threadId,
@@ -6051,6 +6038,7 @@ export default function MobileTasksScreen() {
{
repo: `id:${repo.id}`,
number: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
body: `@${commentAuthor(comment)} ${body}`,
type: projectRowType(row) ?? 'issue'
},
@@ -6092,7 +6080,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, findProjectRowRepo, itemReplyDrafts, projectMutating]
[activeGitHubProjectHost, client, findProjectRowRepo, itemReplyDrafts, projectMutating]
)
const mutateProjectRowMetadata = useCallback(
@@ -6120,6 +6108,7 @@ export default function MobileTasksScreen() {
{
owner: slug.owner,
repo: slug.repo,
host: activeGitHubProjectHost,
number: row.content.number,
updates
},
@@ -6204,7 +6193,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, projectMutating]
[activeGitHubProjectHost, client, projectMutating]
)
const mutateProjectRowField = useCallback(
@@ -6223,11 +6212,13 @@ export default function MobileTasksScreen() {
value === null
? {
projectId: githubProjectTable.project.id,
host: activeGitHubProjectHost,
itemId: row.id,
fieldId: field.id
}
: {
projectId: githubProjectTable.project.id,
host: activeGitHubProjectHost,
itemId: row.id,
fieldId: field.id,
value
@@ -6274,7 +6265,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, githubProjectTable, projectMutating]
[activeGitHubProjectHost, client, githubProjectTable, projectMutating]
)
const mutateProjectRowIssueType = useCallback(
@@ -6294,6 +6285,7 @@ export default function MobileTasksScreen() {
{
owner: slug.owner,
repo: slug.repo,
host: activeGitHubProjectHost,
number: row.content.number,
issueTypeId: issueType?.id ?? null
},
@@ -6329,7 +6321,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, projectMutating]
[activeGitHubProjectHost, client, projectMutating]
)
const requestProjectGitHubReviewers = useCallback(
@@ -6350,6 +6342,7 @@ export default function MobileTasksScreen() {
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
reviewers
},
{ timeoutMs: 30_000 }
@@ -6397,7 +6390,14 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, findProjectRowRepo, projectMutating, projectReviewersDraft, projectRowDetail]
[
activeGitHubProjectHost,
client,
findProjectRowRepo,
projectMutating,
projectReviewersDraft,
projectRowDetail
]
)
const refreshProjectGitHubChecks = useCallback(
@@ -6420,6 +6420,7 @@ export default function MobileTasksScreen() {
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined,
noCache: true
},
@@ -6441,7 +6442,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, findProjectRowRepo, projectMutating, projectRowDetail]
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail]
)
const rerunProjectGitHubChecks = useCallback(
@@ -6464,6 +6465,7 @@ export default function MobileTasksScreen() {
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined,
failedOnly
},
@@ -6483,7 +6485,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, findProjectRowRepo, projectMutating, projectRowDetail]
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail]
)
const toggleProjectGitHubFileViewed = useCallback(
@@ -6504,6 +6506,7 @@ export default function MobileTasksScreen() {
'github.setPRFileViewed',
{
repo: `id:${repo.id}`,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
pullRequestId: projectRowDetail.pullRequestId,
path: file.path,
viewed
@@ -6536,7 +6539,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, findProjectRowRepo, projectMutating, projectRowDetail]
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail]
)
const toggleProjectGitHubFileExpansion = useCallback(
@@ -6570,6 +6573,7 @@ export default function MobileTasksScreen() {
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
@@ -6593,7 +6597,14 @@ export default function MobileTasksScreen() {
setPrFileLoadingPath(null)
}
},
[client, expandedPrFilePath, findProjectRowRepo, prFileContents, projectRowDetail]
[
activeGitHubProjectHost,
client,
expandedPrFilePath,
findProjectRowRepo,
prFileContents,
projectRowDetail
]
)
const addProjectGitHubFileReviewComment = useCallback(
@@ -6625,6 +6636,7 @@ export default function MobileTasksScreen() {
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
commitId: projectRowDetail.headSha,
path: file.path,
line,
@@ -6669,7 +6681,14 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, findProjectRowRepo, prFileCommentDrafts, projectMutating, projectRowDetail]
[
activeGitHubProjectHost,
client,
findProjectRowRepo,
prFileCommentDrafts,
projectMutating,
projectRowDetail
]
)
const mergeProjectGitHubPullRequest = useCallback(
@@ -6695,6 +6714,7 @@ export default function MobileTasksScreen() {
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
method
},
{ timeoutMs: 60_000 }
@@ -6731,7 +6751,7 @@ export default function MobileTasksScreen() {
setProjectMutating(false)
}
},
[client, findProjectRowRepo, projectMutating]
[activeGitHubProjectHost, client, findProjectRowRepo, projectMutating]
)
const toggleGitHubStatus = useCallback(
+12
View File
@@ -0,0 +1,12 @@
import { Redirect, useLocalSearchParams } from 'expo-router'
import {
legacyNotificationOptInDestination,
type LegacyNotificationOptInParams
} from '../src/onboarding/legacy-notification-opt-in-destination'
export default function NotificationOptInRedirect() {
const params = useLocalSearchParams<LegacyNotificationOptInParams>()
// Why: restored stacks may contain this retired route; replacement keeps Back from reopening it.
return <Redirect href={legacyNotificationOptInDestination(params)} />
}
@@ -10,6 +10,7 @@ import type { RpcClient } from '../transport/rpc-client'
import { getWorktreeLabel } from '../session/worktree-label'
import {
buildMobileAiVaultResumeLaunch,
prepareMobileAiVaultSessionResume,
createMobileAiVaultResumeMutationRegistry,
readMobileRuntimeHostPlatform,
readMobileRuntimeTerminalWindowsShell,
@@ -197,8 +198,9 @@ export function MobileAgentSessionHistoryPanel({
return
}
const preparedSession = await prepareMobileAiVaultSessionResume(client, session)
const launch = buildMobileAiVaultResumeLaunch({
session,
session: preparedSession,
hostPlatform: platform,
hostTerminalWindowsShell,
settings
@@ -10,7 +10,7 @@ import { PRCommentComposer } from './PRCommentComposer'
import { formatPrCommentRelativeTime } from './pr-comment-time'
import { prCommentsStyles as styles } from './pr-comments-styles'
export type PRCommentRepoSlug = { owner: string; repo: string }
export type PRCommentRepoSlug = { owner: string; repo: string; host?: string }
// Action handlers are passed from the comment actions hook (stable callbacks), so
// adding them keeps the memo'd card from re-rendering on unrelated timeline changes.
@@ -0,0 +1,19 @@
export type LegacyNotificationOptInParams = {
hostId?: string | string[]
}
export type LegacyNotificationOptInDestination = {
pathname: '/mobile-onboarding'
params: { steps: 'notifications'; hostId?: string }
}
/** Maps restored navigation and old deep links onto the canonical notifications step. */
export function legacyNotificationOptInDestination(
params: LegacyNotificationOptInParams
): LegacyNotificationOptInDestination {
const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId
return {
pathname: '/mobile-onboarding',
params: { steps: 'notifications', ...(hostId ? { hostId } : {}) }
}
}
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest'
import NotificationOptInRedirect from '../../app/notification-opt-in'
import { legacyNotificationOptInDestination } from './legacy-notification-opt-in-destination'
const mocks = vi.hoisted(() => ({
params: {} as Record<string, string | string[] | undefined>,
ensureNotificationPermissions: vi.fn()
}))
vi.mock('expo-router', () => ({
Redirect: 'Redirect',
useLocalSearchParams: () => mocks.params
}))
vi.mock('../notifications/mobile-notifications', () => ({
ensureNotificationPermissions: mocks.ensureNotificationPermissions
}))
describe('legacy notification opt-in destination', () => {
it('selects only notifications and preserves a scalar hostId', () => {
expect(legacyNotificationOptInDestination({ hostId: 'paired-host' })).toEqual({
pathname: '/mobile-onboarding',
params: { steps: 'notifications', hostId: 'paired-host' }
})
})
it.each([
[['first-host', 'second-host'], 'first-host'],
[[], undefined],
[undefined, undefined]
] as const)('normalizes hostId %j to %s', (hostId, expectedHostId) => {
expect(legacyNotificationOptInDestination({ hostId })).toEqual({
pathname: '/mobile-onboarding',
params: {
steps: 'notifications',
...(expectedHostId ? { hostId: expectedHostId } : {})
}
})
})
it('ignores unrelated legacy parameters', () => {
const legacyParams = {
hostId: 'paired-host',
steps: 'session-view',
returnTo: '/settings'
}
expect(legacyNotificationOptInDestination(legacyParams)).toEqual({
pathname: '/mobile-onboarding',
params: { steps: 'notifications', hostId: 'paired-host' }
})
})
})
describe('/notification-opt-in compatibility route', () => {
it('is present and synchronously returns a replace-style redirect to the canonical route', () => {
mocks.params = {
hostId: ['paired-host', 'ignored-host'],
steps: 'session-view',
unrelated: 'ignored'
}
expect(NotificationOptInRedirect()).toMatchObject({
type: 'Redirect',
props: {
href: {
pathname: '/mobile-onboarding',
params: { steps: 'notifications', hostId: 'paired-host' }
}
}
})
expect(mocks.ensureNotificationPermissions).not.toHaveBeenCalled()
})
})
@@ -198,7 +198,10 @@ describe('resumeAiVaultSessionInTerminal', () => {
agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }
},
launchAgent: 'claude',
clientMutationId: 'resume-1'
clientMutationId: 'resume-1',
activate: false,
select: true,
navigation: 'caller'
})
).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' })
expect(sendRequest).toHaveBeenNthCalledWith(
@@ -214,7 +217,10 @@ describe('resumeAiVaultSessionInTerminal', () => {
agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }
},
launchAgent: 'claude',
clientMutationId: 'resume-1'
clientMutationId: 'resume-1',
activate: false,
select: true,
navigation: 'caller'
},
// Why: a socket drop mid-resume must reject within the request timeout
// instead of parking on the reconnect waiter with the spinner pinned.
+41 -1
View File
@@ -4,6 +4,10 @@ import {
buildAiVaultResumeShellCommand,
realHomeCodexResumeEnvDeletion
} from '../../../src/shared/ai-vault-types'
import {
isAiVaultPrepareSessionResumeUnavailableError,
isLegacySharedCodexHome
} from '../../../src/shared/ai-vault-resume-preparation'
import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume'
import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume'
import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup'
@@ -151,6 +155,39 @@ function normalizeMobileAiVaultResumeCommandOverrides(
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
export const RESUME_RPC_TIMEOUT_MS = 30_000
export async function prepareMobileAiVaultSessionResume(
client: Pick<RpcClient, 'sendRequest'>,
session: AiVaultSession
): Promise<AiVaultSession> {
if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) {
return session
}
const response = await client.sendRequest(
'aiVault.prepareSessionResume',
{
agent: session.agent,
filePath: session.filePath,
codexHome: session.codexHome,
executionHostId: session.executionHostId
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
if (!response.ok) {
if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) {
// Why: older hosts cannot prepare, but their shared home still supports the legacy resume path.
return session
}
throw new Error(
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
)
}
const result = response.result as { useRealCodexHome?: unknown } | null
if (result?.useRealCodexHome !== true) {
return session
}
return { ...session, codexHome: null }
}
export async function resumeAiVaultSessionInTerminal(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
@@ -164,7 +201,10 @@ export async function resumeAiVaultSessionInTerminal(
...(launch.envToDelete ? { envToDelete: launch.envToDelete } : {}),
...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}),
...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}),
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {})
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}),
activate: false,
select: true,
navigation: 'caller'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
@@ -0,0 +1,163 @@
import { describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import {
buildMobileAiVaultResumeLaunch,
prepareMobileAiVaultSessionResume,
resumeAiVaultSessionInTerminal,
RESUME_RPC_TIMEOUT_MS
} from './ai-vault-resume-launch'
const LEGACY_CODEX_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home'
function legacySession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
id: 'codex:legacy-1',
executionHostId: 'local',
agent: 'codex',
sessionId: 'legacy-1',
title: 'Resume me',
cwd: '/Users/ada/repo',
branch: 'main',
model: null,
filePath: `${LEGACY_CODEX_HOME}/sessions/2026/07/20/rollout-a.jsonl`,
codexHome: LEGACY_CODEX_HOME,
createdAt: null,
updatedAt: null,
modifiedAt: '2026-07-20T00:00:00.000Z',
messageCount: 2,
totalTokens: 10,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: '',
subagent: null,
...overrides
}
}
describe('prepareMobileAiVaultSessionResume', () => {
it.each([
{
code: 'forbidden',
message: "Method 'aiVault.prepareSessionResume' is not available to mobile clients"
},
{
code: 'method_not_found',
message: 'Unknown method: aiVault.prepareSessionResume'
}
])('uses the legacy command and environment on old-host $code', async (error) => {
const legacy = legacySession()
const sendRequest = vi
.fn()
.mockResolvedValueOnce({ ok: false, error })
.mockResolvedValueOnce({
ok: true,
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
})
.mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } })
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy)
const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' })
await resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', launch)
expect(prepared).toBe(legacy)
expect(sendRequest.mock.calls[1]?.[1]).not.toHaveProperty('envToDelete')
expect(sendRequest).toHaveBeenNthCalledWith(
3,
'terminal.send',
{
terminal: 'pty-1',
text: `cd '/Users/ada/repo' && CODEX_HOME='${LEGACY_CODEX_HOME}' codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'legacy-1'`,
enter: true
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
})
it('uses the real Codex home when a supported desktop requests it', async () => {
const legacy = legacySession()
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: true }
})
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy)
const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' })
expect(sendRequest).toHaveBeenCalledWith(
'aiVault.prepareSessionResume',
{
agent: 'codex',
filePath: legacy.filePath,
codexHome: LEGACY_CODEX_HOME,
executionHostId: 'local'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
expect(prepared).toEqual({ ...legacy, codexHome: null })
expect(launch.command).toBe(
"cd '/Users/ada/repo' && codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'legacy-1'"
)
expect(launch.envToDelete).toEqual(['CODEX_HOME', 'ORCA_CODEX_HOME'])
})
it('preserves the legacy resume path when a supported desktop declines real-home use', async () => {
const legacy = legacySession()
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: false }
})
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, legacy)
const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' })
expect(prepared).toBe(legacy)
expect(launch.command).toContain(`CODEX_HOME='${LEGACY_CODEX_HOME}'`)
expect(launch.envToDelete).toBeUndefined()
})
it.each([
{ agent: 'claude' as const, codexHome: null },
{ agent: 'codex' as const, codexHome: '/Users/ada/.config/codex' },
{
agent: 'codex' as const,
codexHome: '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
},
{ agent: 'codex' as const, codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' }
])('does not prepare non-legacy session $agent at $codexHome', async (overrides) => {
const current = legacySession(overrides)
const sendRequest = vi.fn()
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
expect(sendRequest).not.toHaveBeenCalled()
})
it.each([
{
code: 'internal_error',
message: 'Retry resume after checking session folder permissions.'
},
{
code: 'forbidden',
message: 'Session resume is forbidden'
}
])('keeps genuine server error visible: $code', async (error) => {
const sendRequest = vi.fn().mockResolvedValue({ ok: false, error })
await expect(
prepareMobileAiVaultSessionResume({ sendRequest }, legacySession())
).rejects.toThrow(error.message)
})
it.each(['Request timed out: aiVault.prepareSessionResume', 'Connection interrupted'])(
'keeps transport failure visible: %s',
async (message) => {
const failure = new Error(message)
const sendRequest = vi.fn().mockRejectedValue(failure)
await expect(
prepareMobileAiVaultSessionResume({ sendRequest }, legacySession())
).rejects.toBe(failure)
}
)
})
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import {
FLOATING_WORKSPACE_WORKTREE_ID,
floatingWorkspaceSessionPath,
isFloatingWorkspaceWorktreeId
} from './floating-workspace'
describe('floating workspace routing', () => {
it('matches only the desktop sentinel id', () => {
expect(isFloatingWorkspaceWorktreeId('global-floating-terminal')).toBe(true)
expect(isFloatingWorkspaceWorktreeId('repo-1::/worktree')).toBe(false)
expect(isFloatingWorkspaceWorktreeId('folder:group-1')).toBe(false)
expect(isFloatingWorkspaceWorktreeId(undefined)).toBe(false)
expect(isFloatingWorkspaceWorktreeId(null)).toBe(false)
})
it('builds the session route with an explicit title seed', () => {
expect(floatingWorkspaceSessionPath('host-1')).toBe(
`/h/host-1/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=Floating%20Workspace`
)
})
})
+15
View File
@@ -0,0 +1,15 @@
// Mirrors FLOATING_TERMINAL_WORKTREE_ID in src/shared/constants.ts — the desktop
// Floating Workspace's synthetic id (no backing repo/worktree; always local runtime).
export const FLOATING_WORKSPACE_WORKTREE_ID = 'global-floating-terminal'
export const FLOATING_WORKSPACE_TITLE = 'Floating Workspace'
export function isFloatingWorkspaceWorktreeId(worktreeId: string | null | undefined): boolean {
return worktreeId === FLOATING_WORKSPACE_WORKTREE_ID
}
// Route target for the host-header entry; the ?name param seeds the session
// screen title before tabs load.
export function floatingWorkspaceSessionPath(hostId: string | undefined): string {
return `/h/${hostId}/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=${encodeURIComponent(FLOATING_WORKSPACE_TITLE)}`
}
+50 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '../transport/types'
import {
fetchAddIssueComment,
fetchAddPRReviewCommentReply,
fetchDeleteIssueComment,
fetchMergePR,
fetchResolveReviewThread,
@@ -29,6 +31,7 @@ function clientRejecting(message: string) {
}
const WORKTREE_ID = 'repo-42::/path/to/wt'
const ENTERPRISE_PR_REPO = { owner: 'o', repo: 'r', host: 'github.acme.test' }
describe('fetchResolveReviewThread / fetchUpdatePRTitle — bare-boolean host result', () => {
it('treats an explicit true as success', async () => {
@@ -102,12 +105,51 @@ describe('mutation transport rejection normalization', () => {
})
})
describe('Enterprise PR repo forwarding', () => {
it('forwards the host through title, root-comment, and review-reply RPCs', async () => {
const titleClient = clientReturning(okResponse(true))
await fetchUpdatePRTitle(titleClient, WORKTREE_ID, {
prNumber: 7,
title: 'New title',
prRepo: ENTERPRISE_PR_REPO
})
expect(titleClient.sendRequest).toHaveBeenCalledWith(
'github.updatePRTitle',
expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO })
)
const rootClient = clientReturning(okResponse({ ok: true }))
await fetchAddIssueComment(rootClient, WORKTREE_ID, {
prNumber: 7,
body: 'Root comment',
prRepo: ENTERPRISE_PR_REPO
})
expect(rootClient.sendRequest).toHaveBeenCalledWith(
'github.addIssueComment',
expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO })
)
const replyClient = clientReturning(okResponse({ ok: true }))
await fetchAddPRReviewCommentReply(replyClient, WORKTREE_ID, {
prNumber: 7,
commentId: 42,
body: 'Reply',
prRepo: ENTERPRISE_PR_REPO
})
expect(replyClient.sendRequest).toHaveBeenCalledWith(
'github.addPRReviewCommentReply',
expect.objectContaining({ prRepo: ENTERPRISE_PR_REPO })
)
})
})
describe('fetchUpdateIssueComment / fetchDeleteIssueComment — slug-addressed envelope', () => {
it('sends owner/repo/commentId(+body) and reads the { ok } envelope', async () => {
const editClient = clientReturning(okResponse({ ok: true }))
const edit = await fetchUpdateIssueComment(editClient, {
owner: 'o',
repo: 'r',
host: 'github.acme.test',
commentId: 5,
body: 'edited'
})
@@ -115,16 +157,23 @@ describe('fetchUpdateIssueComment / fetchDeleteIssueComment — slug-addressed e
expect(editClient.sendRequest).toHaveBeenCalledWith('github.project.updateIssueCommentBySlug', {
owner: 'o',
repo: 'r',
host: 'github.acme.test',
commentId: 5,
body: 'edited'
})
const delClient = clientReturning(okResponse({ ok: true }))
const del = await fetchDeleteIssueComment(delClient, { owner: 'o', repo: 'r', commentId: 5 })
const del = await fetchDeleteIssueComment(delClient, {
owner: 'o',
repo: 'r',
host: 'github.acme.test',
commentId: 5
})
expect(del).toEqual({ ok: true })
expect(delClient.sendRequest).toHaveBeenCalledWith('github.project.deleteIssueCommentBySlug', {
owner: 'o',
repo: 'r',
host: 'github.acme.test',
commentId: 5
})
})
+45 -51
View File
@@ -1,6 +1,6 @@
import type { GitHubPRMergeMethod } from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
import { buildGithubPrParams, type GitHubPrRepoSlug } from './github-pr-rpc'
import { buildGithubPrParams, githubPrRepoSlugParam, type GitHubPrRepoSlug } from './github-pr-rpc'
// Mutation wrappers for the github.* PR surface, split out so github-pr-rpc.ts
// stays under the max-lines budget. They mirror the read wrappers' shape but
@@ -101,16 +101,10 @@ export async function fetchUpdatePRTitle(
args: { prNumber: number; title: string; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
const params: Record<string, unknown> = { prNumber: args.prNumber, title: args.title }
// updatePRTitle accepts prRepo for fork PRs, but it is not in the centralized
// METHODS_ACCEPTING_PR_REPO read allow-list — pass it explicitly so it reaches the
// host schema (which declares it optional/nullable).
if (args.prRepo) {
params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo }
}
const response = await sendRaw(
client,
'github.updatePRTitle',
buildGithubPrParams('github.updatePRTitle', worktreeId, params)
buildGithubPrParams('github.updatePRTitle', worktreeId, params, { prRepo: args.prRepo })
)
if (!response.ok) {
return { ok: false, error: response.error || 'Request failed: github.updatePRTitle' }
@@ -147,48 +141,51 @@ export async function fetchSetPRAutoMerge(
export async function fetchUpdatePRState(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { prNumber: number; state: 'open' | 'closed' }
args: { prNumber: number; state: 'open' | 'closed'; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
// updatePRState does NOT accept prRepo (KTD3) — buildGithubPrParams omits it.
return sendGithubPrMutation(
client,
'github.updatePRState',
buildGithubPrParams('github.updatePRState', worktreeId, {
prNumber: args.prNumber,
updates: { state: args.state }
})
buildGithubPrParams(
'github.updatePRState',
worktreeId,
{ prNumber: args.prNumber, updates: { state: args.state } },
{ prRepo: args.prRepo }
)
)
}
export async function fetchRequestPRReviewers(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { prNumber: number; reviewers: string[] }
args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
// requestPRReviewers does NOT accept prRepo (KTD3).
return sendGithubPrMutation(
client,
'github.requestPRReviewers',
buildGithubPrParams('github.requestPRReviewers', worktreeId, {
prNumber: args.prNumber,
reviewers: args.reviewers
})
buildGithubPrParams(
'github.requestPRReviewers',
worktreeId,
{ prNumber: args.prNumber, reviewers: args.reviewers },
{ prRepo: args.prRepo }
)
)
}
export async function fetchRemovePRReviewers(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { prNumber: number; reviewers: string[] }
args: { prNumber: number; reviewers: string[]; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
// removePRReviewers does NOT accept prRepo (KTD3).
return sendGithubPrMutation(
client,
'github.removePRReviewers',
buildGithubPrParams('github.removePRReviewers', worktreeId, {
prNumber: args.prNumber,
reviewers: args.reviewers
})
buildGithubPrParams(
'github.removePRReviewers',
worktreeId,
{ prNumber: args.prNumber, reviewers: args.reviewers },
{ prRepo: args.prRepo }
)
)
}
@@ -222,16 +219,12 @@ export async function fetchAddPRReviewCommentReply(
if (typeof args.line === 'number') {
params.line = args.line
}
// addPRReviewCommentReply accepts prRepo for fork PRs, but it is not in the
// centralized METHODS_ACCEPTING_PR_REPO allow-list (read-focused) — pass it
// explicitly so it reaches the host schema, which declares it optional.
if (args.prRepo) {
params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo }
}
return sendGithubPrMutation(
client,
'github.addPRReviewCommentReply',
buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params)
buildGithubPrParams('github.addPRReviewCommentReply', worktreeId, params, {
prRepo: args.prRepo
})
)
}
@@ -246,13 +239,10 @@ export async function fetchAddIssueComment(
body: args.body,
type: 'pr'
}
if (args.prRepo) {
params.prRepo = { owner: args.prRepo.owner, repo: args.prRepo.repo }
}
return sendGithubPrMutation(
client,
'github.addIssueComment',
buildGithubPrParams('github.addIssueComment', worktreeId, params)
buildGithubPrParams('github.addIssueComment', worktreeId, params, { prRepo: args.prRepo })
)
}
@@ -262,15 +252,17 @@ export async function fetchAddIssueComment(
export async function fetchResolveReviewThread(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { threadId: string; resolve: boolean }
args: { threadId: string; resolve: boolean; prRepo?: GitHubPrRepoSlug | null }
): Promise<GitHubPrMutationOutcome> {
const response = await sendRaw(
client,
'github.resolveReviewThread',
buildGithubPrParams('github.resolveReviewThread', worktreeId, {
threadId: args.threadId,
resolve: args.resolve
})
buildGithubPrParams(
'github.resolveReviewThread',
worktreeId,
{ threadId: args.threadId, resolve: args.resolve },
{ prRepo: args.prRepo }
)
)
if (!response.ok) {
return {
@@ -292,11 +284,10 @@ export async function fetchResolveReviewThread(
// GitHubProjectMutationResult `{ ok }` envelope sendGithubPrMutation reads.
export async function fetchUpdateIssueComment(
client: Pick<RpcClient, 'sendRequest'>,
args: { owner: string; repo: string; commentId: number; body: string }
args: { owner: string; repo: string; host?: string; commentId: number; body: string }
): Promise<GitHubPrMutationOutcome> {
return sendGithubPrMutation(client, 'github.project.updateIssueCommentBySlug', {
owner: args.owner,
repo: args.repo,
...githubPrRepoSlugParam(args),
commentId: args.commentId,
body: args.body
})
@@ -305,11 +296,10 @@ export async function fetchUpdateIssueComment(
// Delete a root conversation (issue) comment. Slug-addressed like the edit wrapper.
export async function fetchDeleteIssueComment(
client: Pick<RpcClient, 'sendRequest'>,
args: { owner: string; repo: string; commentId: number }
args: { owner: string; repo: string; host?: string; commentId: number }
): Promise<GitHubPrMutationOutcome> {
return sendGithubPrMutation(client, 'github.project.deleteIssueCommentBySlug', {
owner: args.owner,
repo: args.repo,
...githubPrRepoSlugParam(args),
commentId: args.commentId
})
}
@@ -317,9 +307,13 @@ export async function fetchDeleteIssueComment(
export async function fetchRerunPRChecks(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: { prNumber: number; headSha?: string | null; failedOnly?: boolean }
args: {
prNumber: number
headSha?: string | null
failedOnly?: boolean
prRepo?: GitHubPrRepoSlug | null
}
): Promise<GitHubPrMutationOutcome> {
// rerunPRChecks does NOT accept prRepo (KTD3); headSha is a plain param here.
const params: Record<string, unknown> = { prNumber: args.prNumber }
if (args.failedOnly !== undefined) {
params.failedOnly = args.failedOnly
@@ -330,6 +324,6 @@ export async function fetchRerunPRChecks(
return sendGithubPrMutation(
client,
'github.rerunPRChecks',
buildGithubPrParams('github.rerunPRChecks', worktreeId, params)
buildGithubPrParams('github.rerunPRChecks', worktreeId, params, { prRepo: args.prRepo })
)
}
+26 -14
View File
@@ -103,13 +103,17 @@ describe('readPRForBranch', () => {
const parsed = readPRForBranch({
number: 3,
state: 'open',
prRepo: { owner: 'forkOwner', repo: 'forkRepo' },
prRepo: { owner: 'forkOwner', repo: 'forkRepo', host: 'github.acme.test' },
mergeMethodSettings: {
defaultMethod: 'squash',
allowedMethods: { merge: false, squash: true, rebase: true }
}
})
expect(parsed?.prRepo).toEqual({ owner: 'forkOwner', repo: 'forkRepo' })
expect(parsed?.prRepo).toEqual({
owner: 'forkOwner',
repo: 'forkRepo',
host: 'github.acme.test'
})
expect(parsed?.mergeMethodSettings).toEqual({
defaultMethod: 'squash',
allowedMethods: { merge: false, squash: true, rebase: true }
@@ -266,7 +270,7 @@ describe('readAssignableUsers', () => {
})
describe('buildGithubPrParams — method-aware prRepo / headSha', () => {
const fork = { owner: 'forkOwner', repo: 'forkRepo' }
const fork = { owner: 'forkOwner', repo: 'forkRepo', host: 'github.acme.test' }
it('reuses mobileRepoSelectorFromWorktreeId for the repo selector', () => {
const params = buildGithubPrParams('github.prChecks', WORKTREE_ID, { prNumber: 1 })
@@ -278,9 +282,20 @@ describe('buildGithubPrParams — method-aware prRepo / headSha', () => {
for (const method of [
'github.prChecks',
'github.prCheckDetails',
'github.rerunPRChecks',
'github.resolveReviewThread',
'github.setPRFileViewed',
'github.updatePRState',
'github.requestPRReviewers',
'github.removePRReviewers',
'github.mergePR',
'github.setPRAutoMerge',
'github.prComments'
'github.updatePRTitle',
'github.prComments',
'github.prFileContents',
'github.addPRReviewComment',
'github.addIssueComment',
'github.addPRReviewCommentReply'
]) {
const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork })
expect(params.prRepo).toEqual(fork)
@@ -288,13 +303,7 @@ describe('buildGithubPrParams — method-aware prRepo / headSha', () => {
})
it('omits prRepo for methods that reject it', () => {
for (const method of [
'github.updatePRState',
'github.requestPRReviewers',
'github.removePRReviewers',
'github.listAssignableUsers',
'github.rerunPRChecks'
]) {
for (const method of ['github.repoSlug', 'github.prForBranch', 'github.listAssignableUsers']) {
const params = buildGithubPrParams(method, WORKTREE_ID, { prNumber: 1 }, { prRepo: fork })
expect('prRepo' in params).toBe(false)
}
@@ -380,12 +389,15 @@ describe('fetch wrappers', () => {
expect('headSha' in (params as object)).toBe(false)
})
it('fetchGithubRepoSlug returns the slug for a github repo, null otherwise', async () => {
it('fetchGithubRepoSlug preserves an Enterprise host, and returns null otherwise', async () => {
const found = await fetchGithubRepoSlug(
mockClient(okResponse({ owner: 'o', repo: 'r' })).client,
mockClient(okResponse({ owner: 'o', repo: 'r', host: 'github.acme.test' })).client,
WORKTREE_ID
)
expect(found).toEqual({ ok: true, result: { owner: 'o', repo: 'r' } })
expect(found).toEqual({
ok: true,
result: { owner: 'o', repo: 'r', host: 'github.acme.test' }
})
const none = await fetchGithubRepoSlug(mockClient(okResponse(null)).client, WORKTREE_ID)
expect(none).toEqual({ ok: true, result: null })
})
+26 -8
View File
@@ -31,20 +31,35 @@ export {
// Why: a fork PR's head lives in a different owner/repo; the host's SlugRepo
// (`{ owner, repo }`) identifies it. Only a subset of github.* methods accept it.
export type GitHubPrRepoSlug = { owner: string; repo: string }
// Why: `host` must survive the RPC boundary or GHES actions on the host fall
// back to a same-named github.com repo (src/shared/types.ts identity contract).
export type GitHubPrRepoSlug = { owner: string; repo: string; host?: string }
export function githubPrRepoSlugParam(slug: GitHubPrRepoSlug): Record<string, string> {
return { owner: slug.owner, repo: slug.repo, ...(slug.host ? { host: slug.host } : {}) }
}
export type GitHubPrReadOutcome<T> = { ok: true; result: T } | { ok: false; error: string }
// Why: `prRepo` is method-asymmetric (KTD3). These are the only github.* methods
// whose host schema (SlugRepo on PullRequest/PullRequestChecks/PullRequestCheckDetails)
// accepts it; the rest reject the key. Centralizing the allow-list keeps a fork's
// prRepo from leaking into a schema that would reject it.
// Why: `prRepo` remains method-asymmetric. Keep the RPC schema allow-list here
// so fork/GHES identity reaches every PR-scoped read or mutation that accepts it.
const METHODS_ACCEPTING_PR_REPO = new Set<string>([
'github.prChecks',
'github.prCheckDetails',
'github.rerunPRChecks',
'github.resolveReviewThread',
'github.setPRFileViewed',
'github.updatePRState',
'github.requestPRReviewers',
'github.removePRReviewers',
'github.mergePR',
'github.setPRAutoMerge',
'github.prComments'
'github.updatePRTitle',
'github.prComments',
'github.prFileContents',
'github.addPRReviewComment',
'github.addIssueComment',
'github.addPRReviewCommentReply'
])
// Why: only github.prChecks declares a `headSha` param (PullRequestCheckDetails
@@ -62,7 +77,7 @@ export function buildGithubPrParams(
...params
}
if (options?.prRepo && METHODS_ACCEPTING_PR_REPO.has(method) && !('prRepo' in built)) {
built.prRepo = { owner: options.prRepo.owner, repo: options.prRepo.repo }
built.prRepo = githubPrRepoSlugParam(options.prRepo)
}
if (options?.headSha && METHODS_ACCEPTING_HEAD_SHA.has(method) && !('headSha' in built)) {
built.headSha = options.headSha
@@ -107,7 +122,10 @@ export async function fetchGithubRepoSlug(
const record = value as Record<string, unknown>
const owner = record.owner
const repo = record.repo
return typeof owner === 'string' && typeof repo === 'string' ? { owner, repo } : null
const host = record.host
return typeof owner === 'string' && typeof repo === 'string'
? { owner, repo, ...(typeof host === 'string' && host ? { host } : {}) }
: null
}
)
}
@@ -9,6 +9,14 @@ describe('readRepoIdentity', () => {
})
})
it('preserves an Enterprise host', () => {
expect(readRepoIdentity({ owner: 'octo', repo: 'orca', host: 'github.acme.test' })).toEqual({
owner: 'octo',
repo: 'orca',
host: 'github.acme.test'
})
})
it('drops a non-record value', () => {
expect(readRepoIdentity(null)).toBeUndefined()
expect(readRepoIdentity('octo/orca')).toBeUndefined()
@@ -153,7 +153,10 @@ export function readRepoIdentity(value: unknown): GitHubRepositoryIdentity | und
if (!owner || !repo) {
return undefined
}
return { owner, repo }
// Why: dropping `host` here would strip the GHES identity before every
// subsequent PR RPC, forcing the host to re-derive it per call.
const host = readString(value.host)
return { owner, repo, ...(host ? { host } : {}) }
}
function readMergeMethod(value: unknown): GitHubPRMergeMethod | undefined {
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace'
import { loadMobileNewTabAgentOptions } from './mobile-new-tab-agent-loader'
function createClient(
handler: (method: string, params?: unknown) => Promise<unknown>
): RpcClient & { sendRequest: ReturnType<typeof vi.fn> } {
return {
sendRequest: vi.fn(handler),
subscribe: vi.fn(() => () => {})
} as unknown as RpcClient & { sendRequest: ReturnType<typeof vi.fn> }
}
describe('mobile new-tab agent loading', () => {
it('detects agents locally for the floating workspace without listing repos', async () => {
const client = createClient(async (method) => {
if (method === 'settings.get') {
return {
ok: true,
result: { settings: { defaultTuiAgent: 'codex', disabledTuiAgents: [] } }
}
}
if (method === 'preflight.detectAgents') {
return { ok: true, result: ['claude', 'codex'] }
}
throw new Error(`unexpected request: ${method}`)
})
await expect(
loadMobileNewTabAgentOptions({
client,
worktreeId: FLOATING_WORKSPACE_WORKTREE_ID
})
).resolves.toEqual([
{ agent: 'codex', label: 'Codex' },
{ agent: 'claude', label: 'Claude' }
])
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'preflight.detectAgents',
'settings.get'
])
})
it('detects agents through the worktree repo connection for SSH sessions', async () => {
const client = createClient(async (method, params) => {
if (method === 'settings.get') {
return { ok: true, result: { settings: {} } }
}
if (method === 'repo.list') {
return { ok: true, result: { repos: [{ id: 'repo-1', connectionId: 'ssh-1' }] } }
}
if (method === 'preflight.detectRemoteAgents') {
expect(params).toEqual({ connectionId: 'ssh-1' })
return { ok: true, result: ['claude'] }
}
throw new Error(`unexpected request: ${method}`)
})
await expect(
loadMobileNewTabAgentOptions({
client,
worktreeId: 'repo-1::/remote/worktree'
})
).resolves.toEqual([{ agent: 'claude', label: 'Claude' }])
expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
'repo.list',
'settings.get',
'preflight.detectRemoteAgents'
])
})
})
@@ -0,0 +1,62 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcSuccess } from '../transport/types'
import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers'
import {
buildMobileNewTabAgentOptions,
type MobileNewTabAgentOption,
type MobileNewTabAgentSettings
} from './mobile-new-tab-agent-options'
type RuntimeRepoSummary = {
id: string
connectionId?: string | null
}
export async function loadMobileNewTabAgentOptions(args: {
client: RpcClient
worktreeId: string
}): Promise<MobileNewTabAgentOption[]> {
const { client, worktreeId } = args
// Why: the floating workspace runs on the paired host, so it has no repo connection to resolve.
const detectedAgentsRequest = isFloatingWorkspaceWorktreeId(worktreeId)
? client.sendRequest('preflight.detectAgents')
: loadWorkspaceDetectedAgents(client, worktreeId)
const [settingsResponse, detectedResponse] = await Promise.all([
client.sendRequest('settings.get'),
detectedAgentsRequest
])
if (!settingsResponse.ok) {
throw new Error((settingsResponse as RpcFailure).error.message)
}
if (!detectedResponse.ok) {
throw new Error((detectedResponse as RpcFailure).error.message)
}
const settings = (
(settingsResponse as RpcSuccess).result as {
settings?: MobileNewTabAgentSettings
}
).settings
return buildMobileNewTabAgentOptions(
settings,
(detectedResponse as RpcSuccess).result as unknown[]
)
}
async function loadWorkspaceDetectedAgents(client: RpcClient, worktreeId: string) {
const repoResponse = await client.sendRequest('repo.list')
if (!repoResponse.ok) {
throw new Error((repoResponse as RpcFailure).error.message)
}
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
const repos =
((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos ?? []
const repo = repos.find((candidate) => candidate.id === repoId)
if (!repo) {
throw new Error('worktree_repo_not_found')
}
const connectionId = repo.connectionId?.trim() || null
return connectionId
? client.sendRequest('preflight.detectRemoteAgents', { connectionId })
: client.sendRequest('preflight.detectAgents')
}
@@ -36,7 +36,10 @@ describe('mobile session startup', () => {
)
expect(startupEffect).toContain("void client\n .sendRequest('worktree.activate'")
expect(startupEffect).toContain("if (client && created !== '1' && !isFloatingWorkspaceRoute)")
expect(startupEffect).toContain("if (client && created === '1' && !isFloatingWorkspaceRoute)")
expect(startupEffect).toContain('notifyClients: false')
expect(startupEffect).toContain("navigation: 'caller'")
expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'")
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
startupEffect.indexOf('await fetchSessionTabs()')
@@ -62,21 +65,23 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id')
expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId')
expect(pendingActivationEffect).toContain('notifyClients: false')
expect(pendingActivationEffect).toContain("navigation: 'caller'")
expect(pendingActivationEffect).toContain(
'applySessionTabs((response as RpcSuccess).result as SessionTabsResult)'
)
expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()')
})
it('mirrors ready terminal taps while persisting them for headless hosts', () => {
it('keeps ready terminal taps local while publishing caller selection', () => {
const readyTerminalSwitch = sliceBetween(
'const switchTab = useCallback(',
'const switchSessionTab = useCallback('
)
expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)')
expect(readyTerminalSwitch).not.toContain('focusMobileTerminal(client, handle)')
expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,')
expect(readyTerminalSwitch).toContain('notifyClients: false')
expect(readyTerminalSwitch).toContain("navigation: 'caller'")
})
it('keeps background and pending session-tab activation local to the phone', () => {
@@ -85,6 +90,7 @@ describe('mobile session startup', () => {
expect(activationRequests).toHaveLength(4)
for (const request of activationRequests) {
expect(request.slice(0, request.indexOf('})'))).toContain('notifyClients: false')
expect(request.slice(0, request.indexOf('})'))).toContain("navigation: 'caller'")
}
})
@@ -25,8 +25,14 @@ describe('mobile session tab activation', () => {
}
)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' })
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' })
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', {
terminal: 'terminal-1',
navigation: 'host'
})
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', {
terminal: 'terminal-1',
navigation: 'host'
})
})
it('retries session-tab activation with the same target after cutover', async () => {
@@ -38,7 +44,8 @@ describe('mobile session tab activation', () => {
worktree: 'id:worktree-1',
tabId: 'tab-1',
leafId: 'leaf-1',
notifyClients: false as const
notifyClients: false as const,
navigation: 'caller' as const
}
await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({
@@ -67,7 +74,8 @@ describe('mobile session tab activation', () => {
activateMobileSessionTab(clientWith(sendRequest), {
worktree: 'id:worktree-1',
tabId: 'tab-1',
notifyClients: false
notifyClients: false,
navigation: 'caller'
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
expect(sendRequest).toHaveBeenCalledTimes(2)
@@ -14,6 +14,7 @@ type MobileSessionTabActivationParams = {
tabId: string
leafId?: string
notifyClients: false
navigation: 'caller'
}
async function retryIdempotentActivationAfterCutover(
@@ -72,7 +73,7 @@ export function focusMobileTerminal(
terminal: string
): Promise<RpcResponse> {
return retryIdempotentActivationAfterCutover(
() => client.sendRequest('terminal.focus', { terminal }),
() => client.sendRequest('terminal.focus', { terminal, navigation: 'host' }),
'terminal.focus',
terminal
)
@@ -0,0 +1,38 @@
import type { GitHubPRMergeMethod } from '../../../src/shared/types'
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
import type { GitHubPrRepoSlug } from './github-pr-rpc'
export type PrActionMutations = {
mergePR: (args: {
prNumber: number
method?: GitHubPRMergeMethod
prRepo?: GitHubPrRepoSlug | null
}) => Promise<GitHubPrMutationOutcome>
setPRAutoMerge: (args: {
prNumber: number
enabled: boolean
method?: GitHubPRMergeMethod
prRepo?: GitHubPrRepoSlug | null
}) => Promise<GitHubPrMutationOutcome>
updatePRState: (args: {
prNumber: number
state: 'open' | 'closed'
prRepo?: GitHubPrRepoSlug | null
}) => Promise<GitHubPrMutationOutcome>
requestReviewers: (args: {
prNumber: number
reviewers: string[]
prRepo?: GitHubPrRepoSlug | null
}) => Promise<GitHubPrMutationOutcome>
removeReviewers: (args: {
prNumber: number
reviewers: string[]
prRepo?: GitHubPrRepoSlug | null
}) => Promise<GitHubPrMutationOutcome>
rerunChecks: (args: {
prNumber: number
headSha?: string | null
failedOnly?: boolean
prRepo?: GitHubPrRepoSlug | null
}) => Promise<GitHubPrMutationOutcome>
}
@@ -198,4 +198,43 @@ describe('PrActionsEngine — PR identity changes', () => {
await action
expect(refetch).not.toHaveBeenCalled()
})
it('clears in-flight state when only the GitHub host changes', async () => {
const slow = deferred<GitHubPrMutationOutcome>()
const mutations: PrActionMutations = {
mergePR: async () => ({ ok: true }),
setPRAutoMerge: async () => slow.promise,
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
}
const refetch = vi.fn()
const onChange = vi.fn()
const baseConfig = {
mutations,
prNumber: 1,
refetch,
onChange
}
const engine = new PrActionsEngine({
...baseConfig,
prRepo: { owner: 'acme', repo: 'widgets' }
})
const action = engine.setAutoMerge(true)
expect(engine.resolveAutoMerge(false)).toBe(true)
expect(engine.isBusy({ kind: 'autoMerge' })).toBe(true)
engine.updateConfig({
...baseConfig,
prRepo: { owner: 'acme', repo: 'widgets', host: 'github.acme.test' }
})
expect(engine.resolveAutoMerge(false)).toBe(false)
expect(engine.busy).toBeNull()
slow.resolve({ ok: true })
await action
expect(refetch).not.toHaveBeenCalled()
})
})
+13 -36
View File
@@ -1,45 +1,18 @@
import type { GitHubPRMergeMethod, PRState } from '../../../src/shared/types'
import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key'
import { classifyPrSidebarFailure } from './mobile-pr-sidebar-state'
import { createOptimisticField, type OptimisticField } from './optimistic-write-sequence'
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
import type { GitHubPrRepoSlug } from './github-pr-rpc'
import type { PrActionMutations } from './pr-action-mutation-contract'
export type { PrActionMutations } from './pr-action-mutation-contract'
// Pure (React-free) engine for the PR mutation actions: owns optimistic fields,
// busy/error/blocked state, and the success/transient/permanent routing. The hook
// is a thin adapter that subscribes to `onChange` and exposes these methods. Kept
// React-free so the U6 action logic is unit-testable with injected fakes.
export type PrActionMutations = {
mergePR: (args: {
prNumber: number
method?: GitHubPRMergeMethod
prRepo?: GitHubPrRepoSlug | null
}) => Promise<GitHubPrMutationOutcome>
setPRAutoMerge: (args: {
prNumber: number
enabled: boolean
method?: GitHubPRMergeMethod
prRepo?: GitHubPrRepoSlug | null
}) => Promise<GitHubPrMutationOutcome>
updatePRState: (args: {
prNumber: number
state: 'open' | 'closed'
}) => Promise<GitHubPrMutationOutcome>
requestReviewers: (args: {
prNumber: number
reviewers: string[]
}) => Promise<GitHubPrMutationOutcome>
removeReviewers: (args: {
prNumber: number
reviewers: string[]
}) => Promise<GitHubPrMutationOutcome>
rerunChecks: (args: {
prNumber: number
headSha?: string | null
failedOnly?: boolean
}) => Promise<GitHubPrMutationOutcome>
}
export type PrActionBusyKey =
| { kind: 'merge' }
| { kind: 'autoMerge' }
@@ -68,7 +41,7 @@ export type PrActionsEngineConfig = {
}
function prActionsIdentity(cfg: PrActionsEngineConfig): string {
const repo = cfg.prRepo ? `${cfg.prRepo.owner}/${cfg.prRepo.repo}` : ''
const repo = cfg.prRepo ? githubRepoIdentityKey(cfg.prRepo) : ''
return `${cfg.prNumber}:${repo}`
}
@@ -260,7 +233,8 @@ export class PrActionsEngine {
try {
const outcome = await cfg.mutations.updatePRState({
prNumber: cfg.prNumber,
state
state,
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, {
onSuccess: () => this.stateField.settleSuccess(seq),
@@ -281,7 +255,8 @@ export class PrActionsEngine {
try {
const outcome = await cfg.mutations.requestReviewers({
prNumber: cfg.prNumber,
reviewers: [login]
reviewers: [login],
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, {
onSuccess: () => field.settleSuccess(seq),
@@ -302,7 +277,8 @@ export class PrActionsEngine {
try {
const outcome = await cfg.mutations.removeReviewers({
prNumber: cfg.prNumber,
reviewers: [login]
reviewers: [login],
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, {
onSuccess: () => field.settleSuccess(seq),
@@ -322,7 +298,8 @@ export class PrActionsEngine {
const outcome = await cfg.mutations.rerunChecks({
prNumber: cfg.prNumber,
headSha: cfg.headSha,
failedOnly: true
failedOnly: true,
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} })
} finally {
@@ -24,7 +24,10 @@ describe('createTerminalAndSendPrompt', () => {
await createTerminalAndSendPrompt(client, 'wt-1', 'do the thing')
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.createTerminal', {
worktree: 'id:wt-1'
worktree: 'id:wt-1',
activate: false,
select: true,
navigation: 'caller'
})
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'terminal.send', {
terminal: 'term-1',
+4 -1
View File
@@ -16,7 +16,10 @@ export async function createTerminalAndSendPrompt(
prompt: string
): Promise<void> {
const created = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`
worktree: `id:${worktreeId}`,
activate: false,
select: true,
navigation: 'caller'
})
if (!created.ok) {
throw new Error(created.error?.message || 'Failed to create terminal')
@@ -134,13 +134,19 @@ describe('canEditComment / canDeleteComment', () => {
describe('buildEditCommentParams / buildDeleteCommentParams', () => {
it('build slug-addressed params', () => {
const slug = { owner: 'o', repo: 'r' }
const slug = { owner: 'o', repo: 'r', host: 'github.acme.test' }
expect(buildEditCommentParams(slug, 42, 'new body')).toEqual({
owner: 'o',
repo: 'r',
host: 'github.acme.test',
commentId: 42,
body: 'new body'
})
expect(buildDeleteCommentParams(slug, 42)).toEqual({ owner: 'o', repo: 'r', commentId: 42 })
expect(buildDeleteCommentParams(slug, 42)).toEqual({
owner: 'o',
repo: 'r',
host: 'github.acme.test',
commentId: 42
})
})
})
+20 -9
View File
@@ -1,4 +1,4 @@
import type { PRComment, PRState } from '../../../src/shared/types'
import type { GitHubOwnerRepo, PRComment, PRState } from '../../../src/shared/types'
// Pure helpers for the interactive PR comment timeline (reply / resolve / add
// root comment). Kept free of React/native imports so they unit-test under the
@@ -87,35 +87,46 @@ export function isMutablePRConversationComment(
// can hide the affordance rather than firing a doomed request.
export function canEditComment(
comment: Pick<PRComment, 'id' | 'threadId' | 'path' | 'url'>,
prRepo: { owner: string; repo: string } | null | undefined
prRepo: GitHubOwnerRepo | null | undefined
): boolean {
return Boolean(prRepo) && isMutablePRConversationComment(comment)
}
export function canDeleteComment(
comment: Pick<PRComment, 'id' | 'threadId' | 'path' | 'url'>,
prRepo: { owner: string; repo: string } | null | undefined
prRepo: GitHubOwnerRepo | null | undefined
): boolean {
return Boolean(prRepo) && isMutablePRConversationComment(comment)
}
export type EditCommentParams = { owner: string; repo: string; commentId: number; body: string }
export type EditCommentParams = GitHubOwnerRepo & { commentId: number; body: string }
export function buildEditCommentParams(
prRepo: { owner: string; repo: string },
prRepo: GitHubOwnerRepo,
commentId: number,
body: string
): EditCommentParams {
return { owner: prRepo.owner, repo: prRepo.repo, commentId, body }
return {
owner: prRepo.owner,
repo: prRepo.repo,
...(prRepo.host ? { host: prRepo.host } : {}),
commentId,
body
}
}
export type DeleteCommentParams = { owner: string; repo: string; commentId: number }
export type DeleteCommentParams = GitHubOwnerRepo & { commentId: number }
export function buildDeleteCommentParams(
prRepo: { owner: string; repo: string },
prRepo: GitHubOwnerRepo,
commentId: number
): DeleteCommentParams {
return { owner: prRepo.owner, repo: prRepo.repo, commentId }
return {
owner: prRepo.owner,
repo: prRepo.repo,
...(prRepo.host ? { host: prRepo.host } : {}),
commentId
}
}
// The composer disables submit on empty/whitespace input (host rejects empty body).
@@ -166,4 +166,86 @@ describe('useLiveWorktreeName request volume', () => {
expect(unsubscribeStream).toHaveBeenCalledTimes(1)
})
it('never calls worktree.show for the floating workspace sentinel', async () => {
let name = ''
function FloatingHarness(): null {
name = useLiveWorktreeName({
client,
connState: 'connected',
routeName: undefined,
worktreeId: 'global-floating-terminal'
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(FloatingHarness))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000)
})
expect(sendRequest).not.toHaveBeenCalled()
expect(subscribe).not.toHaveBeenCalled()
expect(name).toBe('Floating Workspace')
})
it('does not render stale names while switching through the floating route', async () => {
const firstNameByWorktree = new Map<string, string>()
let renderer: ReactTestRenderer | null = null
function RouteHarness(props: { routeName?: string; worktreeId: string }): null {
const name = useLiveWorktreeName({
client,
connState: 'connected',
routeName: props.routeName,
worktreeId: props.worktreeId
})
if (!firstNameByWorktree.has(props.worktreeId)) {
firstNameByWorktree.set(props.worktreeId, name)
}
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(
createElement(RouteHarness, {
routeName: 'Previous workspace',
worktreeId: 'repo-1::/worktree'
})
)
await Promise.resolve()
})
await act(async () => {
renderer?.update(
createElement(RouteHarness, {
worktreeId: 'global-floating-terminal'
})
)
})
await act(async () => {
renderer?.update(
createElement(RouteHarness, {
routeName: 'Next workspace',
worktreeId: 'repo-2::/worktree'
})
)
})
} finally {
restoreConsoleError()
act(() => renderer?.unmount())
}
expect(firstNameByWorktree.get('global-floating-terminal')).toBe('Floating Workspace')
expect(firstNameByWorktree.get('repo-2::/worktree')).toBe('Next workspace')
})
})
+26 -7
View File
@@ -5,6 +5,7 @@ import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name'
import { FLOATING_WORKSPACE_TITLE, isFloatingWorkspaceWorktreeId } from './floating-workspace'
const WORKTREE_NAME_FALLBACK_POLL_MS = 3000
@@ -16,15 +17,26 @@ type Params = {
}
export function useLiveWorktreeName({ client, connState, routeName, worktreeId }: Params): string {
const [worktreeName, setWorktreeName] = useState(() => routeName?.trim() ?? '')
// Why: the floating sentinel has no worktree record, so worktree.show would
// fail forever and keep the 3s fallback poll alive; its title is fixed.
const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId)
const routeNameHint = routeName?.trim() ?? ''
const [worktreeName, setWorktreeName] = useState(() => ({
worktreeId,
name: routeNameHint
}))
useEffect(() => {
setWorktreeName(routeName?.trim() ?? '')
}, [routeName, worktreeId])
setWorktreeName((current) =>
current.worktreeId === worktreeId && current.name === routeNameHint
? current
: { worktreeId, name: routeNameHint }
)
}, [routeNameHint, worktreeId])
useFocusEffect(
useCallback(() => {
if (!client || connState !== 'connected') {
if (isFloatingWorkspace || !client || connState !== 'connected') {
return
}
let stale = false
@@ -58,7 +70,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
? getLiveWorktreeDisplayName([result.worktree], worktreeId)
: null
if (liveName) {
setWorktreeName((current) => (current === liveName ? current : liveName))
setWorktreeName((current) =>
current.worktreeId === worktreeId && current.name === liveName
? current
: { worktreeId, name: liveName }
)
}
hasSuccessfulRefresh = true
if (eventStreamReady) {
@@ -128,8 +144,11 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
stopFallbackPoll()
unsubscribe()
}
}, [client, connState, worktreeId])
}, [client, connState, worktreeId, isFloatingWorkspace])
)
return worktreeName
if (isFloatingWorkspace) {
return FLOATING_WORKSPACE_TITLE
}
return worktreeName.worktreeId === worktreeId ? worktreeName.name : routeNameHint
}
@@ -99,7 +99,10 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) {
throw new Error('Waiting for desktop...')
}
const response = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`
worktree: `id:${worktreeId}`,
activate: false,
select: true,
navigation: 'caller'
})
if (!response.ok) {
throw new Error(response.error?.message || 'Failed to create terminal')
@@ -2,6 +2,7 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { FLOATING_WORKSPACE_WORKTREE_ID } from './floating-workspace'
import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability'
describe('useMobileNativeChatReadability', () => {
@@ -18,15 +19,19 @@ describe('useMobileNativeChatReadability', () => {
renderer = null
})
async function mount(connectionId: string | null): Promise<void> {
async function mount(
connectionId: string | null,
worktreeId = 'repo::/worktree'
): Promise<ReturnType<typeof vi.fn>> {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { repos: [{ id: 'repo', connectionId }] }
})
const client = {
sendRequest: vi.fn().mockResolvedValue({
ok: true,
result: { repos: [{ id: 'repo', connectionId }] }
})
sendRequest
} as unknown as RpcClient
function Harness(): null {
readable = useMobileNativeChatReadability(client, 'repo::/worktree')
readable = useMobileNativeChatReadability(client, worktreeId)
return null
}
const original = console.error
@@ -44,6 +49,7 @@ describe('useMobileNativeChatReadability', () => {
} finally {
consoleSpy.mockRestore()
}
return sendRequest
}
it('admits local and runtime-owned transcript hosts', async () => {
@@ -61,6 +67,13 @@ describe('useMobileNativeChatReadability', () => {
expect(readable).toBe(false)
})
it('treats the host-local floating workspace as readable without listing repos', async () => {
const sendRequest = await mount(null, FLOATING_WORKSPACE_WORKTREE_ID)
expect(readable).toBe(true)
expect(sendRequest).not.toHaveBeenCalled()
})
it('fails closed immediately while a reused route resolves its new worktree', async () => {
let resolveNext: (response: unknown) => void = () => {}
const client = {
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility'
import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers'
@@ -10,12 +11,17 @@ export function useMobileNativeChatReadability(
client: RpcClient | null,
worktreeId: string
): boolean {
const isFloatingWorkspace = isFloatingWorkspaceWorktreeId(worktreeId)
const [state, setState] = useState<ReadabilityState>({
client: null,
worktreeId: '',
readable: false
})
useEffect(() => {
// Why: the floating workspace always runs on the paired host and has no repo connection to resolve.
if (isFloatingWorkspace) {
return
}
let active = true
if (!client) {
setState({ client, worktreeId, readable: false })
@@ -46,7 +52,10 @@ export function useMobileNativeChatReadability(
return () => {
active = false
}
}, [client, worktreeId])
}, [client, isFloatingWorkspace, worktreeId])
if (isFloatingWorkspace) {
return true
}
// Why: route reuse renders before its new effect resolves; never expose the
// previous repo's readability under a different client/worktree key.
return state.client === client && state.worktreeId === worktreeId ? state.readable : false
@@ -10,6 +10,7 @@ import {
} from './github-pr-mutations'
const WORKTREE_ID = 'repo-42::/path/to/wt'
const ENTERPRISE_PR_REPO = { owner: 'fork', repo: 'proj', host: 'github.acme.test' }
function okStatus(): RpcResponse {
return { id: 'x', ok: true, result: { ok: true }, _meta: { runtimeId: 'r' } }
@@ -46,13 +47,17 @@ function fakeMutations(overrides: Partial<PrActionMutations> = {}): PrActionMuta
}
}
function makeEngine(mutations: PrActionMutations, refetch = vi.fn(async () => {})) {
function makeEngine(
mutations: PrActionMutations,
refetch = vi.fn(async () => {}),
prRepo: typeof ENTERPRISE_PR_REPO | null = null
) {
const onChange = vi.fn()
const engine = new PrActionsEngine({
mutations,
prNumber: 7,
headSha: 'abc123',
prRepo: null,
prRepo,
refetch,
onChange
})
@@ -67,7 +72,7 @@ describe('mutation wrappers — prRepo + param shapes', () => {
await fetchMergePR(client, WORKTREE_ID, {
prNumber: 7,
method: 'squash',
prRepo: { owner: 'fork', repo: 'proj' }
prRepo: ENTERPRISE_PR_REPO
})
expect(sendRequest).toHaveBeenCalledWith(
'github.mergePR',
@@ -75,17 +80,25 @@ describe('mutation wrappers — prRepo + param shapes', () => {
repo: 'id:repo-42',
prNumber: 7,
method: 'squash',
prRepo: { owner: 'fork', repo: 'proj' }
prRepo: ENTERPRISE_PR_REPO
})
)
})
it('updatePRState close carries NO prRepo and the state update', async () => {
it('updatePRState carries the host-qualified PR repo and state update', async () => {
const { client, sendRequest } = mockClient(okStatus())
await fetchUpdatePRState(client, WORKTREE_ID, { prNumber: 7, state: 'closed' })
await fetchUpdatePRState(client, WORKTREE_ID, {
prNumber: 7,
state: 'closed',
prRepo: ENTERPRISE_PR_REPO
})
const [, params] = sendRequest.mock.calls[0]
expect(params).toEqual({ repo: 'id:repo-42', prNumber: 7, updates: { state: 'closed' } })
expect(params).not.toHaveProperty('prRepo')
expect(params).toEqual({
repo: 'id:repo-42',
prNumber: 7,
updates: { state: 'closed' },
prRepo: ENTERPRISE_PR_REPO
})
})
it('updatePRState reopen passes state:open', async () => {
@@ -95,27 +108,43 @@ describe('mutation wrappers — prRepo + param shapes', () => {
expect((params.updates as { state: string }).state).toBe('open')
})
it('request/remove reviewers carry NO prRepo even when a fork is in play', async () => {
it('request/remove reviewers carry the host-qualified PR repo', async () => {
const { client, sendRequest } = mockClient(okStatus())
await fetchRequestPRReviewers(client, WORKTREE_ID, { prNumber: 7, reviewers: ['alice'] })
await fetchRemovePRReviewers(client, WORKTREE_ID, { prNumber: 7, reviewers: ['bob'] })
for (const [, params] of sendRequest.mock.calls) {
expect(params).not.toHaveProperty('prRepo')
}
expect(sendRequest.mock.calls[0][1]).toMatchObject({ reviewers: ['alice'] })
expect(sendRequest.mock.calls[1][1]).toMatchObject({ reviewers: ['bob'] })
await fetchRequestPRReviewers(client, WORKTREE_ID, {
prNumber: 7,
reviewers: ['alice'],
prRepo: ENTERPRISE_PR_REPO
})
await fetchRemovePRReviewers(client, WORKTREE_ID, {
prNumber: 7,
reviewers: ['bob'],
prRepo: ENTERPRISE_PR_REPO
})
expect(sendRequest.mock.calls[0][1]).toMatchObject({
reviewers: ['alice'],
prRepo: ENTERPRISE_PR_REPO
})
expect(sendRequest.mock.calls[1][1]).toMatchObject({
reviewers: ['bob'],
prRepo: ENTERPRISE_PR_REPO
})
})
it('rerunPRChecks carries failedOnly + headSha and NO prRepo', async () => {
it('rerunPRChecks carries failedOnly, headSha, and the host-qualified PR repo', async () => {
const { client, sendRequest } = mockClient(okStatus())
await fetchRerunPRChecks(client, WORKTREE_ID, {
prNumber: 7,
headSha: 'abc',
failedOnly: true
failedOnly: true,
prRepo: ENTERPRISE_PR_REPO
})
const [, params] = sendRequest.mock.calls[0]
expect(params).toMatchObject({ prNumber: 7, failedOnly: true, headSha: 'abc' })
expect(params).not.toHaveProperty('prRepo')
expect(params).toMatchObject({
prNumber: 7,
failedOnly: true,
headSha: 'abc',
prRepo: ENTERPRISE_PR_REPO
})
})
it('a host { ok:false, error } result surfaces as a failure outcome', async () => {
@@ -155,14 +184,14 @@ describe('PrActionsEngine — auto-merge optimistic revert', () => {
const mutations = fakeMutations({
setPRAutoMerge: vi.fn(async () => ({ ok: false as const, error: 'connection lost' }))
})
const { engine } = makeEngine(mutations)
const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO)
// authoritative = false; user enables.
await engine.setAutoMerge(true, 'squash')
expect(mutations.setPRAutoMerge).toHaveBeenCalledWith({
prNumber: 7,
enabled: true,
method: 'squash',
prRepo: null
prRepo: ENTERPRISE_PR_REPO
})
// Reverted to authoritative after transient failure.
expect(engine.resolveAutoMerge(false)).toBe(false)
@@ -182,20 +211,32 @@ describe('PrActionsEngine — auto-merge optimistic revert', () => {
describe('PrActionsEngine — updateState close/reopen', () => {
it('close fires state:closed and reopen fires state:open', async () => {
const mutations = fakeMutations()
const { engine } = makeEngine(mutations)
const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO)
await engine.updateState('closed')
await engine.updateState('open')
expect(mutations.updatePRState).toHaveBeenNthCalledWith(1, { prNumber: 7, state: 'closed' })
expect(mutations.updatePRState).toHaveBeenNthCalledWith(2, { prNumber: 7, state: 'open' })
expect(mutations.updatePRState).toHaveBeenNthCalledWith(1, {
prNumber: 7,
state: 'closed',
prRepo: ENTERPRISE_PR_REPO
})
expect(mutations.updatePRState).toHaveBeenNthCalledWith(2, {
prNumber: 7,
state: 'open',
prRepo: ENTERPRISE_PR_REPO
})
})
})
describe('PrActionsEngine — reviewers', () => {
it('requestReviewer adds via requestReviewers with a single login (no prRepo at engine level)', async () => {
it('requestReviewer adds via requestReviewers with a host-qualified PR repo', async () => {
const mutations = fakeMutations()
const { engine } = makeEngine(mutations)
const { engine } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO)
await engine.requestReviewer('alice')
expect(mutations.requestReviewers).toHaveBeenCalledWith({ prNumber: 7, reviewers: ['alice'] })
expect(mutations.requestReviewers).toHaveBeenCalledWith({
prNumber: 7,
reviewers: ['alice'],
prRepo: ENTERPRISE_PR_REPO
})
})
it('removeReviewer optimistic revert on transient failure', async () => {
@@ -213,12 +254,13 @@ describe('PrActionsEngine — reviewers', () => {
describe('PrActionsEngine — rerun checks', () => {
it('fires failedOnly:true with headSha and refetches on success', async () => {
const mutations = fakeMutations()
const { engine, refetch } = makeEngine(mutations)
const { engine, refetch } = makeEngine(mutations, undefined, ENTERPRISE_PR_REPO)
await engine.rerunFailingChecks()
expect(mutations.rerunChecks).toHaveBeenCalledWith({
prNumber: 7,
headSha: 'abc123',
failedOnly: true
failedOnly: true,
prRepo: ENTERPRISE_PR_REPO
})
expect(refetch).toHaveBeenCalledOnce()
})
@@ -30,7 +30,11 @@ export type PrCommentMutations = {
line?: number
prRepo?: GitHubPrRepoSlug | null
}) => Promise<GitHubPrMutationOutcome>
resolveThread: (args: { threadId: string; resolve: boolean }) => Promise<GitHubPrMutationOutcome>
resolveThread: (args: {
threadId: string
resolve: boolean
prRepo?: GitHubPrRepoSlug | null
}) => Promise<GitHubPrMutationOutcome>
addRootComment: (args: {
prNumber: number
body: string
@@ -39,12 +43,14 @@ export type PrCommentMutations = {
editComment: (args: {
owner: string
repo: string
host?: string
commentId: number
body: string
}) => Promise<GitHubPrMutationOutcome>
deleteComment: (args: {
owner: string
repo: string
host?: string
commentId: number
}) => Promise<GitHubPrMutationOutcome>
}
@@ -169,9 +175,9 @@ export function useMobilePrCommentActions(input: PrCommentActionsInput) {
if (!mutations || !params) {
return Promise.resolve(false)
}
return run(resolveKey(params.threadId), () => mutations.resolveThread(params))
return run(resolveKey(params.threadId), () => mutations.resolveThread({ ...params, prRepo }))
},
[mutations, run]
[mutations, prRepo, run]
)
const addRootComment = useCallback(
@@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('../../app/h/[hostId]/tasks.tsx', import.meta.url), 'utf8')
describe('mobile GitHub Project host routing boundary', () => {
it('host-qualifies every Project RPC request', () => {
const calls = [...source.matchAll(/['"](github\.project\.[^'"]+)['"]/g)]
expect(calls.length).toBeGreaterThan(10)
for (const call of calls) {
const request = source.slice(call.index, call.index + 700)
expect(request, `${call[1]} must carry a host`).toMatch(/\bhost\s*:/)
}
})
it('pins Project-row PR actions to the row repository identity', () => {
const actions = source.slice(source.indexOf('const toggleProjectGitHubReviewThread'))
for (const method of [
'github.resolveReviewThread',
'github.addPRReviewCommentReply',
'github.addIssueComment',
'github.requestPRReviewers',
'github.prChecks',
'github.rerunPRChecks',
'github.setPRFileViewed',
'github.prFileContents',
'github.addPRReviewComment',
'github.mergePR'
]) {
const offset = actions.indexOf(`'${method}'`)
expect(offset, `${method} must remain wired in the Project action path`).toBeGreaterThan(-1)
expect(actions.slice(offset, offset + 700), `${method} must carry prRepo`).toContain(
'prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost)'
)
}
})
it('pins discovery to github.com while pasted URLs supply their parsed host', () => {
expect(source).toContain("'github.project.listAccessible', {\n host: 'github.com'")
expect(source).toContain('host: githubProjectHost(parsed.host)')
})
})
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { parseGitHubProjectInput } from './github-project-reference'
describe('parseGitHubProjectInput', () => {
it('keeps legacy owner/number input hostless', () => {
expect(parseGitHubProjectInput('Acme/42')).toEqual({ owner: 'Acme', number: 42 })
})
it('preserves GitHub Enterprise hosts and view numbers', () => {
expect(
parseGitHubProjectInput('https://GitHub.Acme-Corp.com:8443/orgs/Acme/projects/42/views/7')
).toEqual({
owner: 'Acme',
number: 42,
host: 'github.acme-corp.com:8443',
viewNumber: 7
})
})
it('accepts github.com user Project URLs', () => {
expect(parseGitHubProjectInput('http://github.com/users/octocat/projects/3')).toEqual({
owner: 'octocat',
number: 3,
host: 'github.com'
})
})
it('rejects credentials, invalid routes, and unsafe integers', () => {
for (const input of [
'https://user:token@github.acme.test/orgs/acme/projects/1',
'https://github.acme.test/acme/projects/1',
'https://github.acme.test/orgs/acme/projects/1/files',
'https://github.acme.test/orgs/acme/projects/9007199254740992',
'https://github.acme.test/orgs/acme/projects/1/views/0'
]) {
expect(parseGitHubProjectInput(input)).toBeNull()
}
})
})
@@ -0,0 +1,84 @@
import type { GitHubProjectIdentity } from '../../../src/shared/github-project-identity'
export type GitHubProjectOwnerType = GitHubProjectIdentity['ownerType']
export type GitHubProjectRef = GitHubProjectIdentity
export type GitHubProjectSettings = {
pinned: GitHubProjectRef[]
recent: Array<GitHubProjectRef & { lastOpenedAt: string }>
lastViewByProject: Record<string, { viewId: string }>
activeProject: GitHubProjectRef | null
}
export type GitHubProjectSummary = GitHubProjectRef & {
id: string
title: string
url: string
source: string
}
export type GitHubProjectPartialFailure = { owner: string; message: string }
export type GitHubProjectViewSummary = {
id: string
number: number
name: string
layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT'
}
export type ParsedGitHubProjectInput = {
owner: string
number: number
host?: string
viewNumber?: number
}
const OWNER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/
function positiveInteger(value: string | undefined): number | null {
if (!value || !/^\d+$/.test(value)) {
return null
}
const parsed = Number(value)
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
}
export function parseGitHubProjectInput(input: string): ParsedGitHubProjectInput | null {
const trimmed = input.trim()
const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(trimmed)
if (short) {
const number = positiveInteger(short[2])
return number ? { owner: short[1]!, number } : null
}
try {
const url = new URL(trimmed)
if (
(url.protocol !== 'https:' && url.protocol !== 'http:') ||
url.username ||
url.password ||
!url.host
) {
return null
}
const parts = url.pathname.split('/').filter(Boolean)
const hasView = parts.length === 6 && parts[4] === 'views'
if (
(parts[0] !== 'orgs' && parts[0] !== 'users') ||
!OWNER_RE.test(parts[1] ?? '') ||
parts[2] !== 'projects' ||
(parts.length !== 4 && !hasView)
) {
return null
}
const number = positiveInteger(parts[3])
const viewNumber = hasView ? positiveInteger(parts[5]) : null
if (!number || (hasView && !viewNumber)) {
return null
}
return {
owner: parts[1]!,
number,
host: url.host.toLowerCase(),
...(viewNumber ? { viewNumber } : {})
}
} catch {
return null
}
}
@@ -20,7 +20,10 @@ describe('GitHub project repo matching', () => {
it('matches project rows by resolved repo slug before path/display heuristics', () => {
expect(
findRepoForGitHubProjectRepository('stablyai/orca', repos, {
'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' }
'repo-1': {
path: '/Users/me/orca',
repository: { owner: 'stablyai', repo: 'orca' }
}
})
).toBe(repos[0])
})
@@ -28,8 +31,14 @@ describe('GitHub project repo matching', () => {
it('does not pick a repo when resolved slugs are ambiguous', () => {
expect(
findRepoForGitHubProjectRepository('stablyai/orca', repos, {
'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' },
'repo-2': { path: '/Users/me/other', slug: 'stablyai/orca' }
'repo-1': {
path: '/Users/me/orca',
repository: { owner: 'stablyai', repo: 'orca' }
},
'repo-2': {
path: '/Users/me/other',
repository: { owner: 'stablyai', repo: 'orca' }
}
})
).toBeNull()
})
@@ -56,7 +65,10 @@ describe('GitHub project repo matching', () => {
'stablyai/orca',
[{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }],
{
'repo-1': { path: '/Users/me/stablyai/orca', slug: 'fork/orca' }
'repo-1': {
path: '/Users/me/stablyai/orca',
repository: { owner: 'fork', repo: 'orca' }
}
}
)
).toBeNull()
@@ -71,8 +83,46 @@ describe('GitHub project repo matching', () => {
expect(
filterGitHubProjectRowsForRepos(rows, repos, {
'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' }
'repo-1': {
path: '/Users/me/orca',
repository: { owner: 'stablyai', repo: 'orca' }
}
}).map((row) => row.id)
).toEqual(['row-1'])
})
it('matches same-named repositories only on the active Project host', () => {
expect(
findRepoForGitHubProjectRepository(
'stablyai/orca',
repos,
{
'repo-1': {
path: '/Users/me/orca',
repository: { owner: 'stablyai', repo: 'orca', host: 'github.com' }
},
'repo-2': {
path: '/Users/me/other',
repository: {
owner: 'stablyai',
repo: 'orca',
host: 'github.acme-corp.com'
}
}
},
'github.acme-corp.com'
)
).toBe(repos[1])
})
it('does not use hostless path heuristics for Enterprise Project rows', () => {
expect(
findRepoForGitHubProjectRepository(
'stablyai/orca',
[{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }],
{},
'github.acme-corp.com'
)
).toBeNull()
})
})
+32 -8
View File
@@ -1,3 +1,8 @@
import {
githubRepoIdentityKey,
isDefaultGitHubHost
} from '../../../src/shared/github-repository-identity-key'
export type GitHubProjectRepoMatch = {
id: string
path: string
@@ -6,13 +11,13 @@ export type GitHubProjectRepoMatch = {
export type GitHubRepoSlugCacheEntry = {
path: string
slug: string | null
repository: { owner: string; repo: string; host?: string } | null
}
type CachedSlugState =
| { status: 'missing' }
| { status: 'stale' }
| { status: 'resolved'; slug: string | null }
| { status: 'resolved'; repository: GitHubRepoSlugCacheEntry['repository'] }
export function normalizeGitHubRepositorySlug(value: string | null | undefined): string | null {
const trimmed = value?.trim()
@@ -37,13 +42,14 @@ function cachedSlugStateForRepo(
if (cached.path !== repo.path) {
return { status: 'stale' }
}
return { status: 'resolved', slug: normalizeGitHubRepositorySlug(cached.slug) }
return { status: 'resolved', repository: cached.repository }
}
export function findRepoForGitHubProjectRepository(
repository: string | null | undefined,
repos: GitHubProjectRepoMatch[],
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {}
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {},
projectHost?: string
): GitHubProjectRepoMatch | null {
const slug = normalizeGitHubRepositorySlug(repository)
if (!slug) {
@@ -53,9 +59,18 @@ export function findRepoForGitHubProjectRepository(
const slugStates = new Map(
repos.map((repo) => [repo.id, cachedSlugStateForRepo(repo, slugsByRepoId)])
)
const requestedIdentityKey = githubRepoIdentityKey({
owner: slug.split('/')[0]!,
repo: slug.split('/')[1]!,
...(projectHost ? { host: projectHost } : {})
})
const slugMatches = repos.filter((repo) => {
const state = slugStates.get(repo.id)
return state?.status === 'resolved' && state.slug === slug
return (
state?.status === 'resolved' &&
state.repository !== null &&
githubRepoIdentityKey(state.repository) === requestedIdentityKey
)
})
if (slugMatches.length === 1) {
return slugMatches[0]!
@@ -64,10 +79,16 @@ export function findRepoForGitHubProjectRepository(
return null
}
if (!isDefaultGitHubHost(projectHost)) {
// Why: display names and local paths contain no host evidence, so using
// them for GHES rows could bind an Enterprise item to a github.com repo.
return null
}
return (
repos.find((repo) => {
const state = slugStates.get(repo.id)
if (state?.status === 'resolved' && state.slug !== null) {
if (state?.status === 'resolved' && state.repository !== null) {
return false
}
const display = repo.displayName.trim().toLowerCase()
@@ -82,9 +103,12 @@ export function filterGitHubProjectRowsForRepos<
>(
rows: readonly Row[],
repos: GitHubProjectRepoMatch[],
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {}
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {},
projectHost?: string
): Row[] {
return rows.filter((row) =>
Boolean(findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId))
Boolean(
findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId, projectHost)
)
)
}
@@ -1,8 +1,9 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
deriveRepoSlug,
findRepoMatchingSlug,
findRepoMatchingSlugForPaste,
lookupGitHubItemByOwnerRepo,
resolvePasteIntent,
type PasteRepoCandidate
} from './smart-source-paste-intent'
@@ -12,7 +13,7 @@ describe('resolvePasteIntent', () => {
it('classifies a GitHub issue/PR URL as a github-link', () => {
expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({
kind: 'github-link',
link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' }
link: { slug: { owner: 'acme', repo: 'widgets', host: 'github.com' }, number: 12, type: 'pr' }
})
})
@@ -47,6 +48,14 @@ describe('deriveRepoSlug', () => {
})
})
it('preserves an Enterprise upstream host', () => {
expect(
deriveRepoSlug({
upstream: { owner: 'up', repo: 'stream', host: 'github.corp.example' }
})
).toEqual({ owner: 'up', repo: 'stream', host: 'github.corp.example' })
})
it('parses an SSH remote URL', () => {
expect(
deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } })
@@ -78,6 +87,16 @@ describe('findRepoMatchingSlug', () => {
expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull()
})
it('does not match a GHES link to a same-named github.com repo', () => {
expect(
findRepoMatchingSlug(repos, {
owner: 'acme',
repo: 'widgets',
host: 'github.corp.example'
})
).toBeNull()
})
it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => {
const calls: string[] = []
const client = {
@@ -86,7 +105,10 @@ describe('findRepoMatchingSlug', () => {
calls.push(repo)
return {
ok: true,
result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null
result:
repo === 'id:b'
? { owner: 'enterprise', repo: 'widgets', host: 'github.corp.example' }
: null
}
}
} as unknown as RpcClient
@@ -94,7 +116,7 @@ describe('findRepoMatchingSlug', () => {
findRepoMatchingSlugForPaste(
client,
repos,
{ owner: 'enterprise', repo: 'widgets' },
{ owner: 'enterprise', repo: 'widgets', host: 'github.corp.example' },
new Map()
)
).resolves.toMatchObject({ id: 'b' })
@@ -139,4 +161,26 @@ describe('findRepoMatchingSlug', () => {
)
).resolves.toBeNull()
})
it('sends the pasted host through the exact owner/repo lookup RPC', async () => {
const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: null })
const client = { sendRequest } as unknown as RpcClient
await lookupGitHubItemByOwnerRepo(
client,
'repo-1',
{ owner: 'acme', repo: 'widgets', host: 'github.corp.example' },
7,
'pr'
)
expect(sendRequest).toHaveBeenCalledWith('github.workItemByOwnerRepo', {
repo: 'id:repo-1',
owner: 'acme',
ownerRepo: 'widgets',
host: 'github.corp.example',
number: 7,
type: 'pr'
})
})
})
@@ -9,6 +9,7 @@ import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitl
import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { githubRepoIdentityKey } from '../../../src/shared/github-repository-identity-key'
// A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived
// best-effort from the repo's remote metadata.
@@ -58,11 +59,15 @@ export function resolvePasteIntent(query: string): PasteIntent {
// Pure: derive an owner/repo slug from a repo's remote metadata so a pasted
// cross-repo URL can be matched to a locally known repo.
export function deriveRepoSlug(repo: {
upstream?: { owner: string; repo: string } | null
upstream?: { owner: string; repo: string; host?: string } | null
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
}): RepoSlug | null {
if (repo.upstream?.owner && repo.upstream.repo) {
return { owner: repo.upstream.owner, repo: repo.upstream.repo }
return {
owner: repo.upstream.owner,
repo: repo.upstream.repo,
...(repo.upstream.host ? { host: repo.upstream.host } : {})
}
}
const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? ''
const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source)
@@ -76,9 +81,7 @@ function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean {
if (!a || !b) {
return false
}
return (
a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase()
)
return githubRepoIdentityKey(a) === githubRepoIdentityKey(b)
}
export function findRepoMatchingSlug(
@@ -148,6 +151,7 @@ export async function lookupGitHubItemByOwnerRepo(
repo: `id:${repoId}`,
owner: slug.owner,
ownerRepo: slug.repo,
...(slug.host ? { host: slug.host } : {}),
number,
type
})
@@ -22,7 +22,11 @@ const DEBOUNCE_MS = 200
const RESULT_LIMIT = 36
export type SmartCrossRepoPrompt = {
link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' }
link: {
slug: { owner: string; repo: string; host?: string }
number: number
type: 'issue' | 'pr'
}
matchingRepo: PasteRepoCandidate
}
@@ -73,7 +77,9 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) {
// the mode/repo changes so one provider's rows never render under another tab.
const scopeRef = useRef('')
const dismissedPasteRef = useRef<string>('')
const repoSlugCacheRef = useRef<Map<string, { owner: string; repo: string } | null>>(new Map())
const repoSlugCacheRef = useRef<
Map<string, { owner: string; repo: string; host?: string } | null>
>(new Map())
useEffect(() => {
if (!client || !enabled || mode === 'text') {
@@ -185,7 +191,7 @@ async function runSmartSearch(args: {
linearWorkspaceId: string | null | undefined
repos: readonly PasteRepoCandidate[]
dismissedPasteRef: { current: string }
repoSlugCache: Map<string, { owner: string; repo: string } | null>
repoSlugCache: Map<string, { owner: string; repo: string; host?: string } | null>
}): Promise<{
fan: SmartFanOutResult
paste: PasteResolved
@@ -128,6 +128,90 @@ beforeEach(() => {
})
describe('useHostClient', () => {
it('rebinds when Expo reuses a screen between two connected cached hosts', async () => {
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
const client1 = makeFakeClient('connected')
const client2 = makeFakeClient('connected')
connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2)
loadHostsMock.mockResolvedValue([HOST, host2])
let selectedHostId = HOST.id
let selectedClient: RpcClient | null = null
let selectedState: ConnectionState = 'disconnected'
let renderer: ReactTestRenderer | null = null
function Probe(): null {
const selected = useHostClient(selectedHostId)
selectedClient = selected.client
selectedState = selected.state
useHostClient(host2.id)
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(selectedClient).toBe(client1)
expect(selectedState).toBe('connected')
selectedHostId = host2.id
client2.emitState('disconnected')
await act(async () => {
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(selectedClient).toBe(client2)
expect(selectedState).toBe('disconnected')
expect(connectMock).toHaveBeenCalledTimes(2)
} finally {
restore()
act(() => renderer?.unmount())
}
})
it('stays disconnected while a reused screen resolves an uncached host', async () => {
const client = makeFakeClient('connected')
connectMock.mockReturnValue(client)
loadHostsMock.mockResolvedValueOnce([HOST]).mockReturnValueOnce(new Promise<never>(() => {}))
let selectedHostId = HOST.id
let renderTick = 0
const stateByRenderTick = new Map<number, ConnectionState>()
let renderer: ReactTestRenderer | null = null
function Probe(): null {
stateByRenderTick.set(renderTick, useHostClient(selectedHostId).state)
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(stateByRenderTick.get(0)).toBe('connected')
selectedHostId = 'missing-host'
renderTick = 1
await act(async () => {
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
})
expect(stateByRenderTick.get(1)).toBe('disconnected')
renderTick = 2
await act(async () => {
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
})
expect(stateByRenderTick.get(2)).toBe('disconnected')
} finally {
restore()
act(() => renderer?.unmount())
}
})
it('drops the closed client when the host entry is removed', async () => {
const fake = makeFakeClient('connected')
connectMock.mockReturnValue(fake)
+13 -9
View File
@@ -344,13 +344,16 @@ export function useHostClient(hostId: string | undefined): {
hostId ? ctx.getState(hostId) : 'disconnected'
)
const clientRef = useRef<RpcClient | null>(null)
const clientHostIdRef = useRef<string | undefined>(hostId)
useEffect(() => {
if (!hostId) {
clientRef.current = null
clientHostIdRef.current = undefined
setState('disconnected')
return
}
clientHostIdRef.current = hostId
let cancelled = false
// Subscribe before acquire so any state change during open is captured.
const unsub = ctx.subscribeHostState(hostId, (next) => {
@@ -370,28 +373,29 @@ export function useHostClient(hostId: string | undefined): {
}
})
const initial = ctx.acquire(hostId)
clientRef.current = initial
setState(ctx.getState(hostId))
if (initial) {
clientRef.current = initial
setState(ctx.getState(hostId))
// Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client.
force((n) => n + 1)
}
return () => {
cancelled = true
unsub()
ctx.release(hostId)
clientRef.current = null
clientHostIdRef.current = undefined
}
}, [ctx, hostId])
return { client: clientRef.current, state }
// Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render.
const bound = clientHostIdRef.current === hostId
const boundState = bound ? state : hostId ? ctx.getState(hostId) : 'disconnected'
return { client: bound ? clientRef.current : null, state: boundState }
}
// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts.
export function useAllHostClients(hostIds: string[]): Array<{
hostId: string
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
}> {
export function useAllHostClients(hostIds: string[]) {
const ctx = useRpcClientContext()
// Stable key so we don't tear down on every render of the array.
const key = useMemo(() => [...hostIds].sort().join(','), [hostIds])
@@ -0,0 +1,179 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from './rpc-client'
import { useHostStatusGates, type HostStatusGates } from './host-status-gates'
function suppressReactTestRendererDeprecationWarning(): () => 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()
}
describe('useHostStatusGates', () => {
it('clears every prior-host gate and ignores its late response while the client is replaced', async () => {
let resolveOldStatus: ((response: unknown) => void) | null = null
const pendingOldStatus = new Promise((resolve) => {
resolveOldStatus = resolve
})
const oldSendRequest = vi.fn().mockReturnValue(pendingOldStatus)
const oldClient = { sendRequest: oldSendRequest } as unknown as RpcClient
const newSendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
capabilities: ['terminal.quick-commands.v1'],
floatingWorkspaceEnabled: true
}
})
const newClient = { sendRequest: newSendRequest } as unknown as RpcClient
let gates: HostStatusGates | null = null
const firstRenderByHost = new Map<string, HostStatusGates>()
let renderer: ReactTestRenderer | null = null
function Probe({ hostId, client }: { hostId: string; client: RpcClient }): null {
gates = useHostStatusGates({ hostId, client, connState: 'connected' })
if (!firstRenderByHost.has(hostId)) {
firstRenderByHost.set(hostId, gates)
}
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(Probe, { hostId: 'host-1', client: oldClient }))
})
await act(async () => {
renderer?.update(createElement(Probe, { hostId: 'host-2', client: newClient }))
await Promise.resolve()
})
expect(firstRenderByHost.get('host-2')).toMatchObject({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
compatVerdict: { kind: 'ok' }
})
expect(gates).toMatchObject({
hostCapabilities: ['terminal.quick-commands.v1'],
floatingWorkspaceEnabled: true
})
await act(async () => {
resolveOldStatus?.({
ok: true,
result: {
capabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
}
})
await pendingOldStatus
})
expect(gates).toMatchObject({
hostCapabilities: ['terminal.quick-commands.v1'],
floatingWorkspaceEnabled: true
})
expect(oldSendRequest).toHaveBeenCalledOnce()
expect(newSendRequest).toHaveBeenCalledOnce()
} finally {
restore()
renderer?.unmount()
}
})
it('loads gates from the connected host', async () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
capabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
}
})
const client = { sendRequest } as unknown as RpcClient
let gates: HostStatusGates | null = null
let renderer: ReactTestRenderer | null = null
function Probe({ hostId }: { hostId: string }): null {
gates = useHostStatusGates({ hostId, client, connState: 'connected' })
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(Probe, { hostId: 'host-1' }))
await Promise.resolve()
})
expect(gates).toMatchObject({
hostCapabilities: ['browser.screencast.v1'],
floatingWorkspaceEnabled: true
})
expect(sendRequest).toHaveBeenCalledOnce()
} finally {
restore()
renderer?.unmount()
}
})
it('fails closed while the same client reconnects', async () => {
let resolveReconnect: ((response: unknown) => void) | null = null
const pendingReconnect = new Promise((resolve) => {
resolveReconnect = resolve
})
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: { capabilities: ['browser.screencast.v1'], floatingWorkspaceEnabled: true }
})
.mockReturnValueOnce(pendingReconnect)
const client = { sendRequest } as unknown as RpcClient
let gates: HostStatusGates | null = null
let renderer: ReactTestRenderer | null = null
function Probe({ connState }: { connState: 'connected' | 'disconnected' }): null {
gates = useHostStatusGates({ hostId: 'host-1', client, connState })
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(Probe, { connState: 'connected' }))
await Promise.resolve()
})
expect(gates?.floatingWorkspaceEnabled).toBe(true)
await act(async () => {
renderer?.update(createElement(Probe, { connState: 'disconnected' }))
})
await act(async () => {
renderer?.update(createElement(Probe, { connState: 'connected' }))
})
expect(gates).toMatchObject({
hostCapabilities: [],
floatingWorkspaceEnabled: false
})
await act(async () => {
resolveReconnect?.({
ok: true,
result: { capabilities: ['terminal.quick-commands.v1'], floatingWorkspaceEnabled: true }
})
await pendingReconnect
})
expect(gates).toMatchObject({
hostCapabilities: ['terminal.quick-commands.v1'],
floatingWorkspaceEnabled: true
})
} finally {
restore()
renderer?.unmount()
}
})
})
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, RpcSuccess } from './types'
import { evaluateCompat, type CompatVerdict } from './protocol-compat'
import type { DesktopStatus } from '../worktree/host-worktree-rpc-types'
export type HostStatusGates = {
hostCapabilities: string[]
floatingWorkspaceEnabled: boolean
compatVerdict: CompatVerdict
}
type LoadedHostStatusGates = HostStatusGates & {
hostId: string | undefined
client: RpcClient
}
const EMPTY_HOST_CAPABILITIES: string[] = []
// Reads status.get on connect for capabilities, protocol-compat verdict, and the
// floating-workspace flag. Compat constants are wide-open today so this never blocks yet.
export function useHostStatusGates(args: {
hostId: string | undefined
client: RpcClient | null
connState: ConnectionState
}): HostStatusGates {
const { hostId, client, connState } = args
const [loaded, setLoaded] = useState<LoadedHostStatusGates | null>(null)
useEffect(() => {
if (connState !== 'connected' || !client) {
// Why: reconnecting the same host/client must revalidate gates instead of reviving its prior status response.
setLoaded(null)
return
}
let cancelled = false
const requestClient = client
void (async () => {
try {
const response = await requestClient.sendRequest('status.get')
if (cancelled) {
return
}
if (!response.ok) {
return
}
const status = (response as RpcSuccess).result as DesktopStatus & {
capabilities?: string[]
}
const verdict = evaluateCompat({
desktopProtocolVersion: status.protocolVersion,
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
})
setLoaded({
hostId,
client: requestClient,
hostCapabilities: status.capabilities ?? [],
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
compatVerdict: verdict
})
if (verdict.kind === 'blocked') {
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
console.warn('[protocol-compat] blocked', {
reason: verdict.reason,
desktopVersion: verdict.desktopVersion,
requiredMobileVersion: verdict.requiredMobileVersion,
requiredDesktopVersion: verdict.requiredDesktopVersion
})
}
} catch {
// Why: sendRequest can throw on transport tear-down; the fail-closed return below keeps gated actions hidden.
}
})()
return () => {
cancelled = true
}
}, [client, connState, hostId])
// Why: effects run after render, so key loaded gates by host and client to fail closed during route reuse.
if (
connState !== 'connected' ||
!client ||
!loaded ||
loaded.hostId !== hostId ||
loaded.client !== client
) {
return {
hostCapabilities: EMPTY_HOST_CAPABILITIES,
floatingWorkspaceEnabled: false,
compatVerdict: { kind: 'ok' }
}
}
return {
hostCapabilities: loaded.hostCapabilities,
floatingWorkspaceEnabled: loaded.floatingWorkspaceEnabled,
compatVerdict: loaded.compatVerdict
}
}
@@ -5,6 +5,9 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host'
export type DesktopStatus = {
protocolVersion?: number
minCompatibleMobileVersion?: number
// Why: absent on hosts that predate the mobile Floating Workspace entry;
// treat absence as unsupported and hide the entry.
floatingWorkspaceEnabled?: boolean
}
export type RepoSummary = {
@@ -20,5 +20,6 @@ describe('mobile worktree activation', () => {
expect(openSession).toContain("sendRequest('worktree.activate'")
expect(openSession).toContain('notifyClients: false')
expect(openSession).toContain("navigation: 'caller'")
})
})
-709
View File
@@ -1,709 +0,0 @@
# Skill Auto-Update Design
Status: SUPERSEDED in direction by `skill-freshness-design.md` (2026-07-13): Phases 2-4
(background writes, WSL, SSH reconcilers) are retired; Phase-1 detection and all empirical
findings below remain valid inputs. Original status: revised after design, OSS review, and
macOS + Windows + Linux empirical validation (2026-07-12). Phase 1 + background updates were
implemented on PR #8496 (branch brennanb2025/skill-auto-update-research), now archived as
reference for the write machinery. Windows validation confirmed the skills CLI writes CRLF (drove the
text-normalized package-identity rule); Linux confirmed verbatim-LF installs and the
`XDG_STATE_HOME` lock-location rule.
## Problem
Orca ships agent skills in `skills/` (orca-cli, orchestration, computer-use, orca-linear,
linear-tickets, orca-per-workspace-env, orca-emulator, orca-emulator-android). Users install
them with the skills CLI:
```sh
npx skills add https://github.com/stablyai/orca --skill <names> --global
```
Nothing then keeps those installations aligned with the Orca release they describe. Settings
surface a manual `npx skills update <name> --global` command, but users receive no update
signal. A stale skill can therefore tell an agent to use commands that are wrong or unsafe for
the Orca binary it is driving.
## Goals
1. Keep an Orca skill current after Orca has safely adopted or installed that physical copy.
2. Never overwrite a modified, unknown, externally managed, project-scoped, or third-party
skill.
3. Never install a skill the user did not request.
4. Work on macOS, Linux, Windows, WSL, and SSH, with reconciliation performed on the host
where the agent reads the skill.
5. Coexist with the skills CLI, symlink and copy installs, dotfile managers, read-only or
generated configurations, and multiple Orca builds sharing a home directory.
6. Keep skill content compatible with the Orca app release that is allowed to manage it.
Non-goals: updating third-party skills; replacing the skills CLI as the normal install path;
managing repo-scoped `.agents/skills` or `.claude/skills`; mutating plugin caches; merging user
edits into a new skill release.
## Research conclusion
The common package-manager pattern is not to scan arbitrary same-named directories and infer
that they are writable. The updater manages packages inside an ownership boundary established
at install or adoption time, records package identity outside user content, stages a complete
replacement, and treats external links/custom installations as unmanaged.
The skills CLI lock is useful supporting evidence, but it is not an ownership ledger:
- The global lock is `$XDG_STATE_HOME/skills/.skill-lock.json` when `XDG_STATE_HOME` is set,
otherwise `~/.agents/.skill-lock.json`.
- A v3 entry records source, source URL/type, Git ref, path within the repo, an upstream folder
hash, and timestamps.
- It does not record a physical install path, symlink/copy topology, per-skill global agent
placements, the current on-disk hash, local modifications, or app compatibility.
- `skills update` compares the stored source hash with upstream and then re-runs installation.
It does not prove that installed files are unchanged first.
Orca therefore reads supported lock versions as a provenance hint but never treats a lock
entry by itself as permission to write. Orca never writes the foreign lock format.
## Decision: detect, adopt, then manage
Only a physical destination recorded in Orca's management ledger is eligible for background
writes. A destination enters that ledger in one of two ways:
1. Orca's install UI invokes the skills CLI and then verifies and records the resulting
physical installation.
2. A legacy installation is adopted after its complete on-disk package matches a known,
released Orca snapshot and its topology is eligible. Phase 1 makes this an explicit
“Manage and update” action. Background auto-update never silently claims a newly
discovered path.
This one-time adoption cost is intentional. Without it, no app can distinguish an official
copy from a same-named user copy or establish which manager is allowed to replace it.
Do not rely on users discovering adoption passively in settings. When the bounded inventory
finds exact official snapshots in eligible topologies, show one non-repeating banner/toast:
“N installed Orca skills can be kept up to date,” with a one-click review/adoption action.
Prioritize recording ownership immediately after every successful Orca-driven skills CLI
install so new installations never require a later adoption step.
“Non-repeating” is scoped per eligible destination snapshot, not globally or per session. Store
a dismissed-adoption tuple containing host identity, physical destination identity, skill name,
and matched snapshot digest. Do not prompt again for that unchanged tuple, but allow a future
prompt when a newly installed skill or genuinely different official snapshot creates a tuple
the user has never dismissed. Removing another candidate alone does not clear prior dismissals.
## Build and release artifacts
Package `skills/` into app resources on every supported platform and generate a current bundle
manifest. For each skill it contains:
- canonical name and repo-relative source path;
- release revision and Orca app version;
- deterministic whole-package digest (composed from the per-file identities below);
- every regular file's relative path, size, executable bit, a per-file text/binary
classification, an exact-byte SHA-256, and — for text files — a text-normalized SHA-256 with
line endings folded to LF;
- the upstream Git tree SHA when available;
- minimum/maximum compatible app version if a skill is not backward-compatible;
- schema version.
The build rejects absolute paths, traversal, case-colliding paths, special files, and symlinks
inside the shipped package. Executable modes and the exact bytes of binary files are part of
package identity. For text files, identity is the line-ending-normalized content, not the exact
bytes: supported installers apply platform- and Git-config-dependent EOL translation, so an
exact-byte hash is not stable across hosts. This is validated, not hypothetical — the skills CLI
writes CRLF on a default Windows Git install (see Empirical validation), so a macOS-built
exact-byte hash never matches a Windows install and would misclassify every Windows copy as
modified. Because the whole-package digest composes the per-file normalized-or-exact hashes, one
official snapshot has a single identity across macOS, Linux, and Windows and across
`core.autocrlf` settings.
Maintain a compact, checked-in registry of every generated Orca skill snapshot plus a separate
release mapping that identifies which revisions actually shipped. Historical file bytes are
unnecessary; historical paths and hashes are sufficient to prove that an existing package is
an exact official snapshot and to map a skills CLI folder hash to an Orca release. Only a
revision present in the release mapping is eligible as legacy-install provenance; an
unreleased candidate cannot be adopted merely because it appeared on main.
Release revision assignment is mechanical, not a hand-edited field. The manifest generator
compares each package digest with the latest generated registry entry: unchanged content keeps
its revision; changed content appends the next integer. The generator is the only writer of
registry entries, and existing entries are immutable. Pull-request CI verifies generated
output, but the same generation/monotonicity check must rerun against the merge-queue head and
on main pushes so two independently green PRs cannot record different content with the same
revision. Release creation adds the current revisions to the release mapping and fails unless
main's generated registry is current and the packaged manifest exactly matches it.
Do not rely on `metadata.version` inside `SKILL.md` as the authority. Installers can transform
frontmatter, users can edit it, and a value inside the package cannot prove the rest of the
package is intact.
The exact-snapshot model depends on supported skills CLI installations preserving the shipped
package. Add a release CI round trip on macOS, Linux, and Windows that installs representative
single- and multi-file Orca skills through a pinned supported CLI version in both symlink and
copy/fallback shapes, then compares paths, bytes, and applicable executable modes with the
generated bundle manifest. Also exercise the newest CLI as an early-warning job. The bundle
manifest remains generated from Orca's shipped source; if an installer intentionally transforms
content, model that installation shape explicitly or mark it ineligible rather than silently
changing the authoritative digest. A mismatch blocks background-update rollout for that shape.
macOS verbatim behavior and Windows CRLF translation are both confirmed (see Empirical
validation); the round trip must assert LF-normalized identity holds — not exact bytes — and
cover `core.autocrlf` on/off plus the copy-fallback and junction shapes as a regression guard.
## Eligible roots and topology
Use an explicit registry of global, user-owned skill roots. Do not derive writable roots from
all discovery sources: discovery also includes repo roots and the Codex plugin cache, neither
of which this feature may mutate.
Classify every discovered physical destination before offering adoption:
| Installation topology | Behavior |
| --- | --- |
| Canonical `~/.agents/skills/<name>` with a known official snapshot | Eligible for adoption |
| Provider symlink/junction resolving to that canonical copy | Dedupe; manage the canonical copy once |
| Independent copy in an approved global provider root with a known official snapshot | Eligible for separate adoption |
| Modified, incomplete, or unknown same-named copy | Never auto-write; show diff/replacement action |
| Symlink/junction into dotfiles, a checkout, Nix/Home Manager output, network storage, or another external tree | Unmanaged; never write through the link |
| Broken/dangling provider symlink (target missing) | Inaccessible; never adopt or write through; offer provenance-verified repair only with consent |
| Read-only/generated root | Detection only |
| Repo-scoped skill or plugin cache | Out of scope; never mutate |
Hardlinks, directory junctions, case-insensitive aliases, and symlinked parent directories must
be deduplicated by physical identity where the host API exposes it, with normalized real paths
as the fallback. Revalidate the entry type and resolved parent immediately before every
mutation so a link swap cannot redirect a verified write.
Management and agent visibility are separate. Updating a canonical `~/.agents/skills` copy
does not make it visible to an agent that reads only its provider-specific root. If no verified
provider link/copy exists, settings must say “managed but not visible to <agent>” rather than
reporting the provider as current. Creating or repairing a provider link is a separate,
provenance-verified, user-approved follow-up; auto-update never invents a missing placement.
## Orca management ledger
Store state in Orca-owned application state on the execution host, never inside a user skill
directory. Use one record per adopted physical destination:
- stable execution-host identity and user/home identity;
- logical root kind and unresolved destination path;
- last verified physical identity, entry type, and resolved path;
- skill name, source, source path, and source ref/hash evidence;
- installed release revision and whole-package digest;
- per-file paths, hashes, modes, and the digest Orca last wrote;
- last attempted bundle fingerprint, outcome, and error category;
- adoption source and timestamp.
Local, WSL distro, and SSH records are isolated. SSH identity must include the persisted target
identity plus the resolved remote user/home; an in-memory provider object or relay connection
ID is not durable identity. Host-side state lets a remote remember ownership across desktop
reinstalls and reconnects.
Corrupt, missing, migrated, or mismatched state fails closed. It can be reconstructed only by
the same exact-snapshot adoption rules; it never grants ownership from a name alone.
## Detection and adoption
For each Orca skill found in an approved global root:
1. `lstat` the logical path and classify its topology without following external targets for
mutation.
2. Dedupe aliases that resolve to the same eligible canonical destination.
3. Read the supported skills CLI lock as optional evidence. Bind a lock entry only to the
canonical installation shape it describes; never apply one name-level entry to every
same-named copy.
4. Hash the complete physical package — exact bytes for binary/executable files, LF-normalized
content for text files — and compare with the released-manifest registry, so installer EOL
translation never misclassifies an official Windows install as modified.
5. Classify it as current, update available, newer known release, modified, unknown,
externally managed, or inaccessible.
6. Offer “Manage and update” only for an exact known official snapshot in an eligible
topology. Show a diff and explicit destructive replacement action for modified/unknown
content; that action is not adoption and must preserve a backup until success.
Never send skill contents, diffs, paths, or user edits to telemetry or normal logs.
## Reconcile algorithm for adopted destinations
For each adopted physical destination whose bundle revision is newer and app-compatible:
1. Acquire an Orca transaction lock scoped to the execution host and destination. Dedupe
in-flight work within the process as well. Other managers do not honor this lock, so also
revalidate immediately before publish.
2. Re-read topology and hash the installed package. Continue only when it equals the exact
digest in the ledger. Any local edit, extra file, missing file, skills CLI update, or link
change turns the destination into a conflict and cancels the write.
3. Stage the complete bundled package in a unique directory under the reserved transaction
workspace on the same filesystem. Write exact bytes and modes, then validate the staged
package against the manifest.
4. Recheck the live destination digest and identity after staging. If either changed, delete
the staging directory and report a concurrent modification.
5. Publish using the strongest package-level replacement supported by that host. Consumers
require a fixed skill path, so a universally atomic directory swap is not possible,
especially on Windows. Where package-level replacement is unavailable, first preserve a
complete rollback copy, then use this required order:
1. publish every new/changed non-`SKILL.md` file with same-directory temp + rename;
2. publish `SKILL.md` with same-directory temp + rename as the semantic commit marker;
3. unlink only obsolete files recorded in the old ledger, rechecking that each still has
its old verified hash, then remove only empty recorded directories.
This order ensures a removed file cannot make the new digest permanently unreachable and
avoids deleting an asset while the new entry point is not yet present. It does not make a
multi-file update atomic: a reader may briefly observe the old entry point with new assets,
or the new entry point with harmless obsolete assets. Skills requiring cross-file atomicity
must use package-level replacement; if the host cannot provide it, skip and retry rather
than use the in-place fallback. Do not claim stronger consistency than the host provides.
6. Verify the live package digest. Only then update the destination ledger and remove the
backup. On failure, restore the old package when possible, retain the prior ledger digest,
and leave the destination retryable.
7. Notify discovery and show one aggregated toast for successfully updated skills. Running
agent sessions pick changes up at their next skill load/session start.
Never repair equal-revision drift automatically. Equal revision plus different content is a
conflict, not proof of a partial write. Never downgrade a known newer release.
Line endings are a rendering of text content, not part of it. The bundle ships LF, but supported
installers write platform-native endings (validated: CRLF on default Windows Git). Compare text
provenance and drift on LF-normalized content; stage and publish text files in the destination's
existing EOL convention, defaulting to what a fresh supported install would produce on that host
when adopting a copy that has none; write binary and executable files as exact bytes. This keeps
an updated file byte-shaped like a fresh CLI install, so an EOL difference alone never counts as
a conflict and Orca and the skills CLI do not reclassify each other's writes.
### Transaction workspace and crash recovery
Staging and rollback packages must be on the same filesystem/volume as the live destination,
but must not appear as candidate skills. Use an Orca-reserved transaction root adjacent to the
skill root when possible (for example, beside `skills/`, not as another child skill), verify
same-filesystem identity, and fall back to a reserved child only when the skill root is itself
a mount boundary. Both general skill discovery and updater inventory must hard-exclude the
reserved transaction root; a leading dot alone is not an exclusion rule.
Each transaction directory contains an Orca marker with schema version, transaction ID,
destination identity, creation time, and an atomically advanced transaction phase before it
receives skill files. On host startup and before reconciliation, sweep only marked orphan
transactions whose owning lock is absent/stale: restore a verified rollback package when the
marker/ledger phase says publication was incomplete, otherwise remove the verified
staging/backup directory. Never delete an unmarked directory based on its name, age, or
resemblance to a skill. Coordinate cleanup with the same destination lock so one Orca process
cannot sweep another process's live transaction.
### Removed files and retired skills
Package-level replacement naturally omits files removed by a newer managed package. The
in-place fallback explicitly removes old-ledger files after publishing the new `SKILL.md`, as
specified above. Never delete an unrecorded extra file from a live destination; its presence
causes the pre-publish digest check to fail before any write.
Retired skills are detection/prompt-only in the initial implementation. A future cleanup may
delete only individually recorded, unchanged files and then empty directories. It must never
recursively delete a skill directory or follow a link target.
## Fast path and triggers
State is per destination, not one success bit for an entire host. A failed, inaccessible, or
partially reconciled destination remains retryable even when other destinations succeeded.
At launch, after first paint:
1. Perform a bounded inventory of approved global roots to detect newly installed, removed,
or topology-changed Orca skills.
2. For adopted destinations, skip content hashing only when that destination already records
successful reconciliation with the current bundle and its cheap identity/stat signature is
unchanged.
3. Hash only new, changed, failed, or bundle-mismatched candidates.
Also run/invalidate on:
- successful Orca-driven skill installation;
- `notifyInstalledAgentSkillsChanged()` after an install/update terminal exits;
- WSL distro first activation;
- SSH connection after the host runtime is ready;
- restart into a newly installed Orca app version.
Do not reconcile on the updater's “download complete” event: the running process still owns
the old app resources until restart. Coalesce triggers and cap concurrency so launch, WSL, and
SSH activation cannot fan out unbounded filesystem or network work.
## Host execution
- **macOS/Linux:** use Node filesystem APIs and platform app-state paths; do not shell out.
- **Windows:** use `path` APIs, preserve exact bytes, support junction/copy topology, handle
case-insensitive identity and long/UNC paths, and retry bounded `EPERM`/`EBUSY` replacement
failures. A skipped destination remains retryable and never advances its ledger digest. A
long-running agent may keep an obsolete file open; if its hash-verified unlink still fails
after bounded retries, roll back the whole update and retry after the handle is released.
- **WSL:** run reconciliation inside the selected distro through a host-side runtime/RPC
operation. Do not mix Windows UNC mutation semantics with Linux locks, modes, and renames.
State is scoped to distro plus Linux user/home.
- **SSH:** run discovery, hashing, staging, locking, and publication on the remote host through
the runtime/filesystem abstraction. Transfer only the selected bundled package and manifest.
Do not assemble shell commands. Support Linux, macOS, and Windows SSH targets, and fail closed
when the remote runtime lacks a required safe filesystem primitive.
## Multiple writers and app compatibility
The same global roots may be touched by stable Orca, a development build, and the skills CLI.
Orca cannot guarantee that one shared global package simultaneously matches two incompatible
app binaries.
- Production stable Orca is the only automatic writer by default.
- Main-process runtime identity is authoritative: `app.isPackaged`, the signed release channel,
and the resolved user-data/home roots determine whether writes are allowed. Renderer build
flags alone are insufficient. An unpackaged build or development channel is detection-only
unless both skill home and user-data roots are explicitly isolated from production.
- A stable app writes only a bundle declared compatible with that app version.
- A newer known installed release is never downgraded.
- If `npx skills update` changes an adopted package, the next Orca check sees a ledger-digest
mismatch and stops managing it until the new content matches a known released snapshot and
is explicitly re-adopted.
- Provenance and drift comparison fold text line endings to LF, so a stable app and the skills
CLI never treat each other's platform-native EOL output as a conflict; only real content
changes do.
- Skills should remain backward-compatible across supported stable app versions where
practical; compatibility metadata is still required for exceptions.
This avoids version ping-pong. A monotonic number inside user content alone cannot solve
multiple incompatible writers.
## Consent and settings UX
The background setting is “Keep managed Orca agent skills up to date.” It controls only
already adopted/Orca-installed destinations and may default on. It does not authorize claiming
new paths.
Settings show each physical installation as one of:
- managed and current;
- managed, update available;
- known official copy, available to manage;
- modified/unknown, review required;
- externally managed/read-only;
- inaccessible or update failed.
Managed and known-snapshot rows show the released skill revision, Orca app release, and a short
digest for human/support diagnosis. Do not add a second, non-authoritative version marker to
`SKILL.md`; it can be transformed independently of the package and mistaken for write
authority.
Every successful background batch produces one toast. Conflicts and failures remain visible in
settings without repeated error toasts. Explicit replacement shows a local diff, warns that it
discards edits, and keeps a rollback backup until verification succeeds.
## Empirical validation (2026-07-12, macOS + Windows + Linux)
Both load-bearing assumptions were tested on a real developer machine against live installed
skills, not deferred to Phase 1 telemetry. Results are recorded so the evidence travels with
the design.
- **Verbatim install (decisive).** A clean-room `npx skills add https://github.com/stablyai/orca
--skill orca-cli orchestration --global --yes` into a throwaway home produced files
byte-identical to `origin/main`: equal Git blob hashes, equal byte counts, zero CRLF, exactly
one `SKILL.md` per skill, and no injected or stripped files. The CLI does not transform content
on macOS, so exact-content provenance is viable.
- **Historical-snapshot match on real stale installs.** The machine's genuinely stale `orca-cli`,
`computer-use`, and `orchestration` have on-disk bytes that exist verbatim as committed Git
blobs in repo history. Exact-content adoption against a released-snapshot registry would
recognize real, messy installs — not just freshly installed ones.
- **Release-mapping guard justified by data.** Those stale blobs are reachable at commits dated
after their recorded install time, i.e. identical bytes existed in a checkout/branch before or
independently of shipping. Presence in history is therefore not proof of an official release;
adoption must gate on the release mapping, and content identity — never timestamps — is the
arbiter. This is exactly the hole the separate release mapping closes.
- **Topology and dedup.** Provider skills under `~/.claude/skills` are symlinks (both relative
`../../.agents/...` and absolute forms) into canonical `~/.agents/skills`; realpath resolution
collapses provider and canonical to one physical destination, confirming physical-identity
dedup yields a single write. A live broken/dangling provider symlink was also present (target
missing), which is why the topology table has an explicit inaccessible row.
- **Lockfile corroboration present (macOS).** Lock entries carry `source: stablyai/orca` and a
folder hash, usable only as corroboration, consistent with the design.
**Windows (2026-07-12, validated on a real machine via the handoff below).** A clean-room
`npx skills add ... --skill orca-cli --global` on a default, non-Developer-Mode Windows install
produced:
- **CRLF translation, not byte-identical.** Installed `SKILL.md` was 21180 bytes with 318 CR
bytes; repo-main source was 20862 bytes with 0 CR. The size delta equals the CR count exactly,
i.e. a pure LF→CRLF translation with identical text. This is why text-file package identity is
LF-normalized rather than exact-byte; an exact-byte model would have adopted nothing on Windows.
- **Copy-fallback shape.** `.agents\skills\orca-cli` was a plain directory copy (no link), and no
`.claude` provider copy was created (Developer Mode off, process unelevated). Confirms the
Windows copy path and the need to manage independent per-root copies, not only a canonical
symlink target.
- **Same file set.** Only `SKILL.md` in both source and install — no injected or stripped files.
- **Lockfile populated.** The lock recorded `orca-cli`; the known Windows empty-lockfile bug did
not reproduce on this machine/version. Corroboration signal is therefore sometimes available on
Windows, but the design still relies on content-match as primary since it is not guaranteed.
Still gated behind the cross-platform CI round trip as a regression guard, and untested:
`core.autocrlf=false` on Windows (would install LF), and the junction/symlink shape under
Developer Mode. LF-normalized identity covers the autocrlf variance by construction.
**Linux (2026-07-12, throwaway Docker container, reached over SSH).** Ran the same clean-room
install on `Linux 6.12 aarch64` (node 22, npx 10.9, git 2.39). The container served SSH (sshd
listening); the CLI check was executed on the box, since SSH transport does not change what the
CLI writes to disk:
- **Verbatim LF, like macOS.** `orca-cli` and `orchestration` installed byte-identical to
`origin/main`: equal SHA-256, exact byte counts (20862 / 22850), **CR=0**, only `SKILL.md` in
each dir. Linux needs no separate manifest shape — it is covered by the LF identity.
- **Symlink topology.** `~/.claude/skills/orca-cli` is a relative symlink
(`../../.agents/skills/orca-cli`) into the canonical `~/.agents/skills` copy, matching macOS;
realpath dedup collapses provider and canonical to one write.
- **XDG lockfile path confirmed, with a sharper rule.** With `XDG_STATE_HOME` unset the lock is
`~/.agents/.skill-lock.json` (`source: stablyai/orca`); with `XDG_STATE_HOME` set the lock is
at `$XDG_STATE_HOME/skills/.skill-lock.json` and **not** at `~/.agents` — it moves, it is not
duplicated. So the corroboration reader must resolve `XDG_STATE_HOME` and read the single
correct location; checking only `~/.agents` finds no lock at all on such hosts. This is a
property of the Linux/host environment, not of Orca's SSH transport, so it applies equally to
native Linux, WSL, and SSH Linux targets — the reconciler must resolve the remote host's
`XDG_STATE_HOME` when reading lock corroboration remotely.
Not exercised here: Orca's own remote reconciler over SSH (Phase 4, unbuilt — nothing to drive
yet). This validated the Linux CLI install shape and the remote lock-location rule the reconciler
will depend on.
## Windows validation handoff
Result (2026-07-12): **FAIL — CRLF translation**, resolved by LF-normalized text identity (see
Build and release artifacts and Empirical validation). The procedure is retained for CI
regression and for the still-untested `core.autocrlf=false` and Developer-Mode junction shapes.
Give this to an agent on a Windows machine. It is self-contained, non-destructive (sandboxes the
skills CLI to a throwaway profile), and requires only PowerShell, Node/npx, and Git. It answers
one question: does `npx skills add` on Windows write skill bytes identical to the repo source, or
does it translate line endings / change the file set — and where does the lockfile land.
Goal and pass/fail:
- PASS (design safe as written): the installed `SKILL.md` is byte-identical to the repo source
(equal SHA-256, equal byte count, zero CRLF), and the skill directory contains the same file
set as the source.
- FAIL (design must model the Windows shape explicitly or mark it ineligible): the installed file
differs only by CRLF/line endings, or the file set differs, or the lockfile `skills` object is
empty after a successful install (the known Windows lockfile-not-written failure), which means
lockfile corroboration is unavailable on Windows and content-match must carry provenance alone.
Run this in PowerShell and paste the full transcript back:
```powershell
$ErrorActionPreference = 'Stop'
$sandbox = Join-Path $env:TEMP ("orca-skilltest-" + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $sandbox | Out-Null
# Sandbox the CLI's global install to a throwaway profile so real skills are untouched.
$old = @{ USERPROFILE=$env:USERPROFILE; HOME=$env:HOME; XDG_STATE_HOME=$env:XDG_STATE_HOME }
$env:USERPROFILE = $sandbox; $env:HOME = $sandbox; Remove-Item Env:XDG_STATE_HOME -ErrorAction SilentlyContinue
try {
npx --yes skills add https://github.com/stablyai/orca --skill orca-cli --global --yes 2>&1 | Tee-Object "$sandbox\install.log" | Out-Null
$installed = Join-Path $sandbox '.agents\skills\orca-cli\SKILL.md'
$truth = Join-Path $sandbox 'truth-SKILL.md'
# Ground truth = exact bytes Git stores on main (LF), fetched without transformation.
Invoke-WebRequest 'https://raw.githubusercontent.com/stablyai/orca/main/skills/orca-cli/SKILL.md' -OutFile $truth
function Info($label,$f){
if(!(Test-Path $f)){ Write-Host "$label`: MISSING"; return }
$bytes=[IO.File]::ReadAllBytes($f)
$crlf=($bytes | Where-Object {$_ -eq 13}).Count
Write-Host ("{0}: sha256={1} bytes={2} CR={3}" -f $label,(Get-FileHash $f -Algorithm SHA256).Hash.Substring(0,16),$bytes.Length,$crlf)
}
Write-Host "`n=== byte fidelity ==="
Info 'installed' $installed
Info 'repo-main ' $truth
$same = (Get-FileHash $installed -Algorithm SHA256).Hash -eq (Get-FileHash $truth -Algorithm SHA256).Hash
Write-Host ("VERDICT: {0}" -f ($(if($same){'VERBATIM (pass)'}else{'DIFFERS (inspect CR counts: CRLF-only diff = autocrlf translation)'})))
Write-Host "`n=== link shape (junction/symlink/copy) for provider + canonical ==="
foreach($p in @("$sandbox\.claude\skills\orca-cli","$sandbox\.agents\skills\orca-cli")){
if(Test-Path $p){ $i=Get-Item $p; Write-Host ("{0} -> LinkType={1} Target={2}" -f $p,$i.LinkType,($i.Target -join ',')) }
else { Write-Host "$p -> (absent)" }
}
Write-Host "`n=== file set in installed skill dir (extra/stripped files?) ==="
Get-ChildItem (Join-Path $sandbox '.agents\skills\orca-cli') -Recurse -File | ForEach-Object { $_.FullName.Substring($sandbox.Length) }
Write-Host "`n=== lockfile location + whether skills object populated (Windows #-not-written bug) ==="
foreach($lp in @("$sandbox\.agents\.skill-lock.json", "$env:XDG_STATE_HOME\skills\.skill-lock.json")){
if($lp -and (Test-Path $lp)){
$j=Get-Content $lp -Raw | ConvertFrom-Json
Write-Host ("{0} -> skills keys: {1}" -f $lp, (($j.skills.PSObject.Properties.Name) -join ','))
}
}
} finally {
$env:USERPROFILE=$old.USERPROFILE; $env:HOME=$old.HOME; if($old.XDG_STATE_HOME){$env:XDG_STATE_HOME=$old.XDG_STATE_HOME}
Remove-Item $sandbox -Recurse -Force -ErrorAction SilentlyContinue
Write-Host "`n(sandbox removed; your real skills were never touched)"
}
```
Also report, in words: (1) is Windows Developer Mode on (decides whether junctions or copy
fallback occurred)? (2) the VERDICT line, (3) whether CR counts differ between installed and
repo-main (CRLF-only difference = `core.autocrlf` translation → design must treat the Windows
install shape as its own manifest or mark it ineligible), (4) the link shapes, (5) the file set,
(6) whether any lockfile `skills` object was populated. If Developer Mode can be toggled, run the
block once with it on and once off to capture both junction and copy-fallback shapes.
## Historical rollout (superseded; do not implement)
The steps below preserve the retired write-based rollout for research context only. The
active rollout is defined in `skill-freshness-design.md` and uses read-only detection plus a
user-invoked, targeted `npx skills update <names...> --global` command; it has no ledger,
adoption flow, background updater, transactional writer, WSL reconciler, or SSH reconciler.
1. **Detection and adoption:** ship current/historical manifests, lockfile parsing, topology
classification, destination-scoped ledger, settings states, and an explicit “Manage and
update” action. No background writes.
2. **Local background updates:** enable adopted destinations on native macOS/Linux/Windows,
including transaction, rollback, concurrency, and restart tests.
3. **WSL:** move the same host-side reconciler into the distro runtime and validate Linux
semantics independently of UNC discovery.
4. **SSH:** expose the reconciler through remote runtime/RPC and validate Linux/macOS/Windows
remote hosts, reconnects, and multiple desktop clients.
Do not advance phases based only on unit tests. Each phase needs a real-host package update,
failure injection between every transaction boundary, and proof that modified/external content
was not written.
## Implementation touchpoints
Grounded in the current codebase (verified 2026-07-12); an implementer starts here. Follow the
repo naming rule — concrete domain names, no `helpers`/`utils`.
Main process (skill engine):
- `src/main/skills/skill-discovery-sources.ts` — the approved writable-root registry lives here
or beside it. Today it enumerates `~/.codex/skills`, `~/.agents/skills`, `~/.claude/skills`
(sourceKind `home`) plus a Codex plugin cache (`plugin`) and repo roots. The updater must
include only `home` roots and exclude the plugin cache and repo roots.
- `src/main/skills/discovery.ts` and `src/main/ipc/skills.ts` (`registerSkillsHandlers(store)`,
wired in `src/main/ipc/register-core-handlers.ts`) — extend discovery to classify topology and
provenance, and add IPC for ledger states, the adoption action, and explicit replace. The
handler already receives the persistence `store`.
- New modules, e.g. `src/main/skills/skill-manifest.ts`, `skill-ledger.ts`, `skill-reconcile.ts`
— manifest load, content identity (LF-normalized text / exact-byte binary), transaction
workspace, publish/rollback.
- `src/shared/skill-metadata.ts` — existing top-level-only frontmatter parser; reuse for
name/description in settings. It need not read a version (identity is manifest-based, not
`metadata.version`).
State and host identity:
- `src/main/persistence.ts` (`store`, host-partitioned `orca-data.json`) — home for the
management ledger; already host-aware and passed to the skills handler.
- `src/shared/execution-host.ts` — `ExecutionHostId = 'local' | ssh:<id> | runtime:<id>`; add a
`wsl:<distro>` variant (none today) so ledger records key per host. Do not key off
`src/main/git/git-capability-state.ts`: it is in-memory only and scopes SSH by provider object
identity, which is not durable across reconnects or restarts.
Renderer and UX:
- The ~13 setting/feature surfaces that today print raw `npx skills ...` strings from
`src/shared/agent-feature-install-commands.ts` (CliSection, OrchestrationPane, BrowserUsePane,
EphemeralVmsPane, ComputerUseSkillSetupPanel, the linear/emulator CTAs, and the feature-wall /
feature-tip cards) become ledger-state rows with adoption/update actions.
- `src/renderer/src/hooks/useInstalledAgentSkills.ts` — `notifyInstalledAgentSkillsChanged()` is
the post-write refresh signal, already listened to on focus and on the install event.
Build and CI:
- `config/electron-builder.config.cjs` (+ `config/scripts/electron-builder-config.test.mjs`) —
extraResources is already contract-tested; add `skills/` bundling and the generated manifest.
- Manifest generator, released-snapshot registry, and the monotonic-revision check run on PR and
re-run on merge-queue / main pushes (the localization-catalog check in `pnpm lint` is the
precedent for a generated-output gate).
- Cross-platform round trip asserts LF-normalized identity (not exact bytes) for text files and
exact bytes for binary/executable files.
Tests:
- `tests/e2e/settings-skill-detection.spec.ts` — extend for update-available / adoption /
conflict / rollback states.
- Keep filesystem-transaction and host-isolation coverage as deterministic integration tests
below E2E, per the Test matrix.
### Phase 1 definition of done
Ship-ready, with no background writes, when:
- The bundled manifest and released-snapshot registry are generated and CI-verified (monotonic
per-skill revisions, immutable history, release mapping).
- Discovery classifies every `home`-root Orca skill as current / update-available / newer-known /
modified / unknown / externally-managed / inaccessible, using LF-normalized text identity.
- The ledger records adopted destinations in the host-partitioned store, keyed by
`ExecutionHostId` (plus `wsl:<distro>` where applicable).
- Settings shows those states; a proactive non-repeating adoption nudge exists; "Manage and
update" and explicit destructive replace (with backup and diff) work on the local host.
- Orca-driven installs auto-record ownership.
- No path is written in the background and no path is adopted silently.
Phase 1 exists to validate the two field assumptions before the background writer is built:
the byte/EOL identity match rate on real installs, and the adoption take-rate on the nudge.
## Test matrix
### Content and provenance
- exact current and historical official snapshot;
- edited, missing, extra, truncated, and mode-changed file;
- foreign same-name skill;
- equal revision/different content;
- newer known and unknown release;
- missing, corrupt, old-version, XDG-located, and spoofed skills CLI lock;
- lost/corrupt Orca ledger and app reinstall;
- source ref/path changes and repo/skill rename;
- supported pinned and newest skills CLI round trips match bundle identity on macOS, Linux, and
Windows, including symlink and copy/fallback installation: exact bytes/modes for binary and
executable files, LF-normalized content for text files;
- CRLF vs LF install (`core.autocrlf` on and off) adopts and stays managed via normalized
identity; an EOL-only difference is never a conflict; a real content change still is;
- Orca-published text files keep the destination's existing EOL convention and do not trigger a
skills-CLI re-update loop.
- two same-skill PRs that independently change content cannot pass the merge queue/main
monotonicity gate with one revision;
- adoption dismissal suppresses the same destination snapshot but a newly installed official
destination remains eligible for one proactive prompt.
### Topology
- canonical copy with provider symlinks;
- independent copy mode and Windows symlink-to-copy fallback;
- parent-directory symlink, relative/absolute skill symlink, Windows junction, hardlink, and
case-variant alias;
- external dotfiles/chezmoi/stow target;
- Nix/Home Manager/generated and read-only roots;
- network/UNC home and long Windows paths;
- repo-scoped and plugin-cache same-name skills remain untouched;
- partial provider presence and custom provider home.
### Transactions and concurrency
- failure after stage, backup, publish, verify, ledger write, and cleanup;
- crash-orphaned staging and rollback directories are excluded from discovery, recovered or
swept from their markers, and never offered for adoption;
- unmarked lookalike directories under or near the reserved transaction path are never swept;
- `EPERM`, `EBUSY`, disk full, permission loss, and process crash;
- two Orca windows and duplicate triggers;
- stable/dev attempts and isolated dev home;
- skills CLI or user mutation before stage, during stage, and immediately before publish;
- failed destinations retry without reprocessing successful siblings.
### Hosts and E2E
- native macOS, Linux, and Windows;
- multiple WSL distros/users, distro shutdown mid-update, and state isolation;
- SSH Linux/macOS/Windows, disconnect/reconnect mid-update, old remote runtime, two desktop
clients, and host/user identity changes;
- settings update-available, adoption, managed-current, conflict, rollback, and retry states;
- proactive adoption nudge is non-repeating, opens review, and never claims a path by itself;
- canonical-only installs report provider visibility accurately and provider-link repair stays
separately consented;
- post-restart app update uses the new bundle, never the pre-restart bundle.
Extend `tests/e2e/settings-skill-detection.spec.ts`, but keep filesystem transaction and
host-isolation coverage below E2E as deterministic integration tests.
## Open questions
- Exact platform paths and schema migration policy for the host-local management ledger.
- Whether the skills CLI can expose a supported machine-readable placement/ownership API in
the future; until then its private lock remains read-only supporting evidence.
- Which custom agent homes Orca can identify from the actual launch environment rather than
ambient desktop environment variables.
- Exact provider-link repair scope after a canonical install is managed but not visible to an
agent; this remains separately consented from content updates.
- Toast copy and whether settings should link to a per-skill changelog.
-262
View File
@@ -1,262 +0,0 @@
# Skill Freshness: Thin Stubs + Read-Only Detection
Status: adopted direction 2026-07-13. Phase 1 (guide sources + binary-served CLI) is
implemented, pending release; detection and stub migration are not implemented. This is the
authoritative plan. It supersedes the write phases of `skill-auto-update-design.md` (Phases
2–4: background updates, WSL, SSH) and the migration section of
`skill-guide-indirection-design.md`. The detection and content-identity research in those
notes still applies and is referenced below.
## Problem (unchanged)
Orca ships agent skills that teach coding agents to drive the `orca` CLI. Users install them
with `npx skills add stablyai/orca --global`. Installed copies are frozen files; the Orca
binary keeps moving. A stale skill tells an agent to use commands that are wrong or unsafe
for the binary it is driving.
## Decision
Three moves, replacing the in-app write machinery entirely:
1. **Structural fix — content lives in the binary.** All version-sensitive skill content is
served by the CLI (`orca skills get <topic>`), compiled at build time from authoritative
`skill-guides/` sources. Generated `skills/<name>/SKILL.md` files are the installable
discovery surface and become permanent thin stubs. Staleness becomes impossible for the
content that matters, rather than mitigated.
2. **Residual freshness — read-only detection, ecosystem-rail updates.** Orca detects
outdated official copies (content-addressed, LF-normalized identity) and pre-fills a
targeted `npx skills update <names...> --global` command in a terminal. The user reviews
and runs the skills CLI's own update command; Orca never submits it automatically and
never writes a byte into a skill directory.
3. **No persistent ownership or update state.** No ownership ledger, no adoption consent,
no background writer, no transactional publish/rollback stack, no settings toggle. The
one content migration (fat skill → stub) rides the same user-invoked npx rail. A small
dismissed-nudge set remains in app state; it grants no write authority.
### Why the write machinery was dropped (decision record)
- **Maintenance-per-use.** The transactional mutation stack (staging, rollback, crash
recovery, orphan sweeping, ownership ledger, installer attribution) is ~3.2K production
lines before its tests. It is the most correctness-critical code in the tree, built to
write into user-owned directories forever. Under the stub model it would run meaningfully
once. Every module is permanent Windows/WSL/SSH edge-case surface for a one-shot job.
- **Trust posture.** Ecosystem discourse (2026) consistently favors pinning and reviewable,
user-invoked updates over silent writes into `$HOME`; Orca has first-hand precedent of
user backlash from writing into user-owned config directories. Read-only detection has no
trust cost at all.
- **The rail already exists.** `npx skills update <names...> --global` is the ecosystem's
documented remedy, and once `skills/` contains stubs it delivers the migration without an
Orca-owned writer. The rail is treated as an external dependency with a tested contract,
not assumed trustworthy from its lock file alone.
- The full write implementation exists, reviewed and green, on branch
`brennanb2025/skill-auto-update-research` (PR #8496, closed as superseded). If in-app
writes are ever genuinely needed, start from that branch, not from scratch.
## Design
### A. Binary-served guides
```sh
orca skills list # one line per topic: name + when to use
orca skills get <topic> # full version-matched guide, markdown to stdout
orca skills get <topic> --full # include bundled reference docs, if any
```
- Topics are the skill names (orca-cli, orchestration, computer-use, …).
- Full version-sensitive content lives in `skill-guides/<topic>.md`. A generator embeds it
in a concrete CLI module and emits the installable `skills/<name>/SKILL.md` projection.
During the pre-stub release this projection may remain fat; after migration it is a stub.
- The generated-output-current check uses the existing generated-artifact-gate pattern used
by the manifest verifier. It asserts that generated guide data and installable projections
match their sources and that every stub topic resolves against the compiled guide table.
No network or runtime filesystem lookup is required. Unknown topic → nonzero exit + topic
list.
- Authoritative guides, generated projections, and embedded TypeScript are pinned to LF, and
the generator normalizes input before embedding it. Detection still normalizes text identity
because already-installed Windows copies may retain the historical CRLF shape.
- Topic names are append-only and aliased forever: a stub installed in 2026 must still
resolve in 2028. Renames add an alias, never remove one.
- Verb is `skills get` (not `guide`) to match the convention agents are already taught by
other tools in the wild.
### B. Stub format and command resolution
One stub per skill — frontmatter descriptions are the agent-routing layer and stay
per-skill registry entries.
```markdown
---
name: orca-cli
description: <unchanged per-skill trigger copy — the discovery surface>
allowed-tools: <the supported Orca CLI command names>
---
# Orca CLI
This file is a discovery stub, not the usage guide. The full, version-matched reference
lives in the `orca` binary itself.
Before using Orca commands, resolve the Orca CLI for this session and load the guide once:
<resolved-orca-cli> skills get orca-cli
Don't guess subcommands or flags from memory or from cached copies of this skill — they
change between Orca releases; the command above always matches the installed binary that
will handle subsequent Orca commands.
```
Rules:
- The permanent body says when to engage Orca, how to resolve its CLI, and where to fetch
the version-matched guide. It does not carry the changing command reference.
- A stub must never blindly invoke bare `orca` outside an Orca-managed terminal on Linux;
that name commonly resolves to the GNOME Orca screen reader. The contract must cover
packaged `orca`, Linux/WSL `orca-ide`, SSH relay `orca`, and development `orca-dev`, and
`allowed-tools` must cover every command the resolution contract can select.
- **Linux command decision (2026-07-13):** do not install a uniform global bare `orca` alias;
it would shadow or risk launching the GNOME Orca screen reader. Keep `orca-ide` outside
managed Linux terminals, the existing managed-terminal/SSH `orca` shims, and `orca-dev` for
development. The permanent stub therefore needs the short resolver exercised by the spike.
- First-generation stubs are hybrid: a minimal safe bootstrap plus the guide pointer. Thin
them further only after pointer compliance and old-binary behavior are measured. If
`skills get` is unavailable, the hybrid must provide a bounded legacy workflow and tell the
user that updating Orca restores the full version-matched guide; it must not dead-end or
invite the agent to guess the missing command surface.
### C. Read-only detection (kept from Phase 1, slimmed)
Kept as-is:
- Bundled `skills/` packages + current manifest + released-snapshot registry + release
mapping, with the generation script and merge-queue monotonicity gate (static data + CI,
not runtime machinery).
- LF-normalized text identity / exact-byte binary identity (the Windows CRLF finding
stands: exact-byte matching would misclassify every Windows install as modified).
- Bounded inventory work limits, topology classification (symlink dedup, external links,
plugin caches and repo scopes excluded), and the launch / focus / post-install triggers.
- The skills-CLI round-trip CI on macOS/Linux/Windows — extended from current-install tests
to historical-fat-install → targeted global update → stub migration. The matrix covers
LF/CRLF and provider aliases as a positive convergence contract, plus independent-copy
observation that accepts only unchanged historical or exact-current bytes. Post-update
bytes, not exit status, decide.
Slimmed:
- Statuses collapse to: `current`, `outdated` (exact match of an older released snapshot),
`newer-known`, `unrecognized`, and `inaccessible`. Without a ledger, Orca cannot honestly
distinguish a locally modified official copy from unrelated same-named content;
`unrecognized` says it may be edited or from another source. All `managed-*` states, the
ledger, adoption eligibility, and attribution are removed.
- Status and action eligibility are separate. External links, read-only locations, plugin
caches, repo scopes, and unsupported topologies remain informational even when their bytes
match an official snapshot.
- Dismissal state for the nudge is a simple local dismissed-set keyed by
(physical identity, skill, bundled revision) in app state — not a consent ledger.
### D. Surfacing
- **Surfaces (venue decision 2026-07-14):** a lingering toast, an update modal, and the
existing Settings setup rails for CLI, Orchestration, Computer Use, and Per-Workspace
Environments. Their installed pills carry safe freshness status, while their existing
Update and Re-check actions remain the per-skill path. The Skills page was
de-linked by #4535 (2026-06-02) — its only entry, the sidebar toolbox menu, was removed —
so it is no longer a venue; the freshness surface moved off it entirely. The behavior
contracts below (name-scoped eligibility, no auto-run, dismissal keys, re-inventory
triggers) are unchanged; only the venue moved.
- **Per-placement rows** (read-only): name, status badge, one-line explanation. `newer-known`,
`unrecognized`, `inaccessible`, and unsupported-topology rows are informational. They live in
the modal's collapsed **Details** section (auto-expanded when a placement is blocked).
- **Name-scoped update eligibility:** eligibility is computed across all discovered placements
of a name, not per row. Offer a name only when at least one placement is `outdated` and every
placement is an exact `current` or `outdated` official snapshot in a topology the validated
rail actually converges. With skills CLI 1.5.17 that means the canonical global copy and
provider aliases to it. Independent provider copies are informational and poison the offer:
empirical copy-mode testing produced both stale and converged provider copies in otherwise
equivalent 1.5.17 environments, so that topology is not deterministic enough to offer. One
`newer-known`, unrecognized, external, read-only,
inaccessible, repo/plugin, independent-copy, or otherwise unsupported placement poisons the
update offer for that name entirely.
- The action combines only eligible outdated Orca names into
`npx skills update <names...> --global` and opens the update modal's editable terminal with
that command pre-filled, leaving execution to the user. Never use an unscoped bulk update and
never auto-submit the command. Re-inventory after terminal exit, modal close, or focus; only
observed bytes, not the skills CLI exit status, determine success. When the eligible set
empties and every placement is `current`, the modal shows an up-to-date state; if placements
remain outdated-but-blocked or unrecognized, it says so honestly instead.
- **One lingering, non-repeating nudge**: count only eligible outdated skill names and offer the
same targeted action, which opens the update modal. The toast lingers (no auto-close) until the
user opens the modal or explicitly dismisses it; ignoring it (app quit) records nothing, so a
still-outdated skill may prompt once more next launch. A later inventory that resolves or blocks
the offered tuple retracts the stale toast without recording a dismissal. An outdated name
poisoned by another placement remains visible in the modal's Details but never produces an
unsafe nudge action. Dismissal is recorded per (physical identity, name, bundled revision) only
on explicit dismissal, so a newly outdated official placement or genuinely newer stub revision
may prompt once more. No
toggle — nothing automatic happens that would need one.
### E. Migration (fat → stub)
1. **Implemented, pending release:** from a fresh main-based PR, add authoritative guide
sources, generated embedded data, `orca skills list/get`, aliases, generated-output checks,
and local/SSH/WSL/dev tests. Keep distributed skills fat and ship this release first.
2. From a separate PR, land slim read-only detection and Skills-page/nudge UI, including the
name-scoped targeted update action and the real migration-rail CI. Keep distributed
skills fat.
3. Run the pointer-compliance spike against the released guide-serving binary, not a checkout
artifact. The binary must be publicly released before a stub PR merges because the skills
CLI installs from repository main, independently of Orca's desktop release train.
4. In one PR, convert only `orca-cli` to a first-generation hybrid stub and keep any final
thinning of that stub in the same change. This bumps its registry revision like any content
change. Existing users see an `outdated` exact snapshot and may run the targeted global
update; users of pre-guide binaries retain the hybrid bootstrap.
5. Cut an RC before the stable release and use that validation window to measure compliance,
task success, old-binary behavior, and token cost. Ship the thin form in stable only if those
gates pass; otherwise retain the hybrid. Convert the remaining skills gradually in later PRs.
Users who ignore the nudge keep working with their existing fat skills.
## Spike gate (before any stub ships)
Using the released guide-serving binary, install the proposed hybrid `orca-cli` stub in a
test home and run real agents (Claude Code, Codex) on representative Orca tasks. Measure:
- how often the agent resolves the correct packaged/Linux/WSL/SSH/dev command and fetches the
guide before its first Orca command;
- task success versus the fat skill;
- old-binary failure behavior; and
- net token cost (stub preload + one fetch versus fat preload).
Then test a thinner stub against the same corpus. Nothing converts, and the hybrid stub does
not thin, until the relevant variant passes.
## Risks and open questions
- **npx rail reliability.** The skills CLI update path has had false "up to date" results,
global Windows failures, missing global lock tracking, lossy lock migration, and copy-mode
topology changes. The historical-fat → targeted-global-update → stub CI is a release gate,
not an early-warning job. Detection always re-checks bytes after the user updates, so a
failed or no-op update re-surfaces `outdated` instead of lying. Minimum validated version is
1.5.17: 1.5.16 failed the provider-alias convergence contract, while 1.5.17 copy convergence
still varies by environment. CI pins 1.5.17 and probes latest; monitor and contribute upstream
fixes before broadening eligibility.
- **Trigger-copy iteration slows.** Improvements to stub descriptions reach existing
installs only when users run the npx command. Acceptable at stub-change cadence; the
compiled guides (the content that matters) are exempt by construction.
- **Multi-file skills.** Current shipped packages are single-file. If a future skill needs
scripts/assets, either the binary serves them (`--full` / `--script`) or that skill
accepts the fat-file decay model. Decide when it happens.
- **Remote hosts.** Detection ships local-host-only. Stubs make remote _content_ a non-issue:
SSH/WSL launchers forward to the host's bundled CLI, so the guide matches the command
surface that will handle subsequent requests. Remote stub installs can lag on trigger
copy, which is the accepted residual. The WSL/SSH reconciler phases of the old design are
retired, not deferred.
- **Agent Skills spec evolution** (frontmatter fields, allowed-tools syntax) is the most
likely future cause of a real stub update wave; the nudge path covers it.
## Relationship to prior notes
- `skill-auto-update-design.md`: Problem statement, empirical CLI-behavior findings
(verbatim-LF mac/linux, CRLF Windows, XDG lock location, symlink topology, released-blob
provenance) and the Phase-1 detection design remain valid inputs. Phases 2–4 (background
writes, WSL, SSH reconcilers) are retired by this document.
- `skill-guide-indirection-design.md`: The stub/CLI contract and prior-art survey are
folded in here; its migration-via-in-app-updater section is superseded by §E.
-148
View File
@@ -1,148 +0,0 @@
# Skill Guide Indirection (Thin Stubs + `orca skills get`)
Status: FOLDED INTO `skill-freshness-design.md` (2026-07-13) — read that instead. The stub/CLI
contract and prior-art survey carried over; the migration-via-in-app-updater section here is
superseded (migration now rides `npx skills update`, no in-app writes).
## Principle
Version-sensitive content must not live in distributed files; only discovery metadata should.
Every hard problem in the current system — staleness, adoption consent, installer attribution,
transactional replacement, remote-host reconciliation — descends from shipping full skill
bodies as mutable files that must track the installed Orca binary. Move the bodies into the
binary and the problems shrink to a residue the existing machinery already handles.
## Design
### 1. The binary serves the instructions
New CLI surface (topic names match skill names):
```sh
orca skills list # enumerate available guides, one line each
orca skills get <topic> # full version-matched guide for one skill, markdown to stdout
orca skills get <topic> --full # include bundled reference docs, if any
```
- Content is authored in `skill-guides/<topic>.md`. A generator embeds those authoritative
sources in the CLI and emits `skills/<name>/SKILL.md` as an installable projection;
`skills/` is generated output, not an authoring source.
- Output contract: plain markdown on stdout, exit 0; unknown topic exits nonzero with the
topic list. No network, no filesystem reads outside the binary's own resources.
- Verb choice: `skills get` (not `guide`) to match the convention agents are already being
taught by other tools (see Prior art).
### 2. Historical stub sketch (superseded; do not copy)
This sketch records the indirection idea only. The resolver and first-generation hybrid stub
contract in `skill-freshness-design.md` are authoritative and must cover packaged `orca`,
Linux/WSL `orca-ide`, SSH `orca`, and development `orca-dev` without blindly invoking bare
`orca` on Linux.
```markdown
---
name: orca-cli
description: <unchanged per-skill trigger copy — this is the discovery surface>
allowed-tools: <all supported Orca CLI command names>
---
# Orca CLI
This file is a discovery stub, not the usage guide. The full, version-matched reference
lives in the `orca` binary itself.
Before using Orca commands, resolve the CLI for this session and load the guide once:
<resolved-orca-cli> skills get orca-cli
Don't guess subcommands or flags from memory or from cached copies of this skill — they
change between Orca releases; the command above always matches the installed binary.
```
Stub rules:
- Body is deliberately version-independent: it says when to engage Orca and where to fetch
the how — never the how itself. A stub should survive many releases unchanged.
- `allowed-tools` must cover every executable that the authoritative resolver can select.
- Stub must not ship before the binary that serves its topic: gate stub rollout on the
release that includes `skills get` (a stub pointing at a command that does not exist
is worse than a fat skill). Enforce with a build check: every stub topic must resolve
against the compiled guide table.
- Stub should degrade honestly when no supported Orca command is on PATH and must retain a
bounded legacy bootstrap for binaries that predate `skills get`.
### 3. What this retires, what it keeps
Retired / collapsed:
- The ownership ledger, adoption and installer-attribution flows, background updater,
transactional publish/rollback/orphan sweep, and all automatic writes into user-owned
skill directories.
- Phases 3–4 of skill-auto-update-design.md (WSL/SSH remote file reconcilers). Wherever the
skill is useful the `orca` binary is present, and the remote binary serves the guide
matching its own host's version. No remote file-sync problem remains.
Kept (read-only):
- Bounded discovery, LF-normalized content identities, the released-snapshot registry,
release mapping, and CI gates. Statuses are `current`, `outdated`, `newer-known`,
`unrecognized`, and `inaccessible`; no ledger is needed to compute them.
- Name-scoped eligibility across every placement. One newer, unrecognized, external,
read-only, repo-scoped, plugin, or inaccessible placement poisons the update offer for
that skill name.
- The skills-CLI round-trip CI, extended to prove historical fat installs migrate to stubs
through targeted global updates across supported hosts and topologies.
- Read-only settings rows and a dismissible nudge that pre-fill a targeted
`npx skills update <eligible-names...> --global` command. Orca never submits it or writes
into a skill directory.
## Prior art (verified live 2026-07-13)
- vercel-labs/agent-browser — canonical stub + `agent-browser skills get core`; docs frame
it explicitly: "the installed SKILL.md rarely changes, while the CLI always serves content
matching its own version." Stub self-describes as a discovery stub that "cannot change
between releases."
- Canner/WrenAI (skills/wren/SKILL.md) — independent (non-Vercel) adopter: "The actual
workflow guides … live inside the `wren` CLI itself, so they always match the installed
wrenai version (no skill cache, no version drift)." Uses `wren skills list` /
`wren skills get <topic>` / `--full` — the verb convention to match.
- vercel-labs/zerolang (skills/zero/SKILL.md) — "This file is only a discovery stub… ask the
installed compiler for the skill content that matches that exact binary." Adds the nuance
of warning agents not to replace a pinned binary.
- vercel/next.js (skills/next-dev-loop/SKILL.md) — consumes the pattern: instructs agents to
"run `agent-browser skills get core` once for the version-matched usage guide — don't
guess subcommands from memory." Normalization signal.
- Ecosystem discourse (Snyk threat model, HN, vercel-labs/skills issues #500/#542, Anthropic
skill-trust guidance) demands pinning + reviewable updates and condemns silent pulls from
mutable remotes. Stub indirection satisfies the audit-once trust model: the audited file
never changes meaning; served content is exactly as trusted as the installed binary.
## Migration plan
0. Release `orca skills list/get` first from authoritative `skill-guides/` sources while
distributed skills remain fat. No stub may reach repository main before a public binary
can serve it.
1. Add read-only freshness detection, name-scoped update eligibility, the targeted
user-invoked `npx skills update <names...> --global` action, and migration-rail CI. Keep
distributed skills fat.
2. Spike pointer compliance against the released guide-serving binary with Claude Code and
Codex, including Linux/WSL/SSH/dev command resolution, old-binary fallback, task success,
and token cost.
3. Convert only `orca-cli` to a first-generation hybrid stub. Existing exact official fat
copies become eligible for the targeted ecosystem update rail; users who ignore the
nudge retain their existing skills.
4. Cut an RC, measure the gates, and thin the hybrid only if it passes. Convert remaining
skills gradually in later PRs.
## Open questions
- Compliance failure mode: if agents skim the stub and skip the fetch, options are stronger
stub wording, frontmatter `description` nudging ("requires running orca skills get"),
or hybrid stubs carrying a minimal command table plus the pointer. Spike decides.
- Multi-file skills: current shipped packages are single-file; if a future skill needs
scripts/assets, decide whether the binary serves them (`--script <name>` like WrenAI) or
they stay in the package (then that skill keeps the fat-update path).
- Topic/verb naming: `orca skills get` collides conceptually with the `skills` installer
CLI; confirm no confusion in agent behavior during the spike.
- Old binaries: a user can hold a stub while running an older orca without `skills get`
(downgrade case). Stub wording should fail gracefully ("if the command is missing, update
Orca"); acceptable residual.
- Whether settings should surface "guide served by binary" as a distinct row state so
support can tell stub-era installs from fat-era ones at a glance.
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.147-rc.4",
"version": "1.4.148-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -77,6 +77,7 @@
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless",
"test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs",
"test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
+2 -1
View File
@@ -45,7 +45,8 @@ const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000
const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', {
terminal: await getTerminalHandle(flags, cwd, client)
terminal: await getTerminalHandle(flags, cwd, client),
navigation: 'host'
})
printResult(result, json, formatTerminalFocus)
}
+4 -1
View File
@@ -191,7 +191,10 @@ describe('command aliases dispatch to the canonical handler', () => {
await main(['terminal', 'focus', '--terminal', 'term_abc', '--json'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('terminal.focus', expect.objectContaining({}))
expect(callMock).toHaveBeenCalledWith(
'terminal.focus',
expect.objectContaining({ navigation: 'host' })
)
})
it('serves `agent-context --json` without contacting the runtime', async () => {
+5 -8
View File
@@ -13,7 +13,10 @@ export function buildPosixHookPayloadCapture(
}
export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin'
export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = '"%SystemRoot%\\System32\\more.com" >nul 2>nul'
// Why: qualify the stdin reader because Windows searches the worktree for
// executables before PATH and hook payloads must not reach repo-local code.
export const WINDOWS_HOOK_STDIN_READER = '"%SystemRoot%\\System32\\more.com"'
export const WINDOWS_HOOK_STDIN_DRAIN_COMMAND = `${WINDOWS_HOOK_STDIN_READER} >nul 2>nul`
// Why: batch payloads stream directly to curl and cannot be buffered safely in
// environment variables, so guard failures share one EOF-draining epilogue.
@@ -27,11 +30,5 @@ export function buildWindowsHookEnvironmentGuardLines(): string[] {
}
export function buildWindowsHookStdinDrainEpilogue(): string[] {
return [
`:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`,
// Why: qualify the inbox reader because Windows searches the worktree for
// executables before PATH and hook payloads must not reach repo-local code.
WINDOWS_HOOK_STDIN_DRAIN_COMMAND,
'exit /b 0'
]
return [`:${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`, WINDOWS_HOOK_STDIN_DRAIN_COMMAND, 'exit /b 0']
}
@@ -207,8 +207,11 @@ async function generatePosixScripts(): Promise<Map<string, string>> {
const generated = [...memory.fs.files.entries()].filter(
([path]) => path.includes('/.orca/agent-hooks/') && path.endsWith('.sh')
)
expect(generated, `${entry.agent} generated scripts`).toHaveLength(1)
scripts.set(entry.agent, generated[0][1])
// Why: Claude ships a second managed script (the statusline usage feed); the stdin lifecycle contract applies to every generated script.
expect(generated.length, `${entry.agent} generated scripts`).toBeGreaterThan(0)
for (const [path, script] of generated) {
scripts.set(`${entry.agent} ${path.split('/').pop()}`, script)
}
}
return scripts
}
@@ -377,14 +380,13 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', ()
it('accepts a large payload without Orca environment or a broken writer', async () => {
const scripts = await generatePosixScripts()
for (const [agent, script] of scripts) {
const extraEnv =
agent === 'command-code'
? {
ORCA_AGENT_HOOK_PORT: '1',
ORCA_AGENT_HOOK_TOKEN: 'test-token',
ORCA_PANE_KEY: 'test-pane'
}
: {}
const extraEnv = agent.startsWith('command-code')
? {
ORCA_AGENT_HOOK_PORT: '1',
ORCA_AGENT_HOOK_TOKEN: 'test-token',
ORCA_PANE_KEY: 'test-pane'
}
: {}
const result = await runPosixHook(script, extraEnv)
expect(result.exitCode, `${agent} exit code`).toBe(0)
expect(result.stdinErrors, `${agent} stdin errors`).toHaveLength(0)
@@ -392,7 +394,7 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', ()
})
it('drains before Claude skips hooks imported by Devin', async () => {
const script = (await generatePosixScripts()).get('claude')
const script = (await generatePosixScripts()).get('claude claude-hook.sh')
expect(script).toBeDefined()
const result = await runPosixHook(script!, { DEVIN_PROJECT_DIR: '/tmp/devin-project' })
expect(result.exitCode).toBe(0)
@@ -112,6 +112,8 @@ const JSON_INSTALLERS = [
] as const
const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/'
// Why: statusLine is not a hook — Claude's schema has no timeout field (type/command/padding/refreshInterval), and a slow statusline can't block agent turns.
const STATUSLINE_SCRIPT_NEEDLE = '-statusline.'
// Walk the parsed config and assert every Orca-managed command carrier (a node
// with a `command`/`bash`/`powershell` string pointing at the managed script
@@ -121,8 +123,13 @@ const MANAGED_HOOKS_DIR_NEEDLE = '/.orca/agent-hooks/'
function countManagedCarriersWithTimeout(
node: unknown,
expectedTimeout: number,
isManagedCarrier = (value: string): boolean =>
value.replaceAll('\\', '/').includes(MANAGED_HOOKS_DIR_NEEDLE)
isManagedCarrier = (value: string): boolean => {
const normalized = value.replaceAll('\\', '/')
return (
normalized.includes(MANAGED_HOOKS_DIR_NEEDLE) &&
!normalized.includes(STATUSLINE_SCRIPT_NEEDLE)
)
}
): number {
if (Array.isArray(node)) {
return node.reduce<number>(
@@ -0,0 +1,77 @@
// Why: locks the /statusline/claude loopback contract — form-encoded posts from the
// managed statusline script must reach the listener, and junk must fail open (204).
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AgentHookServer } from './server'
import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits'
describe('AgentHookServer /statusline/claude', () => {
let server: AgentHookServer
beforeEach(async () => {
server = new AgentHookServer()
await server.start({ env: 'production' })
})
afterEach(() => {
server.stop()
})
function post(body: string, token?: string): Promise<Response> {
const env = server.buildPtyEnv()
return fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/statusline/claude`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Orca-Agent-Hook-Token': token ?? env.ORCA_AGENT_HOOK_TOKEN
},
body
})
}
it('forwards parsed rate limits to the statusline listener', async () => {
const events: ClaudeStatusLineRateLimits[] = []
server.setClaudeStatusLineListener((event) => {
events.push(event)
})
const payload = JSON.stringify({
rate_limits: {
five_hour: { used_percentage: 12.5, resets_at: 1738425600 },
seven_day: { used_percentage: 40, resets_at: 1712059200 }
}
})
const body = new URLSearchParams({
paneKey: 'pane-1',
configDir: '/home/dev/managed',
payload
}).toString()
await expect(post(body)).resolves.toMatchObject({ status: 204 })
expect(events).toEqual([
{
configDir: '/home/dev/managed',
fiveHour: { used_percentage: 12.5, resets_at: 1738425600 },
sevenDay: { used_percentage: 40, resets_at: 1712059200 }
}
])
})
it('rejects posts with a bad token and ignores payloads without rate limits', async () => {
const events: ClaudeStatusLineRateLimits[] = []
server.setClaudeStatusLineListener((event) => {
events.push(event)
})
await expect(post('payload={}', 'wrong-token')).resolves.toMatchObject({ status: 403 })
const noLimits = new URLSearchParams({
paneKey: 'pane-1',
payload: JSON.stringify({ context_window: { used_percentage: 8 } })
}).toString()
await expect(post(noLimits)).resolves.toMatchObject({ status: 204 })
await expect(post('payload=not-json')).resolves.toMatchObject({ status: 204 })
expect(events).toEqual([])
})
})
+22
View File
@@ -32,6 +32,11 @@ import {
type HookListenerState
} from '../../shared/agent-hook-listener'
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import {
CLAUDE_STATUSLINE_PATHNAME,
parseClaudeStatusLineBody,
type ClaudeStatusLineRateLimits
} from '../../shared/claude-statusline-rate-limits'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusClearIpcPayload,
@@ -445,6 +450,7 @@ export class AgentHookServer {
// Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge.
private env = 'production'
private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null
private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null
private onPaneStatusCleared: PaneStatusClearListener | null = null
private statusChangeListeners = new Set<StatusChangeListener>()
// Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env).
@@ -486,6 +492,13 @@ export class AgentHookServer {
}
}
// Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly.
setClaudeStatusLineListener(
listener: ((event: ClaudeStatusLineRateLimits) => void) | null
): void {
this.onClaudeStatusLine = listener
}
subscribeStatusChanges(listener: StatusChangeListener): () => void {
this.statusChangeListeners.add(listener)
return () => {
@@ -1472,6 +1485,15 @@ export class AgentHookServer {
try {
const body = await readRequestBody(req)
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
const statusLineEvent = parseClaudeStatusLineBody(body)
if (statusLineEvent) {
this.onClaudeStatusLine?.(statusLineEvent)
}
res.writeHead(204)
res.end()
return
}
const source = resolveHookSource(pathname)
if (!source) {
res.writeHead(404)
+65
View File
@@ -137,6 +137,71 @@ describe('markCopilotFolderTrusted', () => {
})
describe('markCodexProjectTrusted', () => {
it('trusts the main repository root for a linked worktree without reading commondir', () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-linked-ws-'))
const repository = join(fixtureRoot, 'repo')
const workspace = join(fixtureRoot, 'worktrees', 'feature')
const worktreeGitDir = join(repository, '.git', 'worktrees', 'feature')
try {
mkdirSync(worktreeGitDir, { recursive: true })
mkdirSync(workspace, { recursive: true })
writeFileSync(join(workspace, '.git'), `gitdir: ${worktreeGitDir}\n`, 'utf-8')
writeFileSync(join(worktreeGitDir, 'gitdir'), join(workspace, '.git'), 'utf-8')
markCodexProjectTrusted(workspace)
const repositoryRoot = realpathSync.native(repository)
const workspaceRoot = realpathSync.native(workspace)
const configPath = join(testState.fakeHomeDir, '.codex', 'config.toml')
const runtimeConfigPath = join(
testState.userDataDir,
'codex-runtime-home',
'home',
'config.toml'
)
for (const written of [
readFileSync(configPath, 'utf-8'),
readFileSync(runtimeConfigPath, 'utf-8')
]) {
expect(written).toContain(`[projects."${escapeTomlBasicString(repositoryRoot)}"]`)
expect(written).not.toContain(`[projects."${escapeTomlBasicString(workspaceRoot)}"]`)
}
} finally {
rmSync(fixtureRoot, { recursive: true, force: true })
}
})
it('does not broaden trust through arbitrary or adversarial Git metadata', () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'orca-codex-untrusted-gitdir-'))
const workspace = join(fixtureRoot, 'workspace')
const arbitraryGitDir = join(fixtureRoot, 'metadata', 'feature')
const unrelatedRoot = join(fixtureRoot, 'unrelated')
try {
mkdirSync(arbitraryGitDir, { recursive: true })
mkdirSync(workspace, { recursive: true })
mkdirSync(unrelatedRoot, { recursive: true })
writeFileSync(join(workspace, '.git'), `gitdir: ${arbitraryGitDir}\n`, 'utf-8')
writeFileSync(join(arbitraryGitDir, 'commondir'), join(unrelatedRoot, '.git'), 'utf-8')
markCodexProjectTrusted(workspace)
const structuredGitDir = join(unrelatedRoot, '.git', 'worktrees', 'feature')
mkdirSync(structuredGitDir, { recursive: true })
writeFileSync(join(workspace, '.git'), `gitdir: ${structuredGitDir}\n`, 'utf-8')
writeFileSync(join(structuredGitDir, 'gitdir'), join(unrelatedRoot, '.git'), 'utf-8')
markCodexProjectTrusted(workspace)
const written = readFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), 'utf-8')
expect(written).toContain(
`[projects."${escapeTomlBasicString(realpathSync.native(workspace))}"]`
)
expect(written).not.toContain(
`[projects."${escapeTomlBasicString(realpathSync.native(unrelatedRoot))}"]`
)
} finally {
rmSync(fixtureRoot, { recursive: true, force: true })
}
})
it('writes ~/.codex/config.toml with the project marked trusted', () => {
const workspace = mkdtempSync(join(tmpdir(), 'orca-codex-ws-'))
try {
+38 -2
View File
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { basename, dirname, join, resolve } from 'node:path'
import { writeFileAtomically } from './codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath } from './codex/codex-home-paths'
import { upsertProjectTrustLevel } from './codex/config-toml-trust'
@@ -109,7 +109,7 @@ export function markCopilotFolderTrusted(workspacePath: string): void {
* codex-rs/core/src/config/config_tests.rs in the Codex CLI source.
*/
export function markCodexProjectTrusted(workspacePath: string): void {
const absPath = canonicalize(workspacePath)
const absPath = resolveCodexProjectTrustRoot(workspacePath)
const configPath = join(homedir(), '.codex', 'config.toml')
upsertProjectTrustLevel(configPath, absPath, 'trusted')
// Why: Orca-launched Codex runs with an Orca-owned CODEX_HOME, so the trust
@@ -117,6 +117,42 @@ export function markCodexProjectTrusted(workspacePath: string): void {
upsertProjectTrustLevel(join(getOrcaManagedCodexHomePath(), 'config.toml'), absPath, 'trusted')
}
function resolveCodexProjectTrustRoot(workspacePath: string): string {
const absPath = canonicalize(workspacePath)
try {
const gitDirReference = readFileSync(join(absPath, '.git'), 'utf-8').trim()
if (!gitDirReference.startsWith('gitdir:')) {
return absPath
}
const gitDirPath = gitDirReference.slice('gitdir:'.length).trim()
if (!gitDirPath) {
return absPath
}
const gitDir = resolve(absPath, gitDirPath)
const worktreesDir = dirname(gitDir)
if (basename(worktreesDir) !== 'worktrees') {
return absPath
}
// Why: workspace-controlled .git metadata must not broaden trust without Git's reciprocal link.
const gitDirBacklink = readFileSync(join(gitDir, 'gitdir'), 'utf-8').trim()
if (!gitDirBacklink) {
return absPath
}
const resolvedBacklink = resolve(gitDir, gitDirBacklink)
const workspaceGitFile = join(absPath, '.git')
if (
resolvedBacklink !== workspaceGitFile &&
canonicalize(resolvedBacklink) !== canonicalize(workspaceGitFile)
) {
return absPath
}
// Why: mirror Codex's validated .git/worktrees/<name> traversal instead of trusting arbitrary commondir contents.
return canonicalize(dirname(dirname(worktreesDir)))
} catch {
return absPath
}
}
function canonicalize(p: string): string {
// Why: macOS reports `/tmp/x` and `/private/tmp/x` as the same inode, but
// both Cursor and Copilot's trust comparators run realpath() before the
@@ -14,8 +14,11 @@ vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
const { getSavedRuntimeAiVaultHostInfos, scanRuntimeAiVaultSessions } =
await import('./runtime-session-scanner')
const {
getSavedRuntimeAiVaultHostInfos,
prepareRuntimeAiVaultSessionResume,
scanRuntimeAiVaultSessions
} = await import('./runtime-session-scanner')
beforeEach(() => {
vi.clearAllMocks()
@@ -108,6 +111,42 @@ describe('runtime AI Vault session scanner', () => {
})
])
})
it('prepares a resume on the transcript-owning runtime', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: true,
result: { useRealCodexHome: true }
})
const args = {
agent: 'codex' as const,
filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl',
codexHome: '/managed',
executionHostId: 'runtime:env-1' as const
}
await expect(prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', args)).resolves.toEqual({
useRealCodexHome: true
})
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'aiVault.prepareSessionResume',
args
)
})
it('fails retryably when runtime preparation returns an invalid result', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, result: {} })
await expect(
prepareRuntimeAiVaultSessionResume('/user-data', 'env-1', {
agent: 'codex',
filePath: '/managed/sessions/2026/07/20/rollout-a.jsonl',
codexHome: '/managed',
executionHostId: 'runtime:env-1'
})
).rejects.toThrow('Invalid aiVault.prepareSessionResume response')
})
})
function result(
@@ -9,6 +9,10 @@ import {
import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
export type RuntimeAiVaultHostInfo = {
environmentId: string
@@ -99,6 +103,8 @@ const aiVaultListResultSchema = z.object({
scannedAt: z.string()
})
const aiVaultPrepareSessionResumeResultSchema = z.object({ useRealCodexHome: z.boolean() })
export function getSavedRuntimeAiVaultHostInfos(
userDataPath: string
): readonly RuntimeAiVaultHostInfo[] {
@@ -151,6 +157,29 @@ export async function scanRuntimeAiVaultSessions(
})
}
export async function prepareRuntimeAiVaultSessionResume(
userDataPath: string,
environmentId: string,
args: AiVaultPrepareSessionResumeArgs
): Promise<AiVaultPrepareSessionResumeResult> {
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'aiVault.prepareSessionResume',
args
)
if (response.ok !== true) {
throw new Error(response.error.message)
}
const parsed = aiVaultPrepareSessionResumeResultSchema.safeParse(response.result)
if (!parsed.success) {
throw new Error(
`Invalid aiVault.prepareSessionResume response: ${parsed.error.issues[0]?.message ?? 'unexpected result shape'}`
)
}
return parsed.data
}
function withRuntimeExecutionHost(
result: AiVaultListResult,
executionHostId: `runtime:${string}`
+321 -25
View File
@@ -67,7 +67,8 @@ import {
// inside a try/catch. Override the private method to inject our mock.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(AgentBrowserBridge.prototype as any).getWebContents = function (id: number) {
return webContentsFromIdMock(id) ?? null
const target = webContentsFromIdMock(id)
return target && !target.isDestroyed() ? target : null
}
function mockBrowserManager(
@@ -89,10 +90,14 @@ function mockBrowserManager(
}
function mockWebContents(id: number, url = 'https://example.com', title = 'Example') {
let currentUrl = url
return {
id,
getURL: () => url,
getURL: () => currentUrl,
getTitle: () => title,
loadURL: vi.fn(async (nextUrl: string) => {
currentUrl = nextUrl
}),
isDestroyed: () => false,
invalidate: vi.fn(),
focus: vi.fn(),
@@ -308,9 +313,9 @@ describe('AgentBrowserBridge', () => {
expect(args[args.indexOf('--session') + 1]).toBe('orca-tab-tab-1')
})
// ── --cdp first-use only ──
// ── Embedded CDP ownership ──
it('passes --cdp only on first command for a session', async () => {
it('passes --cdp on every helper command family so a restarted daemon cannot launch Chrome', async () => {
succeedWith({ snapshot: '...' })
await bridge.snapshot()
@@ -322,16 +327,24 @@ describe('AgentBrowserBridge', () => {
const cdpIdx = (snapshotCall![1] as string[]).indexOf('--cdp')
expect((snapshotCall![1] as string[])[cdpIdx + 1]).toBe('9222')
succeedWith({ clicked: '@e1' })
await bridge.click('@e1')
await bridge.mouseMove(10, 20)
await bridge.setOffline('on')
await bridge.consoleLog()
await bridge.exec('get title')
const clickCall = execFileMock.mock.calls.find((c: unknown[]) =>
(c[1] as string[]).includes('click')
)
expect(clickCall![1]).not.toContain('--cdp')
for (const command of ['click', 'mouse', 'set', 'console', 'get']) {
const call = execFileMock.mock.calls.find((candidate: unknown[]) =>
(candidate[1] as string[]).includes(command)
)
expect(call).toBeDefined()
const args = call![1] as string[]
expect(args).toContain('--cdp')
expect(args[args.indexOf('--cdp') + 1]).toBe('9222')
}
})
it('continues when stale agent-browser session close hangs during session creation', async () => {
it('fails closed when stale agent-browser session ownership cannot be reset', async () => {
vi.useFakeTimers()
try {
const closeKill = vi.fn()
@@ -349,17 +362,17 @@ describe('AgentBrowserBridge', () => {
)
const promise = bridge.snapshot()
let settled = false
void promise.finally(() => {
settled = true
const rejection = expect(promise).rejects.toMatchObject({
code: 'browser_owner_unavailable',
message:
'Could not reset stale helper session orca-tab-tab-1; retry after agent-browser exits'
})
await vi.advanceTimersByTimeAsync(3_000)
await Promise.resolve()
expect(settled).toBe(true)
await expect(promise).resolves.toEqual({ browserPageId: 'tab-1', snapshot: 'ready' })
await rejection
expect(closeKill).toHaveBeenCalled()
expect(execFileMock.mock.calls.some((call) => call[1].includes('snapshot'))).toBe(false)
} finally {
vi.useRealTimers()
}
@@ -876,6 +889,8 @@ describe('AgentBrowserBridge', () => {
)
expect(routeCalls).toHaveLength(2)
expect(routeCalls.at(-1)).toContain('https://old.example/**')
expect(routeCalls.at(-1)).toContain('--cdp')
expect(routeCalls.at(-1)).toContain('9222')
})
it('clears stale sessions after direct CDP visibility re-registration', async () => {
@@ -1119,7 +1134,11 @@ describe('AgentBrowserBridge', () => {
const killedError = Object.assign(new Error('timeout'), { killed: true })
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('close')) {
cb(null, JSON.stringify({ success: true, data: null }), '')
return
}
cb(killedError, '', '')
}
)
@@ -1588,13 +1607,282 @@ describe('AgentBrowserBridge', () => {
// ── goto command ──
it('passes url to goto command', async () => {
succeedWith({ url: 'https://example.com', title: 'Example' })
await bridge.goto('https://example.com')
it('navigates the registered webContents without spawning agent-browser', async () => {
const wc = mockWebContents(100, 'https://example.com/start', 'Example')
webContentsFromIdMock.mockReturnValue(wc)
const args = execFileMock.mock.calls.at(-1)![1] as string[]
expect(args).toContain('goto')
expect(args).toContain('https://example.com')
await expect(bridge.goto('https://example.com/next')).resolves.toEqual({
url: 'https://example.com/next',
title: 'Example'
})
expect(wc.loadURL).toHaveBeenCalledWith('https://example.com/next')
expect(execFileMock).not.toHaveBeenCalled()
})
it('preserves scheme-less navigation semantics at the direct WebContents boundary', async () => {
const wc = mockWebContents(100)
webContentsFromIdMock.mockReturnValue(wc)
await expect(bridge.goto('example.com')).resolves.toEqual({
url: 'https://example.com/',
title: 'Example'
})
expect(wc.loadURL).toHaveBeenCalledWith('https://example.com/')
})
it('rejects unsupported direct navigation URLs without falling back to agent-browser', async () => {
const wc = mockWebContents(100)
webContentsFromIdMock.mockReturnValue(wc)
await expect(bridge.goto('javascript:alert(1)')).rejects.toMatchObject({
code: 'invalid_argument',
message: 'Unsupported browser URL: javascript:alert(1)'
})
expect(wc.loadURL).not.toHaveBeenCalled()
expect(execFileMock).not.toHaveBeenCalled()
})
it('fails closed and releases the command queue when direct navigation never settles', async () => {
vi.useFakeTimers()
try {
const wc = mockWebContents(100)
wc.loadURL.mockReturnValue(new Promise<void>(() => {}))
webContentsFromIdMock.mockReturnValue(wc)
const navigation = bridge.goto('https://example.com/hangs')
const rejection = expect(navigation).rejects.toMatchObject({
code: 'browser_error',
message: 'Failed to navigate browser page tab-1: Browser navigation timed out after 30000ms'
})
await vi.advanceTimersByTimeAsync(30_000)
await rejection
expect(execFileMock).not.toHaveBeenCalled()
expect(
(bridge as unknown as { commandQueues: Map<string, unknown[]> }).commandQueues.size
).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('fails closed when direct navigation is aborted', async () => {
const wc = mockWebContents(100, 'https://example.com/current', 'Current')
wc.loadURL.mockRejectedValue(
Object.assign(new Error('ERR_ABORTED (-3)'), { code: 'ERR_ABORTED' })
)
webContentsFromIdMock.mockReturnValue(wc)
await expect(bridge.goto('https://example.com/download')).rejects.toMatchObject({
code: 'browser_error',
message: 'Failed to navigate browser page tab-1: ERR_ABORTED (-3)'
})
expect(execFileMock).not.toHaveBeenCalled()
})
it.each([
{
command: 'goto',
run: (b: AgentBrowserBridge) => b.goto('https://embedded.example/next'),
helperArg: 'goto',
directMethod: 'loadURL'
},
{
command: 'evaluate',
run: (b: AgentBrowserBridge) => b.evaluate('document.title'),
helperArg: 'eval',
directMethod: 'Runtime.evaluate'
}
])(
'does not route $command to another browser when the helper session is stale',
async ({ run, helperArg, directMethod }) => {
const wc = mockWebContents(100, 'https://embedded.example/current', 'Embedded')
wc.debugger.sendCommand.mockImplementation(async (_method: string, params?: unknown) => ({
result: {
value:
(params as { expression?: string } | undefined)?.expression === 'location.origin'
? 'https://embedded.example'
: 'Embedded'
}
}))
webContentsFromIdMock.mockReturnValue(wc)
const wrongOwnerCalls: string[][] = []
let helperSessionIsStale = false
execFileMock.mockImplementation(
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('close')) {
cb(null, JSON.stringify({ success: true, data: null }), '')
} else if (args.includes('snapshot')) {
cb(null, JSON.stringify({ success: true, data: { snapshot: 'ready' } }), '')
} else {
if (helperSessionIsStale && !args.includes('--cdp')) {
wrongOwnerCalls.push(args)
}
cb(
null,
JSON.stringify({
success: true,
data: { url: 'https://external.example', title: 'External', result: 'external' }
}),
''
)
}
return { kill: vi.fn() }
}
)
await bridge.snapshot()
helperSessionIsStale = true
await run(bridge)
expect(wrongOwnerCalls).toEqual([])
expect(
execFileMock.mock.calls.some((call) => (call[1] as string[]).includes(helperArg))
).toBe(false)
if (directMethod === 'loadURL') {
expect(wc.loadURL).toHaveBeenCalledWith('https://embedded.example/next')
} else {
expect(wc.debugger.sendCommand).toHaveBeenCalledWith(
'Runtime.evaluate',
expect.objectContaining({ expression: 'document.title' })
)
}
}
)
it('returns direct evaluation value and full page URL semantics without spawning agent-browser', async () => {
const wc = mockWebContents(100, 'https://example.com/path?query=1')
wc.debugger.sendCommand.mockResolvedValue({ result: { value: 42 } })
webContentsFromIdMock.mockReturnValue(wc)
await expect(bridge.evaluate('6 * 7')).resolves.toEqual({
result: '42',
origin: 'https://example.com/path?query=1'
})
expect(execFileMock).not.toHaveBeenCalled()
})
it.each([
[{ answer: 42 }, '{"answer":42}'],
[['a', 'b'], '["a","b"]']
])('preserves structured direct evaluation values as JSON text', async (value, expected) => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockResolvedValue({ result: { value } })
webContentsFromIdMock.mockReturnValue(wc)
await expect(bridge.evaluate('structuredValue')).resolves.toMatchObject({ result: expected })
expect(execFileMock).not.toHaveBeenCalled()
})
it('surfaces direct evaluation exceptions without falling back to agent-browser', async () => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockResolvedValue({
result: { type: 'object' },
exceptionDetails: {
text: 'Uncaught',
exception: { description: 'ReferenceError: missingValue is not defined' }
}
})
webContentsFromIdMock.mockReturnValue(wc)
await expect(bridge.evaluate('missingValue')).rejects.toMatchObject({
code: 'browser_eval_error',
message: 'ReferenceError: missingValue is not defined'
})
expect(execFileMock).not.toHaveBeenCalled()
})
it('fails closed when the registered webContents debugger is stale', async () => {
const wc = mockWebContents(100)
wc.debugger.sendCommand.mockRejectedValue(new Error('Debugger is detached'))
webContentsFromIdMock.mockReturnValue(wc)
await expect(bridge.evaluate('document.title')).rejects.toMatchObject({
code: 'browser_error',
message: 'Failed to evaluate in browser page tab-1: Debugger is detached'
})
expect(execFileMock).not.toHaveBeenCalled()
})
it.each([
['goto', (b: AgentBrowserBridge) => b.goto('https://example.com/next', undefined, 'tab-1')],
['evaluate', (b: AgentBrowserBridge) => b.evaluate('document.title', undefined, 'tab-1')]
])('fails closed when direct %s targets a destroyed webContents', async (_command, run) => {
const wc = mockWebContents(100)
wc.isDestroyed = () => true
webContentsFromIdMock.mockReturnValue(wc)
const unregisterGuest = vi.fn()
const b = new AgentBrowserBridge(
mockBrowserManager(new Map([['tab-1', 100]]), new Map(), { unregisterGuest })
)
b.setActiveTab(100)
await expect(run(b)).rejects.toMatchObject({
code: 'browser_tab_not_found',
message: 'Browser page tab-1 is no longer available'
})
expect(unregisterGuest).toHaveBeenCalledWith('tab-1')
expect(execFileMock).not.toHaveBeenCalled()
})
it('returns navigation state from a replacement registered during load', async () => {
const tabs = new Map([['tab-1', 100]])
const oldWc = mockWebContents(100, 'https://example.com/start', 'Old')
const replacementWc = mockWebContents(200, 'https://example.com/final', 'Replacement')
oldWc.loadURL.mockImplementation(async () => {
tabs.set('tab-1', 200)
})
webContentsFromIdMock.mockImplementation((id: number) =>
id === 100 ? oldWc : id === 200 ? replacementWc : null
)
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
b.setActiveTab(100)
await expect(b.goto('https://example.com/next')).resolves.toEqual({
url: 'https://example.com/final',
title: 'Replacement'
})
expect(execFileMock).not.toHaveBeenCalled()
})
it('routes direct commands to the replacement registration, not the stale session owner', async () => {
const tabs = new Map([['tab-1', 100]])
const oldWc = mockWebContents(100, 'https://old.example', 'Old')
const replacementWc = mockWebContents(200, 'https://new.example', 'New')
replacementWc.debugger.sendCommand.mockImplementation(
async (_method: string, params?: unknown) => ({
result: {
value:
(params as { expression?: string } | undefined)?.expression === 'location.origin'
? 'https://new.example'
: 'New'
}
})
)
webContentsFromIdMock.mockImplementation((id: number) =>
id === 100 ? oldWc : id === 200 ? replacementWc : null
)
const b = new AgentBrowserBridge(mockBrowserManager(tabs))
b.setActiveTab(100)
succeedWith({ snapshot: 'ready' })
await b.snapshot()
tabs.set('tab-1', 200)
await b.onProcessSwap('tab-1', 200, 100)
execFileMock.mockClear()
await expect(b.evaluate('document.title', undefined, 'tab-1')).resolves.toEqual({
result: 'New',
origin: 'https://new.example'
})
expect(replacementWc.debugger.sendCommand).toHaveBeenCalledWith(
'Runtime.evaluate',
expect.objectContaining({ expression: 'document.title' })
)
expect(oldWc.debugger.sendCommand).not.toHaveBeenCalled()
expect(execFileMock).not.toHaveBeenCalled()
})
it('rejects oversized browser clipboard writes before spawning agent-browser', async () => {
@@ -2121,7 +2409,11 @@ describe('AgentBrowserBridge', () => {
it('passes stderr through as error message on execFile failure', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('close')) {
cb(null, JSON.stringify({ success: true, data: null }), '')
return
}
cb(new Error('exit code 1'), '', 'daemon crashed: segfault')
}
)
@@ -2130,7 +2422,11 @@ describe('AgentBrowserBridge', () => {
it('falls back to error.message when stderr is empty', async () => {
execFileMock.mockImplementation(
(_bin: string, _args: string[], _opts: unknown, cb: Function) => {
(_bin: string, args: string[], _opts: unknown, cb: Function) => {
if (args.includes('close')) {
cb(null, JSON.stringify({ success: true, data: null }), '')
return
}
cb(new Error('Command failed'), '', '')
}
)
+149 -20
View File
@@ -48,6 +48,7 @@ import type {
BrowserCookie
} from '../../shared/runtime-types'
import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text'
import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
import { iterateBrowserTextInsertionChunks } from './browser-text-insertion'
// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires.
@@ -55,6 +56,7 @@ const EXEC_TIMEOUT_MS = 90_000
const CONSECUTIVE_TIMEOUT_LIMIT = 3
const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000
const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000
const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000
export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024
export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES
@@ -766,9 +768,53 @@ export class AgentBrowserBridge {
}
async goto(url: string, worktreeId?: string, browserPageId?: string): Promise<BrowserGotoResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['goto', url])) as BrowserGotoResult
})
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (_sessionName, target) => {
const wc = this.requireTargetWebContents(target)
const navigationUrl = normalizeBrowserNavigationUrl(url)
if (!navigationUrl) {
throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`)
}
let navigationTimeout: ReturnType<typeof setTimeout> | null = null
try {
await Promise.race([
wc.loadURL(navigationUrl),
new Promise<never>((_resolve, reject) => {
navigationTimeout = setTimeout(
() =>
reject(
new Error(
`Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms`
)
),
EMBEDDED_NAVIGATION_TIMEOUT_MS
)
navigationTimeout.unref?.()
})
])
} catch (error) {
if (!this.getWebContents(target.webContentsId)) {
throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`)
}
throw new BrowserError(
'browser_error',
`Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
if (navigationTimeout) {
clearTimeout(navigationTimeout)
}
}
// Why: cross-process navigation can replace the guest while retaining the same authoritative page id.
const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId)
const navigatedWebContents = this.requireTargetWebContents(navigatedTarget)
return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() }
},
{ ensureSession: false }
)
}
async fill(
@@ -1403,9 +1449,62 @@ export class AgentBrowserBridge {
worktreeId?: string,
browserPageId?: string
): Promise<BrowserEvalResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['eval', expression])) as BrowserEvalResult
})
return this.enqueueTargetedCommand(
worktreeId,
browserPageId,
async (_sessionName, target) => {
const wc = this.requireTargetWebContents(target)
let releaseDebugger = (): void => {}
try {
releaseDebugger = acquireElectronDebugger(wc).release
const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true
})) as {
result: { value?: unknown; description?: string }
exceptionDetails?: { text: string; exception?: { description?: string } }
}
if (exceptionDetails) {
throw new BrowserError(
'browser_eval_error',
exceptionDetails.exception?.description ?? exceptionDetails.text
)
}
const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId)
if (currentTarget.webContentsId !== target.webContentsId) {
throw new BrowserError(
'browser_tab_changed',
`Browser page ${target.browserPageId} changed while evaluating; retry the command`
)
}
return {
result:
result.value !== undefined
? typeof result.value === 'object' && result.value !== null
? JSON.stringify(result.value)
: String(result.value)
: (result.description ?? ''),
origin: wc.getURL()
}
} catch (error) {
if (error instanceof BrowserError) {
throw error
}
if (!this.getWebContents(target.webContentsId)) {
throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`)
}
throw new BrowserError(
'browser_error',
`Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
releaseDebugger()
}
},
{ ensureSession: false }
)
}
async hover(
@@ -2283,12 +2382,9 @@ export class AgentBrowserBridge {
const managesInterceptRoutes =
commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute')
// Why: --cdp is init-only; pass the port (not a ws:// URL) so agent-browser's /json discovery sees only the proxy's webview, not the host renderer page.
const needsInit = !session.initialized
if (needsInit) {
const port = session.proxy.getPort()
args.push('--cdp', String(port))
}
// Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner.
args.push('--cdp', String(session.proxy.getPort()))
// Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits.
for (const commandArg of commandArgs) {
@@ -2323,6 +2419,8 @@ export class AgentBrowserBridge {
await this.runAgentBrowserRaw(sessionName, [
'--session',
sessionName,
'--cdp',
String(session.proxy.getPort()),
'network',
'route',
urlPattern,
@@ -2368,23 +2466,32 @@ export class AgentBrowserBridge {
}
private closeStaleAgentBrowserSession(sessionName: string): Promise<void> {
return new Promise((resolve) => {
return new Promise((resolve, reject) => {
let child: ReturnType<typeof execFile> | null = null
let settled = false
const finish = (): void => {
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
resolve()
if (error) {
reject(error)
} else {
resolve()
}
}
// Why: best-effort daemon cleanup — a wedged close must not block the real browser action.
// Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser.
const timeout = setTimeout(() => {
child?.kill()
finish()
finish(
new BrowserError(
'browser_owner_unavailable',
`Could not reset stale helper session ${sessionName}; retry after agent-browser exits`
)
)
}, STALE_SESSION_CLOSE_TIMEOUT_MS)
try {
@@ -2392,10 +2499,23 @@ export class AgentBrowserBridge {
this.agentBrowserBin,
['--session', sessionName, 'close'],
{ timeout: STALE_SESSION_CLOSE_TIMEOUT_MS },
finish
(error) =>
finish(
error
? new BrowserError(
'browser_owner_unavailable',
`Could not reset stale helper session ${sessionName}: ${error.message}`
)
: undefined
)
)
} catch (error) {
finish(
new BrowserError(
'browser_owner_unavailable',
`Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}`
)
)
} catch {
finish()
}
})
}
@@ -2525,10 +2645,19 @@ export class AgentBrowserBridge {
return null
}
private requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents {
const wc = this.getWebContents(target.webContentsId)
if (!wc || wc.isDestroyed()) {
throw this.createPageUnavailableError(`orca-tab-${target.browserPageId}`)
}
return wc
}
private getWebContents(webContentsId: number): Electron.WebContents | null {
try {
const { webContents } = require('electron')
return webContents.fromId(webContentsId) ?? null
const target = webContents.fromId(webContentsId)
return target && !target.isDestroyed() ? target : null
} catch {
return null
}
@@ -3537,6 +3537,72 @@ describe('ClaudeRuntimeAuthService', () => {
})
})
it('uses the global WSL runtime for untargeted Claude preparation under auto', async () => {
setPlatform('win32')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => null,
toWindowsWslPath: (value: string) => value
}))
const ubuntuAuthPath = createManagedClaudeAuth(
testState.userDataDir,
'ubuntu-account',
createClaudeCredentialsJson('ubuntu@example.com', 'ubuntu-token')
)
const settings = createSettings({
localAccountRuntime: 'auto',
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' },
claudeManagedAccounts: [
createClaudeAccount('ubuntu-account', ubuntuAuthPath, {
managedAuthRuntime: 'wsl',
wslDistro: 'Ubuntu',
wslLinuxAuthPath: '/home/alice/.local/share/orca/claude-accounts/ubuntu/auth'
})
],
activeClaudeManagedAccountId: null,
activeClaudeManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'ubuntu-account' }
}
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
const preparation = await service.prepareForClaudeLaunch()
expect(preparation).toMatchObject({
runtime: 'wsl',
wslDistro: 'Ubuntu',
provenance: 'managed:ubuntu-account:wsl:Ubuntu'
})
})
it('ignores a persisted WSL account-runtime pin on non-Windows hosts', async () => {
setPlatform('darwin')
vi.doMock('../wsl', () => ({
getDefaultWslDistro: () => 'Ubuntu',
getWslHome: () => null,
toWindowsWslPath: (value: string) => value
}))
const settings = createSettings({
localAccountRuntime: 'wsl',
localAccountWslDistro: 'Ubuntu'
})
const store = createStore(settings)
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
const service = new ClaudeRuntimeAuthService(store as never)
const preparation = await service.prepareForClaudeLaunch()
expect(preparation).toMatchObject({
runtime: 'host',
wslDistro: null,
provenance: 'system',
stripAuthEnv: false
})
})
it('keeps untargeted Claude preparation on host when account runtime is host', async () => {
setPlatform('win32')
vi.doMock('../wsl', () => ({
@@ -13,6 +13,7 @@ import {
writeClaudeManagedAuthFile
} from './managed-auth-path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { resolveLocalAccountRuntimeTarget } from '../../shared/local-account-runtime'
import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl'
import { buildEncodedWslBashCommand } from '../wsl-bash-command'
import { hasLiveClaudePtys } from './live-pty-gate'
@@ -683,9 +684,10 @@ export class ClaudeRuntimeAuthService {
private getDefaultAccountSelectionTarget(
settings = this.store.getSettings()
): ClaudeAccountSelectionTarget {
if (process.platform === 'win32' && settings.localAccountRuntime === 'wsl') {
// Why: auth defaults follow account runtime settings, not legacy terminal WSL settings that can outlive the Terminal UI.
return { runtime: 'wsl', wslDistro: settings.localAccountWslDistro ?? null }
// Why: Windows auth follows the resolved account runtime; stale cross-platform WSL pins must stay local-host.
const resolved = resolveLocalAccountRuntimeTarget(settings)
if (process.platform === 'win32' && resolved.runtime === 'wsl') {
return { runtime: 'wsl', wslDistro: resolved.wslDistro }
}
return { runtime: 'host' }
}
+132
View File
@@ -20,6 +20,8 @@ import { ClaudeHookService } from './hook-service'
import { OPENCLAUDE_HOOK_SETTINGS } from './hook-settings'
const CLAUDE_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh'
const STATUSLINE_SCRIPT_FILE_NAME =
process.platform === 'win32' ? 'claude-statusline.cmd' : 'claude-statusline.sh'
const OPENCLAUDE_SCRIPT_FILE_NAME =
process.platform === 'win32' ? 'openclaude-hook.cmd' : 'openclaude-hook.sh'
const WINDOWS_POWERSHELL_LAUNCHER =
@@ -181,6 +183,130 @@ describe('ClaudeHookService.install', () => {
}
})
it('installs the managed statusLine command and forwards rate_limits posts', () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-'))
vi.stubEnv('HOME', tmpHome)
vi.stubEnv('USERPROFILE', tmpHome)
try {
expect(new ClaudeHookService().install().state).toBe('installed')
const settings = JSON.parse(
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
) as { statusLine?: { type: string; command: string } }
expect(settings.statusLine?.type).toBe('command')
expect(settings.statusLine?.command).toContain('claude-statusline')
const script = readFileSync(
join(tmpHome, '.orca', 'agent-hooks', STATUSLINE_SCRIPT_FILE_NAME),
'utf-8'
)
expect(script).toContain('/statusline/claude')
// Why: non-subscriber sessions never carry rate_limits; both branches must guard before spawning curl.
if (process.platform === 'win32') {
expect(script).toContain('findstr.exe" /c:\\"rate_limits\\"')
expect(script).toContain('--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"')
} else {
expect(script).toContain('"rate_limits"')
expect(script).toContain('--data-urlencode "payload@-"')
}
} finally {
vi.unstubAllEnvs()
rmSync(tmpHome, { recursive: true, force: true })
}
})
it('never overwrites a user-owned statusLine command', () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-user-statusline-'))
vi.stubEnv('HOME', tmpHome)
vi.stubEnv('USERPROFILE', tmpHome)
try {
const settingsPath = join(tmpHome, '.claude', 'settings.json')
mkdirSync(join(tmpHome, '.claude'), { recursive: true })
writeFileSync(
settingsPath,
JSON.stringify({ statusLine: { type: 'command', command: '/usr/local/bin/my-statusline' } })
)
expect(new ClaudeHookService().install().state).toBe('installed')
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
expect(settings.statusLine).toEqual({
type: 'command',
command: '/usr/local/bin/my-statusline'
})
// remove() must also leave the user's statusLine untouched.
new ClaudeHookService().remove()
const afterRemove = JSON.parse(readFileSync(settingsPath, 'utf-8'))
expect(afterRemove.statusLine).toEqual({
type: 'command',
command: '/usr/local/bin/my-statusline'
})
} finally {
vi.unstubAllEnvs()
rmSync(tmpHome, { recursive: true, force: true })
}
})
it('removes the managed statusLine on remove()', () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-remove-'))
vi.stubEnv('HOME', tmpHome)
vi.stubEnv('USERPROFILE', tmpHome)
try {
new ClaudeHookService().install()
new ClaudeHookService().remove()
const settings = JSON.parse(readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8'))
expect(settings.statusLine).toBeUndefined()
} finally {
vi.unstubAllEnvs()
rmSync(tmpHome, { recursive: true, force: true })
}
})
it('does not re-install a managed statusLine the user deleted, until remove() resets the opt-out', () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-optout-'))
vi.stubEnv('HOME', tmpHome)
vi.stubEnv('USERPROFILE', tmpHome)
try {
const settingsPath = join(tmpHome, '.claude', 'settings.json')
new ClaudeHookService().install()
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeTruthy()
// The user deletes the managed statusLine from settings.json (e.g. via /statusline or an editor).
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
delete settings.statusLine
writeFileSync(settingsPath, JSON.stringify(settings))
// A later install (app restart) must respect the deletion — statusLine is opportunistic, not required.
new ClaudeHookService().install()
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined()
// An Orca-level remove() resets the opt-out memory, so a fresh install re-adds it.
new ClaudeHookService().remove()
new ClaudeHookService().install()
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeTruthy()
} finally {
vi.unstubAllEnvs()
rmSync(tmpHome, { recursive: true, force: true })
}
})
it('keeps refreshing a still-managed statusLine across installs', () => {
const tmpHome = mkdtempSync(join(tmpdir(), 'orca-claude-statusline-refresh-'))
vi.stubEnv('HOME', tmpHome)
vi.stubEnv('USERPROFILE', tmpHome)
try {
const settingsPath = join(tmpHome, '.claude', 'settings.json')
new ClaudeHookService().install()
new ClaudeHookService().install()
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
expect(settings.statusLine?.command).toContain('claude-statusline')
} finally {
vi.unstubAllEnvs()
rmSync(tmpHome, { recursive: true, force: true })
}
})
// Why: #6078 — Claude Code runs hooks through Git Bash, and an unquoted path
// with a space (e.g. `C:/Users/Jane Doe`) splits at the space. The managed
// command must use an encoded launcher so Git Bash/cmd.exe never splits or
@@ -279,6 +405,10 @@ describe('ClaudeHookService.installRemote', () => {
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
// Why: no remote statusLine — this path serves SSH remotes and WSL guests, whose relay
// listener doesn't route /statusline/claude and whose accounts aren't attributable locally.
expect(parsed.statusLine).toBeUndefined()
expect(fs.files.get('/home/dev/.orca/agent-hooks/claude-statusline.sh')).toBeUndefined()
})
it('reports parse error when remote settings.json cannot be parsed', async () => {
@@ -364,6 +494,8 @@ describe('OpenClaudeHookService-compatible install', () => {
expect(
readFileSync(join(tmpHome, '.orca', 'agent-hooks', OPENCLAUDE_SCRIPT_FILE_NAME), 'utf-8')
).not.toContain('DEVIN_PROJECT_DIR')
// Why: the statusline usage feed is Claude-only; OpenClaude installs must not set statusLine.
expect(parsed.statusLine).toBeUndefined()
expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false)
} finally {
vi.unstubAllEnvs()
+56 -4
View File
@@ -1,10 +1,12 @@
import { existsSync, rmSync, writeFileSync } from 'node:fs'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildWindowsAgentHookCurlPostCommand,
readHooksJson,
writeHooksJson,
writeManagedScript
writeManagedScript,
type HooksConfig
} from '../agent-hooks/installer-utils'
import {
readHooksJsonRemote,
@@ -17,8 +19,10 @@ import {
buildWindowsHookStdinDrainEpilogue,
WINDOWS_HOOK_STDIN_DRAIN_LABEL
} from '../agent-hooks/hook-stdin-contract'
import { getManagedStatusLineScript } from './statusline-script'
import {
applyManagedHooks,
applyManagedStatusLine,
CLAUDE_EVENTS,
CLAUDE_HOOK_SETTINGS,
getManagedScriptFileName,
@@ -28,7 +32,12 @@ import {
getPosixManagedScriptFileName,
getRemoteConfigPath,
getRemoteManagedCommand,
getStatusLineInstallMarkerPath,
getStatusLineScriptFileName,
getStatusLineScriptPath,
getStatusLineSlotState,
removeManagedHooks,
removeManagedStatusLine,
type ClaudeCompatibleHookSettings
} from './hook-settings'
@@ -175,7 +184,7 @@ export class ClaudeHookService {
}
const command = getManagedCommand(scriptPath)
const nextConfig = applyManagedHooks(
let nextConfig = applyManagedHooks(
config,
command,
getManagedScriptFileName(this.options.settings)
@@ -184,10 +193,38 @@ export class ClaudeHookService {
scriptPath,
getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
)
// Why: the statusline usage feed is Claude-only — OpenClaude data would be misattributed to the Claude provider.
if (this.options.agent === 'claude') {
nextConfig = this.installManagedStatusLine(nextConfig)
}
writeHooksJson(configPath, nextConfig)
return this.getStatus()
}
// Why: the statusline feed is opportunistic (usage display, not agent status); a user who deleted the
// managed entry has opted out, and the marker distinguishes that deletion from a first install.
private installManagedStatusLine(config: HooksConfig): HooksConfig {
const scriptFileName = getStatusLineScriptFileName(this.options.settings)
const markerPath = getStatusLineInstallMarkerPath(this.options.settings)
const slot = getStatusLineSlotState(config, scriptFileName)
if (slot === 'user' || (slot === 'empty' && existsSync(markerPath))) {
return config
}
const statusLineScriptPath = getStatusLineScriptPath(this.options.settings)
writeManagedScript(statusLineScriptPath, getManagedStatusLineScript('local'))
const next = applyManagedStatusLine(
config,
getManagedCommand(statusLineScriptPath),
scriptFileName
)
try {
writeFileSync(markerPath, '')
} catch {
// Best-effort: a missing marker only means one future user deletion gets re-installed once.
}
return next
}
// Why: install the Claude hook on the remote box (via SFTP); POSIX-only by design (Windows-remote deferred).
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
// Why: remote-Windows is out of scope; ship POSIX paths. process.platform here is the local box, not the remote, so it can't gate this.
@@ -218,6 +255,9 @@ export class ClaudeHookService {
remoteScriptPath,
getManagedScript('posix', { skipWhenDevinImportsClaude: this.options.agent === 'claude' })
)
// Why: no statusline install here — this path serves SSH remotes and WSL guests, whose relay hook
// listener doesn't route /statusline/claude, and an SSH box's Claude login can be a different
// account than the locally selected one, so its usage must not feed the local bar (live feed is host-local only).
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig)
return {
@@ -250,13 +290,25 @@ export class ClaudeHookService {
detail: `Could not parse ${this.options.displayName} settings.json`
}
}
const { config: nextConfig, changed } = removeManagedHooks(
const { config: hooksRemoved, changed: hooksChanged } = removeManagedHooks(
config,
getManagedScriptFileName(this.options.settings)
)
if (changed) {
const { config: nextConfig, changed: statusLineChanged } = removeManagedStatusLine(
hooksRemoved,
getStatusLineScriptFileName(this.options.settings)
)
if (hooksChanged || statusLineChanged) {
writeHooksJson(configPath, nextConfig)
}
if (this.options.agent === 'claude') {
try {
// Why: an Orca-level uninstall resets the opt-out memory so a later re-enable installs the statusline again.
rmSync(getStatusLineInstallMarkerPath(this.options.settings), { force: true })
} catch {
// ignore — marker cleanup is best-effort
}
}
return this.getStatus()
}
}
+71
View File
@@ -4,6 +4,7 @@ import {
buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
isPlainObject,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsGitBashHookCommand,
@@ -64,6 +65,24 @@ export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string {
return join(homedir(), settings.configDirName, 'settings.json')
}
export function getStatusLineScriptBaseName(settings = CLAUDE_HOOK_SETTINGS): string {
return settings.scriptBaseName.replace(/-hook$/, '-statusline')
}
export function getStatusLineScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string {
return process.platform === 'win32'
? `${getStatusLineScriptBaseName(settings)}.cmd`
: getPosixStatusLineScriptFileName(settings)
}
export function getPosixStatusLineScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string {
return `${getStatusLineScriptBaseName(settings)}.sh`
}
export function getStatusLineScriptPath(settings = CLAUDE_HOOK_SETTINGS): string {
return getSharedManagedScriptPath(getStatusLineScriptFileName(settings))
}
export function getManagedScriptFileName(settings = CLAUDE_HOOK_SETTINGS): string {
return process.platform === 'win32'
? `${settings.scriptBaseName}.cmd`
@@ -113,6 +132,58 @@ export function applyManagedHooks(
return { ...config, hooks: nextHooks }
}
export type StatusLineSlotState = 'managed' | 'user' | 'empty'
// Why: install policy needs "user owns the slot" vs "slot is empty" vs "ours" — an empty slot
// after a prior install means the user deleted the managed entry, which install must respect.
export function getStatusLineSlotState(
config: HooksConfig,
scriptFileName = getStatusLineScriptFileName()
): StatusLineSlotState {
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
const current = config.statusLine
const currentCommand =
isPlainObject(current) && typeof current.command === 'string' ? current.command : null
if (!currentCommand) {
return 'empty'
}
return isManagedCommand(currentCommand) ? 'managed' : 'user'
}
// Why: records that the managed statusline was installed once, so a later empty slot reads as user opt-out.
export function getStatusLineInstallMarkerPath(settings = CLAUDE_HOOK_SETTINGS): string {
return getSharedManagedScriptPath(`${getStatusLineScriptBaseName(settings)}.installed`)
}
// Why: statusLine is a single settings slot, not a hooks array — never overwrite a
// user-owned status line; the usage feed then simply falls back to the OAuth poll.
export function applyManagedStatusLine(
config: HooksConfig,
command: string,
scriptFileName = getStatusLineScriptFileName()
): HooksConfig {
if (getStatusLineSlotState(config, scriptFileName) === 'user') {
return config
}
return { ...config, statusLine: { type: 'command', command } }
}
export function removeManagedStatusLine(
config: HooksConfig,
scriptFileName = getStatusLineScriptFileName()
): { config: HooksConfig; changed: boolean } {
const isManagedCommand = createManagedCommandMatcher(scriptFileName)
const current = config.statusLine
const currentCommand =
isPlainObject(current) && typeof current.command === 'string' ? current.command : null
if (!currentCommand || !isManagedCommand(currentCommand)) {
return { config, changed: false }
}
const next = { ...config }
delete next.statusLine
return { config: next, changed: true }
}
export function removeManagedHooks(
config: HooksConfig,
scriptFileName = getManagedScriptFileName()
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from 'vitest'
import { getManagedStatusLineScript } from './statusline-script'
const ORIGINAL_PLATFORM = process.platform
function stubPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
}
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: ORIGINAL_PLATFORM })
})
describe('getManagedStatusLineScript (posix)', () => {
it('guards on rate_limits before sourcing the endpoint or spawning curl', () => {
stubPlatform('darwin')
const script = getManagedStatusLineScript('local')
expect(script).toBe(getManagedStatusLineScript('posix'))
const guardIndex = script.indexOf('*\'"rate_limits"\'*')
const endpointIndex = script.indexOf('ORCA_AGENT_HOOK_ENDPOINT')
const curlIndex = script.indexOf('curl -sS')
expect(guardIndex).toBeGreaterThan(-1)
expect(guardIndex).toBeLessThan(endpointIndex)
expect(endpointIndex).toBeLessThan(curlIndex)
expect(script).toContain('/statusline/claude')
expect(script).toContain('--data-urlencode "payload@-"')
})
it('returns the posix script even on win32 when targeting a remote', () => {
stubPlatform('win32')
const script = getManagedStatusLineScript('posix')
expect(script).toContain('#!/bin/sh')
expect(script).not.toContain('curl.exe')
})
})
describe('getManagedStatusLineScript (win32 local)', () => {
it('guards on rate_limits via findstr before the endpoint call and curl spawn', () => {
stubPlatform('win32')
const script = getManagedStatusLineScript('local')
const captureIndex = script.indexOf('more.com')
// Why: the \"-escaped needle makes findstr match the quoted JSON key, not any path containing rate_limits.
const guardIndex = script.indexOf('findstr.exe" /c:\\"rate_limits\\"')
const endpointIndex = script.indexOf('call "%ORCA_AGENT_HOOK_ENDPOINT%"')
const curlIndex = script.indexOf('curl.exe')
expect(captureIndex).toBeGreaterThan(-1)
expect(guardIndex).toBeGreaterThan(captureIndex)
expect(guardIndex).toBeLessThan(endpointIndex)
expect(endpointIndex).toBeLessThan(curlIndex)
expect(script).toContain('if errorlevel 1 goto :orca_statusline_cleanup')
})
it('posts the buffered payload file and deletes it afterwards', () => {
stubPlatform('win32')
const script = getManagedStatusLineScript('local')
// Why: the temp file is per-pane (pane key with ":" mapped to "_") because %RANDOM%
// collides across cmd instances spawned in the same second.
expect(script).toContain(
'set "ORCA_STATUSLINE_PAYLOAD_FILE=%TEMP%\\orca-claude-statusline-%ORCA_PANE_KEY::=_%.tmp"'
)
expect(script).toContain('--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"')
expect(script).not.toContain('payload@-')
const curlIndex = script.indexOf('curl.exe')
const delIndex = script.indexOf('del "%ORCA_STATUSLINE_PAYLOAD_FILE%"')
expect(delIndex).toBeGreaterThan(curlIndex)
})
it('never posts a literal %CLAUDE_CONFIG_DIR% token when the var is unset', () => {
stubPlatform('win32')
const script = getManagedStatusLineScript('local')
// Why: the posted field comes from an always-defined variable so an unset
// CLAUDE_CONFIG_DIR yields "configDir=" (matching POSIX + the null snapshot).
expect(script).toContain('set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir="')
expect(script).toContain(
'if defined CLAUDE_CONFIG_DIR set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir=%CLAUDE_CONFIG_DIR%"'
)
expect(script).toContain('--data-urlencode "%ORCA_STATUSLINE_CONFIG_DIR_FIELD%"')
expect(script).not.toContain('"configDir=%CLAUDE_CONFIG_DIR%"')
})
it('drains stdin before exiting when the pane key is missing', () => {
stubPlatform('win32')
const script = getManagedStatusLineScript('local')
const paneGuardIndex = script.indexOf(
'if "%ORCA_PANE_KEY%"=="" goto :orca_agent_hook_drain_stdin'
)
const captureIndex = script.indexOf('more.com')
expect(paneGuardIndex).toBeGreaterThan(-1)
expect(paneGuardIndex).toBeLessThan(captureIndex)
expect(script).toContain(':orca_agent_hook_drain_stdin')
})
})
+85
View File
@@ -0,0 +1,85 @@
import {
buildPosixHookPayloadCapture,
buildWindowsHookStdinDrainEpilogue,
WINDOWS_HOOK_STDIN_DRAIN_LABEL,
WINDOWS_HOOK_STDIN_READER
} from '../agent-hooks/hook-stdin-contract'
import { CLAUDE_STATUSLINE_PATHNAME } from '../../shared/claude-statusline-rate-limits'
const STATUSLINE_CLEANUP_LABEL = 'orca_statusline_cleanup'
// Why: Claude Code pipes `rate_limits` to the statusLine command on every turn; forwarding
// it gives Orca live usage without spending the OAuth usage endpoint's tight budget.
// Emits no stdout so the in-terminal status line stays visually unchanged.
export function getManagedStatusLineScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
// Why: pane key is static PTY env (the endpoint file never sets it), so it can gate before stdin is consumed.
`if "%ORCA_PANE_KEY%"=="" goto :${WINDOWS_HOOK_STDIN_DRAIN_LABEL}`,
// Why: cmd has no builtin stdin capture, so buffer the payload in a per-pane temp file
// (%RANDOM% collides across same-second cmd spawns) to guard before any curl spawn.
'set "ORCA_STATUSLINE_PAYLOAD_FILE=%TEMP%\\orca-claude-statusline-%ORCA_PANE_KEY::=_%.tmp"',
`${WINDOWS_HOOK_STDIN_READER} >"%ORCA_STATUSLINE_PAYLOAD_FILE%" 2>nul`,
// Why: rate_limits appears only for Claude.ai-subscriber sessions after the first API response; the
// statusline ticks ~3x/sec during streaming, so skip the endpoint call and curl spawn otherwise.
// Why: \" is the MSVC argv escape — findstr sees the quoted JSON key, so a cwd containing rate_limits can't false-match (POSIX guard parity).
'"%SystemRoot%\\System32\\findstr.exe" /c:\\"rate_limits\\" "%ORCA_STATUSLINE_PAYLOAD_FILE%" >nul 2>nul',
`if errorlevel 1 goto :${STATUSLINE_CLEANUP_LABEL}`,
// Why: call the endpoint file to refresh port/token — a PTY that survived an Orca restart carries stale env; falls through to PTY env if missing.
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
`if "%ORCA_AGENT_HOOK_PORT%"=="" goto :${STATUSLINE_CLEANUP_LABEL}`,
`if "%ORCA_AGENT_HOOK_TOKEN%"=="" goto :${STATUSLINE_CLEANUP_LABEL}`,
// Why: pre-build the field from an always-defined variable so an unset CLAUDE_CONFIG_DIR posts
// empty (matching POSIX and the null attribution snapshot), never a literal %VAR% token.
'set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir="',
'if defined CLAUDE_CONFIG_DIR set "ORCA_STATUSLINE_CONFIG_DIR_FIELD=configDir=%CLAUDE_CONFIG_DIR%"',
[
'"%SystemRoot%\\System32\\curl.exe" -sS -X POST',
`"http://127.0.0.1:%ORCA_AGENT_HOOK_PORT%${CLAUDE_STATUSLINE_PATHNAME}"`,
'--connect-timeout 0.5 --max-time 1.5',
'-H "Content-Type: application/x-www-form-urlencoded"',
'-H "X-Orca-Agent-Hook-Token: %ORCA_AGENT_HOOK_TOKEN%"',
'--data-urlencode "paneKey=%ORCA_PANE_KEY%"',
'--data-urlencode "%ORCA_STATUSLINE_CONFIG_DIR_FIELD%"',
'--data-urlencode "env=%ORCA_AGENT_HOOK_ENV%"',
'--data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%"',
'--data-urlencode "payload@%ORCA_STATUSLINE_PAYLOAD_FILE%"',
'>nul 2>&1'
].join(' '),
`:${STATUSLINE_CLEANUP_LABEL}`,
'del "%ORCA_STATUSLINE_PAYLOAD_FILE%" >nul 2>nul',
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
''
].join('\r\n')
}
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
// Why: rate_limits appears only for Claude.ai-subscriber sessions after the first API response; skip the post (and its curl spawn) otherwise.
'case "$payload" in',
' *\'"rate_limits"\'*) ;;',
' *) exit 0 ;;',
'esac',
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' exit 0',
'fi',
`printf '%s' "$payload" | curl -sS -X POST "http://127.0.0.1:\${ORCA_AGENT_HOOK_PORT}${CLAUDE_STATUSLINE_PATHNAME}" \\`,
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "configDir=${CLAUDE_CONFIG_DIR}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
}
+208 -4
View File
@@ -14,7 +14,7 @@ import {
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import type { GlobalSettings } from '../../shared/types'
import type { CodexRateLimitAccountsState, GlobalSettings } from '../../shared/types'
import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command'
import type { readHookTrustEntries as ReadHookTrustEntries } from '../codex/config-toml-trust'
@@ -938,6 +938,179 @@ describe('CodexAccountService config sync', () => {
warnSpy.mockRestore()
})
it.each([
{
label: 'the active account',
accountId: 'account-1',
outcome: 'success',
expectedActiveAccountId: 'account-1',
expectedUpdateCount: 2
},
{
label: 'a different account',
accountId: 'account-2',
outcome: 'success',
expectedActiveAccountId: 'account-1',
expectedUpdateCount: 2
},
{
label: 'a login that fails',
accountId: 'account-1',
outcome: 'login-failure',
expectedActiveAccountId: null,
expectedUpdateCount: 1
},
{
label: 'credentials rejected by runtime validation',
accountId: 'account-1',
outcome: 'runtime-validation-failure',
expectedActiveAccountId: null,
expectedUpdateCount: 3
}
])('keeps host selection semantics when reauthenticating $label', async (testCase) => {
vi.resetModules()
const hostAccounts = ['account-1', 'account-2'].map((id, index) => ({
id,
email: `${id}@example.com`,
managedHomePath: createManagedHome(
testState.userDataDir,
id,
'',
createCodexAuthJson(`${id}@example.com`, `provider-${id}`, `refresh-${id}`)
),
providerAccountId: `provider-${id}`,
workspaceLabel: null,
workspaceAccountId: `provider-${id}`,
createdAt: index + 1,
updatedAt: index + 1,
lastAuthenticatedAt: index + 1
}))
const wslAccount = {
id: 'account-wsl',
email: 'account-wsl@example.com',
managedHomePath: createManagedHome(
testState.userDataDir,
'account-wsl',
'',
createCodexAuthJson('account-wsl@example.com', 'provider-wsl', 'refresh-wsl')
),
managedHomeRuntime: 'wsl' as const,
wslDistro: 'Ubuntu',
wslLinuxHomePath: '/home/test/.local/share/orca/codex-accounts/account-wsl/home',
providerAccountId: 'provider-wsl',
workspaceLabel: null,
workspaceAccountId: 'provider-wsl',
createdAt: 3,
updatedAt: 3,
lastAuthenticatedAt: 3
}
const settings = createSettings({
codexManagedAccounts: [...hostAccounts, wslAccount],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: {
host: 'account-1',
wsl: { Ubuntu: 'account-wsl' }
}
})
const store = createStore(settings)
const runtimeHome = createRuntimeHome()
const spawnMock = vi.fn(
(_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => {
const child = new EventEmitter() as EventEmitter & {
stdout: PassThrough
stderr: PassThrough
kill: () => void
}
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.kill = vi.fn()
const current = store.getSettings()
store.updateSettings({
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: {
...current.activeCodexManagedAccountIdsByRuntime!,
host: null,
wsl: { Ubuntu: null }
}
})
if (testCase.outcome === 'login-failure') {
queueMicrotask(() => child.emit('close', 1))
return child
}
writeFileSync(
join(options.env.CODEX_HOME!, 'auth.json'),
createCodexAuthJson('reauthenticated@example.com', 'provider-new', 'refresh-new'),
'utf-8'
)
queueMicrotask(() => child.emit('close', 0))
return child
}
)
vi.doMock('node:child_process', () => ({
execFileSync: vi.fn(),
spawn: spawnMock
}))
vi.doMock('../codex-cli/command', () => ({
resolveCodexCommand: () => 'codex'
}))
runtimeHome.syncForCurrentSelection.mockImplementation(() => {
if (testCase.outcome !== 'runtime-validation-failure') {
return
}
const current = store.getSettings()
store.updateSettings({
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: {
...current.activeCodexManagedAccountIdsByRuntime!,
host: null
}
})
})
const rateLimits = createRateLimits()
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
)
let result: CodexRateLimitAccountsState | null = null
if (testCase.outcome === 'login-failure') {
await expect(service.reauthenticateAccount(testCase.accountId)).rejects.toThrow(
'Codex login exited with code 1.'
)
} else {
result = await service.reauthenticateAccount(testCase.accountId)
}
expect(result?.activeAccountId ?? null).toBe(testCase.expectedActiveAccountId)
if (result) {
expect(result.activeAccountIdsByRuntime).toEqual({
host: testCase.expectedActiveAccountId,
wsl: { Ubuntu: null }
})
}
expect(store.getSettings()).toMatchObject({
activeCodexManagedAccountId: testCase.expectedActiveAccountId,
activeCodexManagedAccountIdsByRuntime: {
host: testCase.expectedActiveAccountId,
wsl: { Ubuntu: null }
}
})
const completedLogin = testCase.outcome !== 'login-failure'
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(completedLogin ? 1 : 0)
if (completedLogin) {
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({ runtime: 'host' })
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledWith(undefined, {
runtime: 'host'
})
}
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(completedLogin ? 1 : 0)
expect(store.updateSettings).toHaveBeenCalledTimes(testCase.expectedUpdateCount)
})
it('does not recreate a missing managed home at a different account path', async () => {
vi.resetModules()
const managedHomePath = join(testState.userDataDir, 'codex-accounts', 'other-account', 'home')
@@ -1252,6 +1425,7 @@ describe('CodexAccountService config sync', () => {
}
return ''
})
let clearSelectionDuringLogin = (): void => {}
const spawnMock = vi.fn((command: string, args: string[]) => {
expect(command).toBe('wsl.exe')
expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath))
@@ -1263,6 +1437,7 @@ describe('CodexAccountService config sync', () => {
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.kill = vi.fn()
clearSelectionDuringLogin()
writeFileSync(
join(wslManagedHomePath, 'auth.json'),
JSON.stringify({
@@ -1307,9 +1482,22 @@ describe('CodexAccountService config sync', () => {
lastAuthenticatedAt: 1
}
],
activeCodexManagedAccountId: 'account-1'
activeCodexManagedAccountId: null,
activeCodexManagedAccountIdsByRuntime: {
host: null,
wsl: { Ubuntu: 'account-1' }
}
})
const store = createStore(settings)
clearSelectionDuringLogin = () => {
const current = store.getSettings()
store.updateSettings({
activeCodexManagedAccountIdsByRuntime: {
...current.activeCodexManagedAccountIdsByRuntime!,
wsl: { Ubuntu: null }
}
})
}
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
@@ -1328,7 +1516,20 @@ describe('CodexAccountService config sync', () => {
managedHomeRuntime: 'wsl',
wslDistro: 'Ubuntu'
})
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled()
expect(result.activeAccountId).toBe(null)
expect(result.activeAccountIdsByRuntime).toEqual({
host: null,
wsl: { Ubuntu: 'account-1' }
})
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledWith({
runtime: 'wsl',
wslDistro: 'Ubuntu'
})
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledWith(undefined, {
runtime: 'wsl',
wslDistro: 'Ubuntu'
})
expect(store.updateSettings).toHaveBeenCalledTimes(2)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
@@ -1555,12 +1756,14 @@ describe('CodexAccountService config sync', () => {
const store = createStore(settings)
const rateLimits = createRateLimits()
const runtimeHome = createRuntimeHome()
const onHostSystemDefaultSelected = vi.fn()
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
rateLimits as never,
runtimeHome as never
runtimeHome as never,
{ onHostSystemDefaultSelected }
)
const result = await service.selectAccount(null)
@@ -1568,6 +1771,7 @@ describe('CodexAccountService config sync', () => {
expect(result.activeAccountId).toBe(null)
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalled()
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalled()
expect(onHostSystemDefaultSelected).toHaveBeenCalledOnce()
})
it('selectAccount immediately rewrites the shared runtime auth for existing terminals', async () => {
+34 -7
View File
@@ -79,6 +79,10 @@ export type CodexAccountAddTarget = {
wslDistro?: string | null
}
export type CodexAccountServiceLifecycle = {
onHostSystemDefaultSelected?: () => void
}
type ManagedHomeLocation = {
managedHomePath: string
managedHomeRuntime: 'host' | 'wsl'
@@ -158,7 +162,8 @@ export class CodexAccountService {
constructor(
private readonly store: Store,
private readonly rateLimits: RateLimitService,
private readonly runtimeHome: CodexRuntimeHomeService
private readonly runtimeHome: CodexRuntimeHomeService,
private readonly lifecycle: CodexAccountServiceLifecycle = {}
) {
this.safeSyncCanonicalConfigToManagedHomes()
}
@@ -258,6 +263,11 @@ export class CodexAccountService {
private async doReauthenticateAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
const account = this.requireAccount(accountId)
const managedHomePath = this.ensureManagedHomeForReauthentication(account)
const accountTarget = getCodexSelectionTargetForAccount(account)
const selectedAccountId = getSelectedCodexAccountIdForTarget(
this.store.getSettings(),
accountTarget
)
this.safeSyncCanonicalConfigIntoManagedHome(managedHomePath, undefined, account.id)
await this.runCodexLogin(managedHomePath)
@@ -281,19 +291,24 @@ export class CodexAccountService {
}
: entry
)
const activeSelection = setSelectedCodexAccountIdForTarget(
normalizeCodexRuntimeSelection(settings),
selectedAccountId,
accountTarget
)
// Why: login can transiently clear this runtime's selection; unrelated runtime validation must remain authoritative.
this.store.updateSettings({
codexManagedAccounts: updatedAccounts
codexManagedAccounts: updatedAccounts,
activeCodexManagedAccountId: activeSelection.host,
activeCodexManagedAccountIdsByRuntime: activeSelection
})
this.safeSyncCanonicalConfigToManagedHomes()
this.runtimeHome.clearLastWrittenAuthJson(accountId)
this.runtimeHome.syncForCurrentSelection(getCodexSelectionTargetForAccount(account))
this.runtimeHome.syncForCurrentSelection(accountTarget)
// Why: re-auth can change the underlying Codex identity, so force a fresh read to avoid showing stale quota.
await this.rateLimits.refreshForCodexAccountChange(
undefined,
getCodexSelectionTargetForAccount(account)
)
await this.rateLimits.refreshForCodexAccountChange(undefined, accountTarget)
return this.getSnapshot()
}
@@ -314,6 +329,9 @@ export class CodexAccountService {
activeCodexManagedAccountIdsByRuntime: nextSelection
})
this.runtimeHome.syncForCurrentSelection()
if (account.managedHomeRuntime === 'host' && nextSelection.host === null) {
this.lifecycle.onHostSystemDefaultSelected?.()
}
this.safeRemoveManagedHome(account.managedHomePath, account.id)
// Why: a removed account can no longer appear in the switcher dropdown,
@@ -361,6 +379,12 @@ export class CodexAccountService {
})
this.safeSyncCanonicalConfigToManagedHomes()
this.runtimeHome.syncForCurrentSelection(effectiveTarget)
if (
accountId === null &&
normalizeCodexAccountSelectionTarget(effectiveTarget).runtime === 'host'
) {
this.lifecycle.onHostSystemDefaultSelected?.()
}
await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, effectiveTarget)
return this.getSnapshot()
@@ -510,6 +534,9 @@ export class CodexAccountService {
activeCodexManagedAccountId: nextSelection.host,
activeCodexManagedAccountIdsByRuntime: nextSelection
})
if (selection.host !== null && nextSelection.host === null) {
this.lifecycle.onHostSystemDefaultSelected?.()
}
}
}
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { prepareLegacySharedCodexSessionResume } from './codex-legacy-session-resume'
describe('prepareLegacySharedCodexSessionResume', () => {
let root: string
let legacyHome: string
let systemHome: string
let rolloutPath: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-legacy-codex-resume-'))
legacyHome = join(root, 'codex-runtime-home', 'home')
systemHome = join(root, 'real-codex-home')
rolloutPath = join(
legacyHome,
'sessions',
'2026',
'07',
'20',
'rollout-2026-07-20T12-00-00-session.jsonl'
)
mkdirSync(join(rolloutPath, '..'), { recursive: true })
writeFileSync(rolloutPath, '{"type":"session_meta"}\n', 'utf-8')
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
it('materializes an immediate legacy resume into the real home', async () => {
const result = await prepare()
const targetPath = targetRolloutPath()
expect(result).toEqual({ useRealCodexHome: true })
expect(readFileSync(targetPath, 'utf-8')).toBe('{"type":"session_meta"}\n')
expect(statSync(targetPath).ino).toBe(statSync(rolloutPath).ino)
})
it('materializes a compressed legacy rollout without changing its representation', async () => {
const compressedPath = `${rolloutPath}.zst`
rmSync(rolloutPath)
rolloutPath = compressedPath
writeFileSync(rolloutPath, 'compressed-rollout', 'utf-8')
await expect(prepare()).resolves.toEqual({ useRealCodexHome: true })
expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('compressed-rollout')
})
it('coalesces concurrent resumes of the same legacy rollout', async () => {
const [first, second] = await Promise.all([prepare(), prepare()])
expect(first.useRealCodexHome).toBe(true)
expect(second.useRealCodexHome).toBe(true)
expect(readFileSync(targetRolloutPath(), 'utf-8')).toContain('session_meta')
})
it('fails clearly and retryably instead of approving a missing rollout', async () => {
rmSync(rolloutPath)
await expect(prepare()).rejects.toThrow(/Retry resume/)
expect(existsSync(targetRolloutPath())).toBe(false)
})
it('fails closed when a different rollout already owns the target path', async () => {
mkdirSync(join(targetRolloutPath(), '..'), { recursive: true })
writeFileSync(targetRolloutPath(), '{"different":true}\n', 'utf-8')
await expect(prepare()).rejects.toThrow(/Retry resume/)
expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('{"different":true}\n')
})
it.each([
['per-account home', join(rootPlaceholder(), 'codex-accounts', 'account-1', 'home'), 'local'],
['custom home', join(rootPlaceholder(), 'custom-codex-home'), 'local'],
['WSL home', '\\\\wsl.localhost\\Ubuntu\\home\\me\\.codex', 'local'],
['SSH session', rootPlaceholder(), 'ssh:server-1']
])(
'preserves %s without materializing it',
async (_label, codexHomeTemplate, executionHostId) => {
const codexHome = codexHomeTemplate.replace(rootPlaceholder(), root)
const result = await prepareLegacySharedCodexSessionResume(
{
agent: 'codex',
filePath: rolloutPath,
codexHome,
executionHostId: executionHostId as 'local'
},
options()
)
expect(result).toEqual({ useRealCodexHome: false })
expect(existsSync(targetRolloutPath())).toBe(false)
}
)
it.each(['managed account', 'custom CODEX_HOME'])(
'preserves the legacy home while the %s lane is selected',
async () => {
const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), {
...options(),
isHostSystemDefaultRealHome: () => false
})
expect(result).toEqual({ useRealCodexHome: false })
expect(existsSync(targetRolloutPath())).toBe(false)
}
)
function prepare() {
return prepareLegacySharedCodexSessionResume(legacyArgs(), options())
}
function legacyArgs() {
return {
agent: 'codex' as const,
filePath: rolloutPath,
codexHome: legacyHome,
executionHostId: 'local' as const
}
}
function options() {
return {
isHostSystemDefaultRealHome: () => true,
legacyCodexHomePath: legacyHome,
systemCodexHomePath: systemHome
}
}
function targetRolloutPath(): string {
return join(systemHome, 'sessions', '2026', '07', '20', basename(rolloutPath))
}
})
function rootPlaceholder(): string {
return '__ROOT__'
}
@@ -0,0 +1,175 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { link, lstat, mkdir } from 'node:fs/promises'
import { dirname, join, relative, resolve, sep } from 'node:path'
import type {
AiVaultPrepareSessionResumeArgs,
AiVaultPrepareSessionResumeResult
} from '../../shared/ai-vault-resume-preparation'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import {
appendCodexSessionHealAuditRecord,
createCodexSessionBackfillAuditWriter
} from './codex-session-backfill-audit'
import {
copySessionFileWithoutOverwrite,
isAtomicNoReplaceUnsupportedError
} from './codex-session-backfill-copy'
import { resolveCodexSessionBackfillPaths } from './codex-session-backfill'
const RETRYABLE_RESUME_ERROR =
'Orca could not safely move this legacy Codex session into your system Codex home. Retry resume; if it still fails, check that both Codex session folders are readable and writable.'
const materializations = new Map<string, Promise<void>>()
export async function prepareLegacySharedCodexSessionResume(
args: AiVaultPrepareSessionResumeArgs,
options: {
isHostSystemDefaultRealHome: () => boolean
legacyCodexHomePath?: string
systemCodexHomePath?: string
}
): Promise<AiVaultPrepareSessionResumeResult> {
const paths = resolveCodexSessionBackfillPaths(options.systemCodexHomePath)
const legacyCodexHomePath = options.legacyCodexHomePath ?? dirname(paths.managedSessionsRoot)
const managedSessionsRoot = join(legacyCodexHomePath, 'sessions')
if (
args.agent !== 'codex' ||
args.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
!args.codexHome ||
!sameRuntimePath(args.codexHome, legacyCodexHomePath) ||
!options.isHostSystemDefaultRealHome()
) {
return { useRealCodexHome: false }
}
const sourcePath = resolve(args.filePath)
const relativePath = relative(resolve(managedSessionsRoot), sourcePath)
if (!isLegacyRolloutRelativePath(relativePath)) {
throw new Error(RETRYABLE_RESUME_ERROR)
}
const targetPath = join(paths.systemSessionsRoot, relativePath)
const key = `${normalizeRuntimePathForComparison(sourcePath)}\0${normalizeRuntimePathForComparison(targetPath)}`
let task = materializations.get(key)
if (!task) {
task = materializeLegacyRollout(sourcePath, targetPath, paths.auditLogPath)
materializations.set(key, task)
void task.then(
() => {
if (materializations.get(key) === task) {
materializations.delete(key)
}
},
() => {
if (materializations.get(key) === task) {
materializations.delete(key)
}
}
)
}
try {
await task
} catch (error) {
console.warn('[codex-legacy-session-resume] Targeted session migration failed:', error)
throw new Error(RETRYABLE_RESUME_ERROR, { cause: error })
}
return { useRealCodexHome: true }
}
async function materializeLegacyRollout(
sourcePath: string,
targetPath: string,
auditLogPath: string
): Promise<void> {
const sourceStat = await lstat(sourcePath)
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw new Error('Legacy rollout source is not a regular file.')
}
await mkdir(dirname(targetPath), { recursive: true })
try {
await link(sourcePath, targetPath)
} catch (linkError) {
if (isExistsError(linkError)) {
await assertMatchingExistingTarget(sourcePath, targetPath)
} else {
try {
await copySessionFileWithoutOverwrite(sourcePath, targetPath)
} catch (copyError) {
if (isExistsError(copyError)) {
await assertMatchingExistingTarget(sourcePath, targetPath)
} else {
if (isAtomicNoReplaceUnsupportedError(copyError)) {
throw new Error('The target filesystem cannot safely install this rollout.', {
cause: copyError
})
}
throw copyError
}
}
}
}
const summary = {
stopped: false,
scannedFiles: 1,
linkedFiles: 0,
copiedFiles: 0,
skippedExistingFiles: 0,
skippedUnexpectedFiles: 0,
skippedSymlinkFiles: 0,
skippedUnsupportedFilesystemFiles: 0,
failedDirectories: 0,
failedFiles: 0,
failedHealAuditRecords: 0
}
await appendCodexSessionHealAuditRecord(
createCodexSessionBackfillAuditWriter(auditLogPath),
summary,
{ action: 'targeted-resume', source: sourcePath, target: targetPath }
)
}
async function assertMatchingExistingTarget(sourcePath: string, targetPath: string): Promise<void> {
const [sourceStat, targetStat] = await Promise.all([lstat(sourcePath), lstat(targetPath)])
if (
!targetStat.isFile() ||
targetStat.isSymbolicLink() ||
sourceStat.size !== targetStat.size ||
((sourceStat.dev !== targetStat.dev || sourceStat.ino !== targetStat.ino) &&
(await fileDigest(sourcePath)) !== (await fileDigest(targetPath)))
) {
throw new Error('A different rollout already occupies the real-home target path.')
}
}
async function fileDigest(filePath: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(filePath)) {
hash.update(chunk as Buffer)
}
return hash.digest('hex')
}
function isLegacyRolloutRelativePath(relativePath: string): boolean {
if (!relativePath || relativePath.startsWith('..') || resolve(relativePath) === relativePath) {
return false
}
const parts = relativePath.split(sep)
return (
parts.length === 4 &&
/^\d{4}$/.test(parts[0] ?? '') &&
/^\d{2}$/.test(parts[1] ?? '') &&
/^\d{2}$/.test(parts[2] ?? '') &&
/^rollout-.+\.jsonl(?:\.zst)?$/.test(parts[3] ?? '')
)
}
function sameRuntimePath(left: string, right: string): boolean {
return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)
}
function isExistsError(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
+29 -1
View File
@@ -58,6 +58,34 @@ export async function* listCodexSessionJsonlFilesIncrementally(
rootPath: string,
options: CodexSessionBridgeIncrementalOptions,
onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise<void>
): AsyncGenerator<string> {
yield* listCodexSessionFilesIncrementally(
rootPath,
options,
(fileName) => fileName.endsWith('.jsonl'),
onDirectoryError
)
}
/** Yields both physical representations that current Codex can resume. */
export async function* listCodexSessionRolloutFilesIncrementally(
rootPath: string,
options: CodexSessionBridgeIncrementalOptions,
onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise<void>
): AsyncGenerator<string> {
yield* listCodexSessionFilesIncrementally(
rootPath,
options,
(fileName) => fileName.endsWith('.jsonl') || fileName.endsWith('.jsonl.zst'),
onDirectoryError
)
}
async function* listCodexSessionFilesIncrementally(
rootPath: string,
options: CodexSessionBridgeIncrementalOptions,
isSessionFile: (fileName: string) => boolean,
onDirectoryError?: (directoryPath: string, error: unknown) => void | Promise<void>
): AsyncGenerator<string> {
const batchSize = Math.max(1, options.batchSize ?? INCREMENTAL_BRIDGE_BATCH_SIZE)
const yieldMs = Math.max(0, options.yieldMs ?? INCREMENTAL_BRIDGE_YIELD_MS)
@@ -75,7 +103,7 @@ export async function* listCodexSessionJsonlFilesIncrementally(
const childPath = join(currentDirectory, entry.name)
if (entry.isDirectory()) {
pendingDirectories.push(childPath)
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
} else if (entry.isFile() && isSessionFile(entry.name)) {
yield childPath
}
entriesSinceYield += 1
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createCodexSessionMigrationScheduler } from './codex-session-migration-scheduler'
describe('createCodexSessionMigrationScheduler', () => {
beforeEach(() => {
vi.useFakeTimers()
})
it('runs after a managed-account startup switches to host system default', async () => {
let eligible = false
const startBackfill = vi.fn().mockResolvedValue(null)
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => eligible,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => undefined,
startBackfill,
startIndexHeal
})
scheduler.scheduleInitialRun()
await vi.advanceTimersByTimeAsync(15_000)
expect(startBackfill).not.toHaveBeenCalled()
eligible = true
scheduler.requestRun()
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
expect(startBackfill).toHaveBeenCalledOnce()
})
it('coalesces concurrent run requests and stops before index heal after opt-out', async () => {
let eligible = true
let releaseBackfill: (() => void) | undefined
const startBackfill = vi.fn(
() =>
new Promise<void>((resolve) => {
releaseBackfill = resolve
})
)
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => eligible,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => '/custom/history',
startBackfill,
startIndexHeal
})
scheduler.requestRun()
scheduler.requestRun()
expect(startBackfill).toHaveBeenCalledOnce()
expect(startBackfill).toHaveBeenCalledWith(expect.any(Object), '/custom/history')
eligible = false
releaseBackfill?.()
await Promise.resolve()
await Promise.resolve()
expect(startIndexHeal).not.toHaveBeenCalled()
})
it('reruns after a stopping migration becomes eligible again', async () => {
let eligible = true
let releaseFirstBackfill: ((result: { stopped: boolean }) => void) | undefined
const startBackfill = vi
.fn()
.mockImplementationOnce(
(_options) =>
new Promise<{ stopped: boolean }>((resolve) => {
releaseFirstBackfill = resolve
})
)
.mockResolvedValueOnce({ stopped: false })
const startIndexHeal = vi.fn().mockResolvedValue(null)
const scheduler = createCodexSessionMigrationScheduler({
isEligible: () => eligible,
isQuitting: () => false,
resolveSystemCodexHomePathOverride: () => undefined,
startBackfill,
startIndexHeal
})
scheduler.requestRun()
const firstRunOptions = startBackfill.mock.calls[0]?.[0]
eligible = false
expect(firstRunOptions?.shouldStop()).toBe(true)
eligible = true
scheduler.requestRun()
releaseFirstBackfill?.({ stopped: true })
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
})
})
@@ -0,0 +1,87 @@
import type { CodexSessionBackfillOptions } from './codex-session-backfill-types'
type MigrationRun = (
options: CodexSessionBackfillOptions,
systemCodexHomePathOverride?: string
) => Promise<unknown>
export type CodexSessionMigrationScheduler = {
scheduleInitialRun(): void
requestRun(): void
}
export function createCodexSessionMigrationScheduler(args: {
isEligible: () => boolean
isQuitting: () => boolean
resolveSystemCodexHomePathOverride: () => string | undefined
startBackfill: MigrationRun
startIndexHeal: MigrationRun
initialDelayMs?: number
}): CodexSessionMigrationScheduler {
let initialTimer: ReturnType<typeof setTimeout> | null = null
let migrationTask: Promise<void> | null = null
let activeRunStopObserved = false
let rerunRequested = false
const requestRun = (): void => {
if (args.isQuitting() || !args.isEligible()) {
return
}
if (migrationTask) {
// Why: an account transition can re-enable migration while the prior run is still stopping.
rerunRequested ||= activeRunStopObserved
return
}
activeRunStopObserved = false
rerunRequested = false
const shouldStop = (): boolean => {
const stopped = args.isQuitting() || !args.isEligible()
activeRunStopObserved ||= stopped
return stopped
}
const systemCodexHomePathOverride = args.resolveSystemCodexHomePathOverride()
let stoppedBackfill = false
const task = args
.startBackfill({ shouldStop }, systemCodexHomePathOverride)
.then((result) => {
stoppedBackfill = isStoppedMigrationResult(result)
if (stoppedBackfill || shouldStop()) {
return
}
return args.startIndexHeal({ shouldStop }, systemCodexHomePathOverride)
})
.catch((error: unknown) => {
console.warn('[codex-session-migration] Background session migration failed:', error)
})
.then(() => undefined)
migrationTask = task
void task.finally(() => {
if (migrationTask === task) {
migrationTask = null
const shouldRerun = rerunRequested || stoppedBackfill
rerunRequested = false
activeRunStopObserved = false
if (shouldRerun) {
requestRun()
}
}
})
}
return {
scheduleInitialRun(): void {
if (initialTimer) {
return
}
initialTimer = setTimeout(() => {
initialTimer = null
requestRun()
}, args.initialDelayMs ?? 15_000)
},
requestRun
}
}
function isStoppedMigrationResult(result: unknown): boolean {
return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped)
}
@@ -0,0 +1,226 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
findTrustedCodexSessionResume,
resolveTrustedCodexSessionResumeHome
} from './codex-session-resume-home'
const tempRoots: string[] = []
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
describe('resolveTrustedCodexSessionResumeHome', () => {
it('returns the trusted home containing a persisted rollout', () => {
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl',
trustedCodexHomes: ['/managed/account/home', '/Users/example/.codex'],
fileIsRegular: () => true
})
).toBe('/Users/example/.codex')
})
it('accepts Windows paths case-insensitively', () => {
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: 'C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl',
trustedCodexHomes: ['c:\\users\\example\\.codex'],
fileIsRegular: () => true
})
).toBe('c:\\users\\example\\.codex')
})
it('rejects paths outside trusted homes or outside the rollout layout', () => {
const fileIsRegular = vi.fn((): boolean => true)
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: '/tmp/sessions/2026/07/20/rollout-a.jsonl',
trustedCodexHomes: ['/Users/example/.codex'],
fileIsRegular
})
).toBeNull()
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: '/Users/example/.codex/sessions/index.jsonl',
trustedCodexHomes: ['/Users/example/.codex'],
fileIsRegular
})
).toBeNull()
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath:
'/Users/example/.codex/sessions/2026/07/20/rollout-a/../../../../outside.jsonl',
trustedCodexHomes: ['/Users/example/.codex'],
fileIsRegular
})
).toBeNull()
expect(fileIsRegular).not.toHaveBeenCalled()
})
it('rejects a trusted-looking path when the rollout no longer exists', () => {
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl',
trustedCodexHomes: ['/Users/example/.codex'],
fileIsRegular: () => false
})
).toBeNull()
})
it('requires the transcript provenance to name a regular rollout file', () => {
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-'))
tempRoots.push(homePath)
const rolloutDirectory = join(homePath, 'sessions', '2026', '07', '20', 'rollout-a.jsonl')
mkdirSync(rolloutDirectory, { recursive: true })
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: rolloutDirectory,
trustedCodexHomes: [homePath]
})
).toBeNull()
const rolloutFile = join(homePath, 'sessions', '2026', '07', '20', 'rollout-b.jsonl')
writeFileSync(rolloutFile, '{}\n')
expect(
resolveTrustedCodexSessionResumeHome({
transcriptPath: rolloutFile,
trustedCodexHomes: [homePath]
})
).toBe(homePath)
})
it('follows Codex when a persisted plain rollout was compressed in place', async () => {
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-'))
tempRoots.push(homePath)
const plainPath = join(
homePath,
'sessions',
'2026',
'07',
'20',
'rollout-2026-07-20T12-00-00-session.jsonl'
)
const compressedPath = `${plainPath}.zst`
mkdirSync(join(plainPath, '..'), { recursive: true })
writeFileSync(compressedPath, 'compressed-rollout')
await expect(
findTrustedCodexSessionResume({
sessionId: 'session-a',
transcriptPath: plainPath,
trustedCodexHomes: [homePath]
})
).resolves.toEqual({ homePath, transcriptPath: compressedPath })
writeFileSync(plainPath, 'active-rollout')
await expect(
findTrustedCodexSessionResume({
sessionId: 'session-a',
transcriptPath: compressedPath,
trustedCodexHomes: [homePath]
})
).resolves.toEqual({ homePath, transcriptPath: plainPath })
})
it('finds compressed rollouts for legacy records without transcript provenance', async () => {
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-'))
tempRoots.push(homePath)
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
const compressedPath = join(
homePath,
'sessions',
'2026',
'07',
'20',
`rollout-2026-07-20T12-00-00-${sessionId}.jsonl.zst`
)
mkdirSync(join(compressedPath, '..'), { recursive: true })
writeFileSync(compressedPath, 'compressed-rollout')
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: undefined,
trustedCodexHomes: [homePath]
})
).resolves.toEqual({ homePath, transcriptPath: compressedPath })
})
it('finds older saved sessions by id when transcript provenance is absent', async () => {
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
const rolloutPath = `/managed/account/home/sessions/2026/07/20/rollout-2026-07-20T15-50-19-${sessionId}.jsonl`
const listSessionFiles = async function* (sessionsRoot: string): AsyncIterable<string> {
if (sessionsRoot === '/managed/account/home/sessions') {
yield `/managed/account/home/sessions/misplaced-${sessionId}.jsonl`
yield rolloutPath
}
}
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: undefined,
trustedCodexHomes: ['/Users/example/.codex', '/managed/account/home'],
listSessionFiles
})
).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath: rolloutPath })
})
it('does not scan session trees when exact transcript provenance is valid', async () => {
const transcriptPath =
'/managed/account/home/sessions/2026/07/20/rollout-2026-07-20-session.jsonl'
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
throw new Error('must not scan')
})
await expect(
findTrustedCodexSessionResume({
sessionId: 'session-a',
transcriptPath,
trustedCodexHomes: ['/managed/account/home'],
fileIsRegular: () => true,
listSessionFiles
})
).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath })
expect(listSessionFiles).not.toHaveBeenCalled()
})
it('does not replace rejected transcript provenance with a same-id rollout from another home', async () => {
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
throw new Error('must not scan')
})
await expect(
findTrustedCodexSessionResume({
sessionId,
transcriptPath: `/managed/origin/home/sessions/2026/07/20/rollout-${sessionId}.jsonl`,
trustedCodexHomes: ['/managed/origin/home', '/managed/other/home'],
fileIsRegular: () => false,
listSessionFiles
})
).resolves.toBeNull()
expect(listSessionFiles).not.toHaveBeenCalled()
})
it('does not scan homes for an untrusted legacy session id shape', async () => {
const listSessionFiles = (): AsyncIterable<string> => {
throw new Error('must not scan')
}
await expect(
findTrustedCodexSessionResume({
sessionId: '../session',
transcriptPath: undefined,
trustedCodexHomes: ['/Users/example/.codex'],
listSessionFiles
})
).resolves.toBeNull()
})
})
+135
View File
@@ -0,0 +1,135 @@
import { existsSync, lstatSync } from 'node:fs'
import { join } from 'node:path'
import {
getRuntimePathBasename,
normalizeRuntimePathForComparison,
relativePathInsideRoot
} from '../../shared/cross-platform-path'
import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing'
// Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials.
const ROLLOUT_RELATIVE_PATH = /^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl(?:\.zst)?$/
function isCodexRolloutInsideSessionsRoot(sessionsRoot: string, filePath: string): boolean {
const relativePath = relativePathInsideRoot(sessionsRoot, filePath)
return Boolean(relativePath && ROLLOUT_RELATIVE_PATH.test(relativePath.replace(/\\/g, '/')))
}
function isRegularFile(filePath: string): boolean {
try {
return lstatSync(filePath).isFile()
} catch {
return false
}
}
function resolveExistingRolloutPath(
transcriptPath: string,
fileIsRegular: (filePath: string) => boolean
): string | null {
const plainPath = transcriptPath.endsWith('.jsonl.zst')
? transcriptPath.slice(0, -'.zst'.length)
: transcriptPath.endsWith('.jsonl')
? transcriptPath
: null
if (!plainPath) {
return fileIsRegular(transcriptPath) ? transcriptPath : null
}
if (fileIsRegular(plainPath)) {
return plainPath
}
const compressedPath = `${plainPath}.zst`
return fileIsRegular(compressedPath) ? compressedPath : null
}
function resolveTrustedCodexSessionResume(args: {
transcriptPath: string | undefined
trustedCodexHomes: readonly string[]
fileIsRegular?: (filePath: string) => boolean
}): { homePath: string; transcriptPath: string } | null {
const persistedPath = args.transcriptPath?.trim()
if (!persistedPath) {
return null
}
for (const homePath of args.trustedCodexHomes) {
const sessionsRoot = join(homePath, 'sessions')
if (!isCodexRolloutInsideSessionsRoot(sessionsRoot, persistedPath)) {
continue
}
const transcriptPath = resolveExistingRolloutPath(
persistedPath,
args.fileIsRegular ?? isRegularFile
)
if (transcriptPath) {
return { homePath, transcriptPath }
}
}
return null
}
export function resolveTrustedCodexSessionResumeHome(args: {
transcriptPath: string | undefined
trustedCodexHomes: readonly string[]
fileIsRegular?: (filePath: string) => boolean
}): string | null {
return resolveTrustedCodexSessionResume(args)?.homePath ?? null
}
export async function findTrustedCodexSessionResume(args: {
sessionId: string
transcriptPath: string | undefined
trustedCodexHomes: readonly string[]
fileIsRegular?: (filePath: string) => boolean
listSessionFiles?: (sessionsRoot: string) => AsyncIterable<string>
}): Promise<{ homePath: string; transcriptPath: string } | null> {
const directSession = resolveTrustedCodexSessionResume(args)
if (directSession) {
return directSession
}
if (args.transcriptPath?.trim()) {
// Why: stale/rejected provenance must not select a same-id rollout under different account credentials; scanning is legacy-only.
return null
}
if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(args.sessionId)) {
return null
}
const listSessionFiles =
args.listSessionFiles ??
((sessionsRoot: string) =>
listCodexSessionRolloutFilesIncrementally(sessionsRoot, { batchSize: 64, yieldMs: 0 }))
const expectedSuffix = `-${args.sessionId}.jsonl`.toLowerCase()
const seenHomes = new Set<string>()
for (const homePath of args.trustedCodexHomes) {
const comparisonHome = normalizeRuntimePathForComparison(homePath)
if (seenHomes.has(comparisonHome)) {
continue
}
seenHomes.add(comparisonHome)
const sessionsRoot = join(homePath, 'sessions')
if (!args.listSessionFiles && !existsSync(sessionsRoot)) {
continue
}
for await (const filePath of listSessionFiles(sessionsRoot)) {
const plainFilePath = filePath.endsWith('.jsonl.zst')
? filePath.slice(0, -'.zst'.length)
: filePath
// Why: the directory entry already proves the compressed file exists; only probe its preferred plain sibling.
const preferredFilePath =
plainFilePath !== filePath && (args.fileIsRegular ?? isRegularFile)(plainFilePath)
? plainFilePath
: filePath
const plainFileName = getRuntimePathBasename(preferredFilePath)
.toLowerCase()
.replace(/\.zst$/, '')
if (
isCodexRolloutInsideSessionsRoot(sessionsRoot, preferredFilePath) &&
plainFileName.endsWith(expectedSuffix)
) {
return { homePath, transcriptPath: preferredFilePath }
}
}
}
return null
}
@@ -3,9 +3,10 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(24)
expect(PROTOCOL_VERSION).toBe(25)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24)
})
})
+3 -3
View File
@@ -1,10 +1,10 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
export const PROTOCOL_VERSION = 24
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 24
export const PROTOCOL_VERSION = 25
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
+1 -4
View File
@@ -151,10 +151,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
rows: 24,
startupIngress: {
colors: { foreground: '#2e3434', background: '#ffffff' },
deadlineMs: 5_000,
...(process.platform === 'win32'
? { echoProjection: 'windows-conpty-esc-stripped' as const }
: {})
deadlineMs: 5_000
}
})
const query = '\x1b]10;?\x07'

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