diff --git a/.gitignore b/.gitignore index 1874a068079..1b3f81e72c0 100644 --- a/.gitignore +++ b/.gitignore @@ -77,20 +77,21 @@ design-docs/ # Machine-local agent hook endpoint files may contain auth tokens. /agent-hooks/ -# Local-only design/planning docs (not checked in). -# Durable docs must live in one of the allow-listed locations below. +# Local-only design/planning docs (not checked in), including most of docs/reference/. +# Durable docs that should be tracked must live in one of the allow-listed +# locations below (assets, readme, STYLEGUIDE, mobile terminal shortcut bar, +# and the tracked reference docs linked from AGENTS.md / README.md). docs/** !docs/ !docs/assets/ !docs/assets/** !docs/readme/ !docs/readme/** -!docs/reference/ -!docs/reference/** -# Keep generated React perf scan ledgers local; durable docs still use docs/reference. -docs/reference/react-performance-audit.md !docs/STYLEGUIDE.md !docs/mobile-terminal-shortcut-bar.md +!docs/reference/ +!docs/reference/git-compatibility.md +!docs/reference/headless-linux-server.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 98beb0c1c1f..85e3d244d68 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -35,13 +35,7 @@ const relayExtraResource = { // from package directories where pnpm's symlink farm is absent. Copy the exact // runtime dependency closure to Resources/node_modules so bare require() calls // do not fall through to a developer checkout's node_modules. -const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources() - -const commonExtraResources = [ - relayExtraResource, - ...packagedRuntimeNodeModuleResources, - skillFreshnessResources -] +const commonExtraResources = [relayExtraResource, skillFreshnessResources] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' @@ -198,6 +192,7 @@ module.exports = { }, extraResources: [ ...commonExtraResources, + ...createPackagedRuntimeNodeModuleResources('win32'), winSpeechNativeResource, { from: 'resources/win32/bin/orca.cmd', @@ -261,6 +256,7 @@ module.exports = { notarize: isMacRelease, extraResources: [ ...commonExtraResources, + ...createPackagedRuntimeNodeModuleResources('darwin'), macSpeechNativeResource, { from: 'resources/darwin/bin/orca', @@ -324,6 +320,7 @@ module.exports = { }, extraResources: [ ...commonExtraResources, + ...createPackagedRuntimeNodeModuleResources('linux'), linuxSpeechNativeResource, { from: 'resources/linux/bin/orca-ide', diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 543ff2e6a60..be7d1cd9080 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -280,7 +280,6 @@ inline src/renderer/src/lib/pane-manager/pane-manager.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts -inline src/renderer/src/lib/terminal-links.ts inline src/renderer/src/lib/worktree-activation.test.ts inline src/renderer/src/lib/worktree-activation.ts inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index d3f81817cee..8854f866f42 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -31,6 +31,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ 'yaml', 'zod' ] +const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['windows-native-registry'] const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = { darwin: 'darwin-', @@ -128,7 +129,7 @@ function isKnownOmittedServeSimDependency(packageName, fromDir) { } } -function collectPackagedRuntimePackages() { +function collectPackagedRuntimePackages(electronPlatformName = process.platform) { const packages = new Map() const visit = (packageName, fromDir = projectDir) => { if (packageName === 'electron' || packages.has(packageName)) { @@ -156,7 +157,12 @@ function collectPackagedRuntimePackages() { } } - for (const packageName of PACKAGED_RUNTIME_PACKAGE_ROOTS) { + // Why: cross-builds must select native dependencies from the artifact target, not the build host. + const packageRoots = [ + ...PACKAGED_RUNTIME_PACKAGE_ROOTS, + ...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : []) + ] + for (const packageName of packageRoots) { visit(packageName) } @@ -183,8 +189,8 @@ function collectPackagedRuntimePackages() { return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right)) } -function createPackagedRuntimeNodeModuleResources() { - return collectPackagedRuntimePackages().map(([packageName, packageDir]) => ({ +function createPackagedRuntimeNodeModuleResources(electronPlatformName = process.platform) { + return collectPackagedRuntimePackages(electronPlatformName).map(([packageName, packageDir]) => ({ from: packageDir, to: join('node_modules', ...packageName.split('/')) })) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index eac27a90965..98b064cd30d 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -11,7 +11,11 @@ const scriptPath = import.meta.filename const projectDir = resolve(import.meta.dirname, '../..') const runtime = readRuntimeArg() -const NATIVE_MODULES = ['node-pty'] +const NATIVE_MODULES = [ + 'node-pty', + ...(process.platform === 'win32' ? ['windows-native-registry'] : []) +] +const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe'] const CHILD_CHECK_FLAG = '--check-only' if (process.argv.includes(CHILD_CHECK_FLAG)) { @@ -240,6 +244,12 @@ function collectNativeModuleFailures() { } function loadNativeModule(moduleName) { + if (moduleName === 'windows-native-registry') { + const registry = require(moduleName) + // Why: the package defers loading its .node addon until the first registry call. + registry.getRegistryKey(registry.HK.CU, 'Environment') + return + } if (moduleName === 'node-pty') { loadNodePtyNativeModule() return @@ -256,13 +266,27 @@ function loadNodePtyNativeModule() { // Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a // terminal is created, so require('node-pty') alone can miss ABI mismatches. const native = loadNativeModule(nativeName) - if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native.dir)) { + assertNodePtyWindowsConptyRuntime(native?.dir) + if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) { throw new Error( `node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active` ) } } +function assertNodePtyWindowsConptyRuntime(nativeDir) { + if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) { + return + } + const runtimeDir = resolve(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty') + const missingFile = NODE_PTY_CONPTY_RUNTIME_FILES.find( + (filename) => !existsSync(resolve(runtimeDir, filename)) + ) + if (missingFile) { + throw new Error(`node-pty ConPTY runtime file is missing: ${resolve(runtimeDir, missingFile)}`) + } +} + function getNodePtyNativeModuleName() { if (process.platform !== 'win32') { return 'pty' diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 1041ca924f2..0bc8e5ae077 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -193,6 +193,7 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { } ` ) + writeFakeWindowsRegistry(projectDir) } function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) { @@ -214,6 +215,19 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { } ` ) + writeFakeWindowsRegistry(projectDir) +} + +function writeFakeWindowsRegistry(projectDir) { + if (process.platform !== 'win32') { + return + } + const registryDir = join(projectDir, 'node_modules', 'windows-native-registry') + mkdirSync(registryDir, { recursive: true }) + writeFileSync( + join(registryDir, 'index.js'), + 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n' + ) } function writeNodePtyPatchFile(projectDir) { diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 16747b1ad3d..21324f11672 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -144,23 +144,22 @@ describe('orchestration skill guidance', () => { 'Sidebar lineage and orchestration lifecycle are related but not identical.' ) expect(workerTerminals).toContain( - 'A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent' + 'A same-worktree worker may appear as a peer under that worktree in the sidebar' + ) + expect(workerTerminals).toContain('while remaining a child dispatch in orchestration state') + expect(workerTerminals).toContain( + 'only an actual child worktree creates visible parent/child worktree lineage' ) expect(workerTerminals).toContain( - 'even though it is a child dispatch in Orca orchestration state' + 'Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible' ) expect(workerTerminals).toContain( - 'A visible parent/child worktree relationship requires creating a child worktree' + 'Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.' ) expect(workerTerminals).toContain( - 'only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree' - ) - expect(workerTerminals).toContain( - 'For supervised new-worktree workers, decide the desired Orca lineage before creation' - ) - expect(workerTerminals).toContain( - 'use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks' + 'When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree' ) + expect(workerTerminals).toContain('use `--no-parent` when it is not stacked') }) it('keeps review-only completions and named next-owner fixes in their lanes', () => { @@ -213,7 +212,7 @@ describe('orchestration skill guidance', () => { /```bash\norca worktree create --name --agent codex --json\n[\s\S]*?```/ )?.[0] - expect(workerTerminals).toContain('Agent-first (required for ordinary agent workers)') + expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:') expect(workerTerminals).toContain('fallback shell + agent pair') expect(workerTerminals).toContain( 'Repo setup or default-terminal settings may still add tabs or splits' diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index c18272d56a4..204ca03b2f9 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -1,9 +1,12 @@ import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' const projectDir = resolve(import.meta.dirname, '../..') +const require = createRequire(import.meta.url) +const { createPackagedRuntimeNodeModuleResources } = require('../packaged-runtime-node-modules.cjs') const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) describe('Electron runtime package contract', () => { @@ -25,6 +28,45 @@ describe('Electron runtime package contract', () => { expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron') }) + it('keeps the native Windows registry addon optional and platform-gated', () => { + const rebuildScript = readFileSync( + join(projectDir, 'config/scripts/rebuild-native-deps.mjs'), + 'utf8' + ) + const ensureScript = readFileSync( + join(projectDir, 'config/scripts/ensure-native-runtime.mjs'), + 'utf8' + ) + expect(packageJson.optionalDependencies['windows-native-registry']).toBe('3.2.2') + // Why: pnpm installs optional target architectures on every host; the root + // Windows-only rebuild owns this addon so macOS/Linux never run node-gyp for it. + expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('windows-native-registry') + expect(rebuildScript).toContain( + "rebuildPlatform === 'win32' ? ['windows-native-registry'] : []" + ) + expect(ensureScript).toContain( + "process.platform === 'win32' ? ['windows-native-registry'] : []" + ) + const packageTargets = { + win32: createPackagedRuntimeNodeModuleResources('win32'), + darwin: createPackagedRuntimeNodeModuleResources('darwin'), + linux: createPackagedRuntimeNodeModuleResources('linux') + } + expect(packageTargets.win32).toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: join('node_modules', 'windows-native-registry') }), + expect.objectContaining({ to: join('node_modules', 'node-addon-api') }) + ]) + ) + for (const platform of ['darwin', 'linux']) { + expect(packageTargets[platform]).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: join('node_modules', 'windows-native-registry') }) + ]) + ) + } + }) + it('guards package scripts that launch Electron tooling', () => { const scripts = packageJson.scripts const guardedScripts = [ diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 199149edaa1..cf91055d1e3 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -20,9 +20,18 @@ import { rebuild } from '@electron/rebuild' import { execFileSync, spawnSync } from 'node:child_process' -import { existsSync, globSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + globSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { platform as osPlatform } from 'node:os' -import { resolve } from 'node:path' +import { join, resolve } from 'node:path' const projectDir = process.cwd() let cliOptions @@ -40,6 +49,7 @@ const electronVersion = JSON.parse( ).version const ignoreModules = ['cpu-features'] +const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe'] if (ignoreModules.length > 0) { console.log(`[rebuild] Skipping optional Electron rebuild modules: ${ignoreModules.join(', ')}`) @@ -49,7 +59,11 @@ if (ignoreModules.length > 0) { // modules inside pnpm's .pnpm/ store. Passing an explicit list of modules to // rebuild via `onlyModules` ensures they're recompiled against Electron's Node // ABI regardless of the package manager's store layout. -const NATIVE_MODULES = ['node-pty', 'cpu-features'] +const NATIVE_MODULES = [ + 'node-pty', + 'cpu-features', + ...(rebuildPlatform === 'win32' ? ['windows-native-registry'] : []) +] const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) const forceRebuild = process.env.ORCA_FORCE_NATIVE_REBUILD === '1' || @@ -58,6 +72,7 @@ const forceRebuild = rebuildArch !== process.arch ensureElectronPackageInstalled() +restoreNodePtyWindowsConptyRuntime() const patchedNodePtyRebuildReason = forceRebuild ? null : getPatchedNodePtyRebuildReason() @@ -124,6 +139,7 @@ try { // Node before postinstall runs this script. force: true }) + restoreNodePtyWindowsConptyRuntime() } catch (/** @type {any} */ err) { console.error('[rebuild] Native module rebuild failed:', err?.message ?? err) if (isWindowsNativeLockError(err)) { @@ -143,6 +159,37 @@ try { process.exit(1) } +function restoreNodePtyWindowsConptyRuntime() { + if (rebuildPlatform !== 'win32' || !onlyModules.includes('node-pty')) { + return + } + + const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty') + if (!existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'))) { + return + } + const conptyRoot = join(nodePtyDir, 'third_party', 'conpty') + const sourceDir = readdirSync(conptyRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(conptyRoot, entry.name, `win10-${rebuildArch}`)) + .find((candidate) => existsSync(candidate)) + if (!sourceDir) { + throw new Error(`node-pty has no ConPTY runtime payload for win10-${rebuildArch}`) + } + + const runtimeDir = join(nodePtyDir, 'build', 'Release', 'conpty') + mkdirSync(runtimeDir, { recursive: true }) + for (const filename of NODE_PTY_CONPTY_RUNTIME_FILES) { + const sourceFile = join(sourceDir, filename) + if (!existsSync(sourceFile)) { + throw new Error(`node-pty is missing ${sourceFile}`) + } + copyFileSync(sourceFile, join(runtimeDir, filename)) + } + // Why: @electron/rebuild bypasses node-pty's postinstall step that normally copies these DLLs. + console.log(`[rebuild] Restored node-pty ConPTY runtime files for win10-${rebuildArch}.`) +} + function ensureElectronPackageInstalled() { if (electronPackageIsUsable()) { return @@ -390,6 +437,7 @@ function probeElectronNativeModules(moduleNames) { const probeSource = ` const { createRequire } = require('node:module') +const { existsSync } = require('node:fs') const { release } = require('node:os') const { resolve } = require('node:path') const projectRequire = createRequire(resolve(process.cwd(), 'package.json')) @@ -411,10 +459,17 @@ if (failures.length > 0) { } function loadNativeModule(moduleName) { + if (moduleName === 'windows-native-registry') { + const registry = projectRequire(moduleName) + // Why: the package defers loading its .node addon until the first registry call. + registry.getRegistryKey(registry.HK.CU, 'Environment') + return + } if (moduleName === 'node-pty') { projectRequire('node-pty') const { loadNativeModule } = projectRequire('node-pty/lib/utils') const native = loadNativeModule(getNodePtyNativeModuleName()) + assertNodePtyWindowsConptyRuntime(native.dir) if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) { throw new Error( 'node-pty resolved to ' + @@ -427,6 +482,26 @@ function loadNativeModule(moduleName) { projectRequire(moduleName) } +function assertNodePtyWindowsConptyRuntime(nativeDir) { + if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) { + return + } + const runtimeDir = resolve( + process.cwd(), + 'node_modules', + 'node-pty', + 'build', + 'Release', + 'conpty' + ) + for (const filename of ${JSON.stringify(NODE_PTY_CONPTY_RUNTIME_FILES)}) { + const runtimeFile = resolve(runtimeDir, filename) + if (!existsSync(runtimeFile)) { + throw new Error('node-pty ConPTY runtime file is missing: ' + runtimeFile) + } + } +} + function isNodePtyReleaseBuildDir(nativeDir) { return typeof nativeDir === 'string' && nativeDir.replace(/\\\\/g, '/').includes('build/Release/') } diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index 15e1e616aa9..1eacf9aa127 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -124,6 +124,61 @@ describe('rebuild-native-deps Electron install fallback', () => { }) describe('rebuild-native-deps patched node-pty rebuild', () => { + it.skipIf(process.platform !== 'win32')( + 'repairs a missing ConPTY runtime before probing without recompiling node-pty', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) + writeFakeWindowsRegistry(projectDir) + writeFakeNodePtyConptyPayload(projectDir, process.arch) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Restored node-pty ConPTY runtime files') + expect(result.stdout).toContain( + 'Native modules already load in Electron; skipping rebuild.' + ) + expect(existsSync(rebuildLogPath)).toBe(false) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + + it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Restored node-pty ConPTY runtime files for win10-x64') + const runtimeDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty') + expect(readFileSync(join(runtimeDir, 'conpty.dll'), 'utf8')).toBe('conpty.dll x64') + expect(readFileSync(join(runtimeDir, 'OpenConsole.exe'), 'utf8')).toBe('OpenConsole.exe x64') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + it.skipIf(process.platform === 'win32')( 'rebuilds when Electron can load node-pty but patched build artifacts are missing', () => { @@ -225,7 +280,7 @@ function mkTempProject() { return projectDir } -function runRebuildScript(projectDir, extraEnv = {}) { +function runRebuildScript(projectDir, extraEnv = {}, args = []) { const env = { ...process.env, npm_config_platform: 'linux', @@ -239,7 +294,7 @@ function runRebuildScript(projectDir, extraEnv = {}) { delete env[key] } } - return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs'], { + return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs', ...args], { cwd: projectDir, encoding: 'utf8', env: { @@ -360,16 +415,20 @@ export async function rebuild(options) { ) } -function writeFakeUsableElectronPackage(projectDir) { +function writeFakeUsableElectronPackage(projectDir, { platform = 'linux' } = {}) { writeFakeElectronPackage(projectDir) const electronDir = join(projectDir, 'node_modules', 'electron') - const electronPath = join(electronDir, 'dist', 'electron') + const platformExecutable = platform === 'win32' ? 'electron.exe' : 'electron' + const electronPath = join(electronDir, 'dist', platformExecutable) mkdirSync(join(electronDir, 'dist'), { recursive: true }) - writeFileSync(join(electronDir, 'path.txt'), 'electron') + writeFileSync(join(electronDir, 'path.txt'), platformExecutable) writeFileSync(join(electronDir, 'dist', 'version'), 'v41.5.0') - writeFileSync( - electronPath, - `#!/usr/bin/env node + if (platform === 'win32') { + copyFileSync(process.execPath, electronPath) + } else { + writeFileSync( + electronPath, + `#!/usr/bin/env node const { spawnSync } = require('node:child_process') const result = spawnSync(process.execPath, process.argv.slice(2), { @@ -385,8 +444,27 @@ if (result.error) { process.exit(result.status ?? 0) ` + ) + chmodSync(electronPath, 0o755) + } +} + +function writeFakeNodePtyConptyPayload(projectDir, arch) { + const releaseDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') + mkdirSync(releaseDir, { recursive: true }) + writeFileSync(join(releaseDir, 'conpty.node'), 'native addon') + const sourceDir = join( + projectDir, + 'node_modules', + 'node-pty', + 'third_party', + 'conpty', + '0.1.0', + `win10-${arch}` ) - chmodSync(electronPath, 0o755) + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(join(sourceDir, 'conpty.dll'), `conpty.dll ${arch}`) + writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`) } function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) { @@ -403,6 +481,15 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { ) } +function writeFakeWindowsRegistry(projectDir) { + const registryDir = join(projectDir, 'node_modules', 'windows-native-registry') + mkdirSync(registryDir, { recursive: true }) + writeFileSync( + join(registryDir, 'index.js'), + 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n' + ) +} + function writeNodePtyPatchFile(projectDir) { mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') diff --git a/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md b/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md deleted file mode 100644 index fdcc7987af0..00000000000 --- a/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md +++ /dev/null @@ -1,193 +0,0 @@ -# Design: Manual network address entry for Orca desktop mobile pairing - -**Date:** 2026-06-27 -**Scope:** Desktop renderer (Settings → Mobile → Network Interface section) -**Status:** Draft, awaiting user review - -## Problem - -`src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` lets the user pick the network address that gets baked into the mobile-pairing QR code. Today the only options come from `networkInterfaces`, which is the list returned by the main process enumerating OS network interfaces (`en0`, `tailscale0`, etc.). If a user wants a tailnet address that the OS hasn't surfaced yet — a Tailscale MagicDNS hostname, a ZeroTier-assigned address not yet visible to the OS, or a manual LAN IP — they have no way to type one in. The QR ends up pointing at an interface the phone cannot actually reach. - -## Decision summary - -Replace the inner `Select` of `MobileNetworkInterfaceSection` with a `Popover + Command` ("combobox") pattern modeled on the existing `AgentCombobox`. The popover contains a single `CommandInput` that filters the auto-discovered interfaces above and renders a special "Use …" entry at the bottom of the list whenever the input is a valid IPv4 address or Tailscale MagicDNS hostname. Picking that entry selects a custom address; the trigger shows `
(custom)`. Custom addresses are session-scoped (cleared when the settings pane closes). - -## Constraints (from `CONTRIBUTING.md` + `AGENTS.md`) - -- Cross-platform: code paths must not assume a single platform; the manual entry path itself is platform-neutral. -- No `helpers`/`utils`/`misc` file names; use concrete names. -- No `eslint-disable max-lines`; split files instead. -- Prefer `.ts` over `.d.ts`. -- UI work follows `docs/STYLEGUIDE.md` and uses shadcn primitives from `src/renderer/src/components/ui/`. -- The renderer ↔ shared boundary is `src/shared/`; pure logic that may be reused outside the renderer goes there. -- Comments explain *why*, briefly. - -## Files - -| Path | Change | -| --- | --- | -| `src/shared/network/manual-address.ts` | **New.** Pure `parseManualNetworkAddress(input)` returning a discriminated union. | -| `src/shared/network/manual-address.test.ts` | **New.** Vitest cases for IPv4 and MagicDNS hostname validation. | -| `src/renderer/src/components/settings/mobile-network-interface-selection.ts` | Replace `mergeForSelect` with `buildComboboxEntries(interfaces, customAddress)` returning the entry list the UI maps over. | -| `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` | Replace `mergeForSelect` tests with `buildComboboxEntries` tests. | -| `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` | Swap `Select` for `Popover + Command`; add `open`/`query`/`customAddress` state. | -| `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx` | **New.** Render tests via `@testing-library/react`. | - -No changes to: `mobile/app/pair-scan.tsx`, `MobilePairingQrSection.tsx`, `use-mobile-install-qr.ts`, or any main-process code. The QR generation pipeline already consumes `selectedAddress: string`, which is all the new flow produces. - -## Module 1: `parseManualNetworkAddress` - -```ts -// src/shared/network/manual-address.ts -export type ParseManualAddressResult = - | { ok: true; address: string } - | { ok: false; error: string } - -export function parseManualNetworkAddress(input: string): ParseManualAddressResult -``` - -**Rules** (in order): - -1. `input.trim()` must be non-empty. Otherwise `{ ok: false, error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' }`. -2. Reject any input containing whitespace anywhere; reject any input longer than 253 chars (DNS hostname cap). -3. Accept if it matches the IPv4 grammar (four dotted octets, each 0–255). No leading zeros except for `0` itself. -4. Accept if it matches the Tailscale MagicDNS hostname grammar: - - Regex (case-insensitive): `/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\.ts\.net$/` -5. Otherwise return the same error as (1). - -Pure function, no React, no I/O. Unit-testable in isolation. - -## Module 2: `buildComboboxEntries` - -```ts -// src/renderer/src/components/settings/mobile-network-interface-selection.ts -export type MobileNetworkInterface = { name: string; address: string } - -export type ComboboxEntry = - | { kind: 'interface'; iface: MobileNetworkInterface } - | { kind: 'use-query'; address: string } // only emitted when current query is valid - -export function buildComboboxEntries( - interfaces: readonly MobileNetworkInterface[], - query: string -): readonly ComboboxEntry[] -``` - -**Behavior:** - -- Trim `query`; if empty, return only `kind: 'interface'` entries from `interfaces` (no `use-query`). -- If `query` is non-empty, behavior branches on `parseManualNetworkAddress(query)`: - - **Valid query:** skip substring filtering and keep every interface visible (so the user can pivot to an existing interface mid-typing). Emit each as `kind: 'interface'`. - - **Invalid query:** filter `interfaces` by case-insensitive substring match on `iface.address` OR `iface.name`. Emit each as `kind: 'interface'`. If the filter yields zero matches, fall back to the full `interfaces` list (so the user always sees the available options, never an empty list mid-typing). -- After the interface entries, if the query parsed as valid AND no emitted interface has an `address` exactly equal to `parsed.address`, append `{ kind: 'use-query', address: parsed.address }`. (Suppression happens regardless of whether filtering ran, because valid queries skip filtering entirely — the check is against the visible interface list, which for valid queries is the full list.) -- Order: interface entries first (stable, in input order — either filtered or the full list per the branch above), then the optional `use-query`. -- The `selectRefreshedNetworkAddress` function is **kept** unchanged — it's still the rule that decides the *initial* `selectedAddress` when no manual entry exists. The UI calls it on mount and on Refresh; afterwards, the combobox owns the selection. - -## Module 3: `MobileNetworkInterfaceSection` UI - -Outer JSX (header text, description, Generate QR button, Refresh button, Tailnet accordion) is untouched. Only the inner selection control is replaced. - -**State:** - -```ts -const [open, setOpen] = useState(false) -const [query, setQuery] = useState('') -const [customAddress, setCustomAddress] = useState(null) -``` - -`customAddress` is the last value the user confirmed via the `use-query` entry. It is the "session-scoped" custom selection that the trigger displays with the `(custom)` label. It is reset when the settings pane unmounts (React unmount handles this automatically; no global store involved). - -**Trigger:** A `Button` styled like the existing `SelectTrigger` (`min-w-[220px]`, `size="sm"`). Label uses the same `formatInterfaceLabel` helper: - -```ts -function formatInterfaceLabel(iface: { name: string; address: string }): string { - return `${iface.address} (${iface.name})` -} -``` - -For the custom selection the rendered iface is `{ name: 'custom', address: customAddress }`, so the trigger shows `100.64.1.20 (custom)`. - -**Popover body:** `Command` containing: - -- `CommandInput` with placeholder `Search or type an address…` and `value={query}` / `onValueChange={setQuery}`. -- `CommandList` containing: - - `CommandEmpty` shown only when no interfaces match AND `parseManualNetworkAddress(query)` is invalid (i.e., truly nothing to pick). - - One `CommandItem` per `kind: 'interface'` entry from `buildComboboxEntries(networkInterfaces, query)`. `onSelect` calls `onSelectedAddressChange(iface.address)`, `setCustomAddress(null)`, `setQuery('')`, `setOpen(false)`. - - Optional visual separator (e.g., `CommandSeparator`) before the `use-query` entry. - - One `CommandItem` for `kind: 'use-query'` (only present when query is valid). Label: `Use ""`. `onSelect` calls `onSelectedAddressChange(address)`, `setCustomAddress(address)`, `setQuery('')`, `setOpen(false)`. - -**Controlled cmdk selection:** Copy the controlled-`commandValue` pattern from `src/renderer/src/components/agent/AgentCombobox.tsx` (imports `createAgentComboboxCommandState`, `resolveAgentComboboxCommandState`, `updateAgentComboboxCommandValue` from `@/components/agent/agent-combobox-command-state`, plus the `Command`, `CommandEmpty`, `CommandInput`, `CommandItem`, `CommandList` primitives from `@/components/ui/command`) so that hovering the footer doesn't leave a stale highlight on a list item. The exact state shape will be minimal — only one list, no footer-group complexity — so the borrowed helpers are sufficient. No new helpers are introduced in this design. - -**Validation feedback:** - -- When `query` is non-empty and invalid, render a one-line `text-xs text-statusRed` message directly below the trigger: `"Enter an IPv4 address or Tailscale MagicDNS hostname"`. Use the existing `statusRed` token from the theme to stay style-guide compliant. -- The `use-query` entry only appears when valid; no need to disable it. - -**Refresh button:** Unchanged. Calls `onRefreshNetworkInterfaces`. The combobox re-renders with the new `networkInterfaces`; `query` and `customAddress` are preserved (user might be mid-typing). - -**Generate QR button:** Unchanged. Disabled when `!selectedAddress`. No new branches. - -## Data flow - -``` -networkInterfaces (prop, refreshed by parent) - │ - ▼ -buildComboboxEntries(networkInterfaces, query) - │ - ▼ -CommandList rows - │ - ▼ onSelect -onSelectedAddressChange(string) ─► parent re-renders MobilePairingQrSection - ─► QR is regenerated with new endpoint -``` - -The parent of `MobileNetworkInterfaceSection` (whichever Settings tab owns it) already maintains `selectedAddress` and re-passes it down. This design does not change that contract. - -## Edge cases - -1. **Duplicate manual entry vs. existing interface** — `buildComboboxEntries` suppresses the `use-query` entry whenever the parsed query exactly equals an emitted interface's `address`. For valid queries the visible list is the full interface list (no substring filter runs), so the suppression check is against every interface, not just filtered ones. The user lands on the existing interface row instead of a duplicate. -2. **OS discovers the manual address later** — If `customAddress === '100.64.1.20'` and a refresh surfaces `100.64.1.20 (tailscale0)`, both are valid options; the user's selection stays. A future iteration may add a "merge" action; out of scope here. -3. **Empty `networkInterfaces`** — All-interface list is empty. If `query` is also empty, `CommandEmpty` shows. If `query` is valid, the `use-query` entry still appears so the user can type an address even when nothing is enumerated. The trigger shows `No interfaces found`. -4. **Manual address becomes unreachable at pair time** — Not handled here. The QR generation succeeds; `pair-scan.tsx` already surfaces "Cannot connect — same network?" on failure. -5. **Closing the popover with an invalid query typed** — `customAddress` and `selectedAddress` are unchanged. Next open starts with an empty `query`. - -## Testing - -**`src/shared/network/manual-address.test.ts`** - -- Accepts: `0.0.0.0`, `255.255.255.255`, `192.168.1.24`, `100.64.1.20`. -- Rejects: `''`, `' '`, `'1.2.3'`, `'1.2.3.4.5'`, `'256.0.0.1'`, `'01.02.03.04'` (leading zeros), `'192.168.1.24 '` (trailing space). -- Accepts MagicDNS: `my-mac.ts.net`, `my-mac.tail-abcd.ts.net`, `a.b.c.d.ts.net`. -- Rejects MagicDNS: `my-mac` (no `.ts.net`), `my-mac.ts.com`, `-foo.ts.net`, `MY-MAC.TS.NET` is accepted (case-insensitive). -- Rejects anything > 253 chars; rejects whitespace anywhere. -- Pure unit tests; no React, no mocks. - -**`mobile-network-interface-selection.test.ts`** - -- `buildComboboxEntries([LAN, TAILNET], '')` returns two interface entries, no `use-query`. -- `buildComboboxEntries([LAN, TAILNET], '100')` (invalid query) returns the tailnet interface only — substring filter on `100.64.1.20` matches `100` — and no `use-query` because the query did not parse. -- `buildComboboxEntries([LAN, TAILNET], '100.64.1.20')` (valid query) returns both interface entries (valid queries skip substring filtering) AND suppresses `use-query` because the parsed address equals an existing interface's `address`. -- `buildComboboxEntries([LAN, TAILNET], 'my-mac.tail-abcd.ts.net')` (valid query) returns both interface entries (valid queries skip substring filtering) plus a `use-query` with the trimmed address. -- `buildComboboxEntries([], '1.2.3.4')` returns just `use-query`. - -**`MobileNetworkInterfaceSection.test.tsx`** (new) - -- Open popover, type `100.64.1.20`, click `Use "100.64.1.20"` → trigger label becomes `100.64.1.20 (custom)`, query clears. -- Type `not-an-address` → error message renders, no `Use …` row. -- Type `192.168.1.24` (matches `en0`) → no `Use …` row; clicking the existing interface selects it as `en0`, not `custom`. -- Trigger label `No interfaces found` shown when `networkInterfaces` is empty and no manual selection. - -## Out of scope - -- Persistent storage of manual addresses across sessions (user explicitly chose session-scoped). -- IPv6, port suffixes, non-Tailscale hostnames (rejected by the parser). -- Mobile-side endpoint override (separate flow; see `mobile/app/pair-scan.tsx`). -- Main-process changes — the renderer has enough information already. - -## Open questions for reviewer - -1. Should the error message stay in English-only here, or get the same `translate('auto.…', 'fallback')` wrap as the rest of the section? Recommend: wrap it for consistency, since the rest of the component already uses `translate()`. -2. Should `customAddress` survive a "Refresh" click? Recommend yes — user might be mid-typing during a VPN reconnect. Confirmed in Edge case 5 above. -3. Should `CommandInput` accept paste of a multi-line string (e.g. user pastes `orca://pair?code=…`)? Recommend: no special handling; the existing trim+validate treats it as an invalid address and shows the error. Pair-URL paste remains the path through `pair-scan.tsx`. diff --git a/docs/reference/README.md b/docs/reference/README.md deleted file mode 100644 index 1ced066c57b..00000000000 --- a/docs/reference/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Durable Docs - -Keep this folder for versioned reference docs that are meant to survive past a single design or implementation pass. - -## What Goes Here - -- Stable reference material. -- Public-facing docs that are not part of the root README. -- Docs that other checked-in files link to. -- Telemetry availability notes that dashboard authors need after the original design or implementation branch is gone. See [Telemetry Availability](./telemetry-availability.md). -- Headless Linux server setup for remote `orca serve` hosts. See [Headless Linux Server](./headless-linux-server.md). -- Feature education state, interaction tracking, and retention analytics notes that define how contextual tours are persisted and measured. See [Feature Education State](./feature-education-state.md), [Feature Discovery Interaction Tracking](./feature-discovery-interaction-tracking.md), and [Feature Education Retention Analytics](./feature-education-retention-analytics.md). -- New-user parallel work telemetry notes that define how the parallel-work tour and setup guide should be measured against retention. See [New User Parallel Work Telemetry](./new-user-parallel-work-telemetry.md). - -## What Stays Out - -Ephemeral design notes, implementation sketches, and planning docs should stay as local Markdown files under `docs/`. They are ignored by default so they do not get checked in accidentally. diff --git a/docs/reference/agent-hook-stdin-lifecycle.md b/docs/reference/agent-hook-stdin-lifecycle.md deleted file mode 100644 index 98b404c97ac..00000000000 --- a/docs/reference/agent-hook-stdin-lifecycle.md +++ /dev/null @@ -1,204 +0,0 @@ -# Agent Hook Stdin Lifecycle - -## Problem - -Orca installs agent hooks in global agent configuration, so a managed hook can -run in an Orca pane, an SSH/WSL runtime, a detached session, or an ordinary -terminal with no Orca environment. Hook runners write an event payload to the -child process's stdin after spawn. - -The generated scripts currently inspect Orca environment variables before they -read stdin. When a guard exits first, the hook runner can still be writing to a -pipe whose reader has closed. POSIX reports `EPIPE`; Windows reports -`ERROR_BROKEN_PIPE`. The same failure exists when a managed launcher finds that -its script was removed or is no longer executable. - -This is an ownership bug rather than an agent-specific parsing bug. Every -process that accepts a hook payload owns the read end until it reaches EOF, -including processes that decide the event is irrelevant. - -## Goals - -- Make successful hook no-ops consume stdin to EOF on macOS, Linux, Windows, - WSL, and SSH hosts. -- Encode stdin ownership once per platform instead of copying ad hoc drains - into every early-exit branch. -- Preserve payload bytes, hook output, transport timeouts, exit-code behavior, - and provider-specific event metadata on the forwarding path. -- Cover every generated script and missing-script launcher with behavioral - regression tests. - -## Non-goals - -- Do not redesign hook configuration schemas, endpoint discovery, relay HTTP - payloads, or event selection. -- Do not make hook payloads unbounded; existing agent and config timeouts remain - the outer lifecycle bound. -- Do not suppress failures from a script that exists and actually runs. Only a - deliberate no-op path exits successfully after consuming stdin. -- Do not change plugin-based integrations that do not receive piped hook stdin. - -## Contract - -1. A generated hook script consumes its input exactly once. -2. No whole-script success exit may occur before that consumption completes. -3. A launcher that declines to start a missing, unreadable, or non-executable - managed script becomes the stdin owner and drains to EOF before returning - success. -4. A launcher propagates the exit code of a managed script that was started. -5. Output required by an agent protocol may be emitted before stdin is read, - but the process must still retain the read end until EOF. -6. Drain commands are platform-qualified where the platform searches the - working directory implicitly. - -## Design - -### POSIX generated scripts - -Capture stdin near the start of the generated script, after any protocol output -that must be immediate and before endpoint refresh, environment guards, or -provider-specific skips: - -```sh -payload=$(cat) -if [ -z "$payload" ]; then - exit 0 -fi -``` - -Antigravity is the one semantic exception: events without payload still post an -empty object, so its shared capture policy maps empty input to `{}` instead of -exiting. Claude's Devin-import skip happens after capture. Command Code captures -before ancestor/endpoint recovery so its comparatively expensive discovery -cannot leave the writer blocked. - -The common payload-capture fragments live in -`src/main/agent-hooks/hook-stdin-contract.ts`. Templates choose the required or -empty-object policy instead of spelling the lifecycle independently. - -### Windows batch generated scripts - -Batch scripts stream stdin directly into system `curl.exe`; buffering arbitrary -JSON in an environment variable would corrupt metacharacters and hit size -limits. Their posting path therefore remains streaming. - -All environment guard failures jump to one epilogue: - -```bat -if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin -... -exit /b 0 -:orca_agent_hook_drain_stdin -"%SystemRoot%\System32\more.com" >nul 2>nul -exit /b 0 -``` - -`more.com` is qualified because Windows searches the current working directory -for executables. Shared guard and epilogue builders keep labels and commands -identical across templates. Command Code's existing endpoint-discovery labels -remain subroutines and the drain epilogue is placed after them. - -### PowerShell generated scripts - -PowerShell captures with `[Console]::In.ReadToEnd()` before endpoint and -environment guards. Copilot then parses the captured value only on the posting -path. This mirrors POSIX ownership without starting an additional process. - -### Managed launchers - -- POSIX `/bin/sh` launchers require a regular, readable, executable file. The - rejected path drains with `cat`; the started-script branch keeps propagating - status. -- Encoded PowerShell launchers use `Test-Path -PathType Leaf`. A missing script - calls `[Console]::In.ReadToEnd()` and exits zero; an existing script preserves - `$LASTEXITCODE`. -- Codex's (and Antigravity's/Devin's) cmd fast path is the bare, directly - spawnable `.cmd` path. These agents launch the hook command as a program - (argv[0]), not through cmd.exe, so the launcher cannot lead with a cmd builtin - such as `if` — that argv[0] is unspawnable and fails every hook. A missing - script therefore surfaces a normal launch failure on this fast path; only - launchers that already require a real interpreter (encoded PowerShell, Git - Bash) drain a missing script. Paths cmd.exe cannot invoke verbatim fall back to - the encoded PowerShell launcher, which owns stdin for the missing case. -- Claude's Git Bash fast path uses a POSIX file guard and drain while continuing - to execute the `.cmd` directly rather than interpreting it as shell source. -- Agent-specific direct launchers, including Antigravity event wrappers and - Copilot's PowerShell-file command, adopt the same missing-file behavior. - -## Data Flow - -```text -hook runner spawns command - -> runner writes payload and closes stdin - -> launcher starts managed script - -> script consumes payload - -> refreshes endpoint / evaluates guards - -> posts or exits zero - -> OR launcher cannot start script - -> launcher drains payload - -> exits zero -``` - -Local, WSL, and SSH installs serialize the same POSIX template. Windows local -installs use the batch or PowerShell template. No host assumes another host's -path syntax or shell. - -## Failure Semantics - -- Missing Orca environment: consume input, exit zero, emit only protocol-required - output. -- Empty payload: consume EOF, then follow the agent's existing empty-event rule. -- Missing/unreadable/non-executable script: launcher consumes input and exits - zero. -- Endpoint parse/read failure: preserve the existing fail-open behavior after - stdin ownership has been satisfied. -- Existing script returns nonzero: propagate its status; do not drain again or - disguise the script failure. -- Hook runner never closes stdin: the existing config-level timeout terminates - the hook. Reading to EOF does not introduce an unbounded lifecycle beyond that - already required by normal payload parsing. - -## Verification - -Add one cross-agent lifecycle suite rather than per-service string-position -assertions. - -- Generate every SSH-compatible POSIX managed script, spawn it with Orca - environment removed, write a payload larger than pipe buffers, and assert: - exit zero, no stdin error, and required protocol output remains valid. -- Exercise the Claude Devin skip independently because it is a second no-op - condition before endpoint forwarding. -- Exercise POSIX, encoded PowerShell, cmd.exe, and Git Bash missing-script - launchers with a large payload and verify zero write errors. -- On Windows CI, install and execute every local batch/PowerShell managed script - with missing Orca environment and a large payload. -- Keep structural assertions for shared batch guard/epilogue generation, but do - not use substring placement as the primary regression gate. -- Preserve existing successful-post, timeout, quoting, SSH install, WSL, and - nonzero-exit propagation tests. - -For a macOS smoke test, launch the real Electron app with an isolated `HOME` -and `ORCA_DEV_USER_DATA_PATH`, record the launch time, then run: - -```sh -node config/scripts/verify-agent-hook-stdin-lifecycle.mjs \ - --home "$ISOLATED_HOME" \ - --min-mtime "$LAUNCH_START_MS" -``` - -The verifier rejects stale files, then exercises the 12 POSIX scripts written -by that Electron launch with a payload larger than pipe buffers. It covers -successful no-ops, loopback forwarding without payload changes, required JSON -stdout, Claude's Devin skip, and both branches of the installed missing-script -launcher. - -## Rollout And Compatibility - -The generated scripts are rewritten by existing install/update flows, so no -config migration is needed. Commands use POSIX `sh` primitives, Windows inbox -executables, and PowerShell features available on supported Windows releases. -No Git behavior or provider-specific review behavior changes. - -The change should ship atomically across templates and launchers. A partial -rollout would leave global hooks with platform- or agent-dependent pipe safety, -which is the inconsistency this design removes. diff --git a/docs/reference/agent-session-resume-cli-evidence.md b/docs/reference/agent-session-resume-cli-evidence.md deleted file mode 100644 index 5c26f9ecbc6..00000000000 --- a/docs/reference/agent-session-resume-cli-evidence.md +++ /dev/null @@ -1,341 +0,0 @@ -# Agent Session Resume Evidence for Sleep/Wake - -Date: June 5, 2026 - -Scope: issue [stablyai/orca#1796](https://github.com/stablyai/orca/issues/1796), current worktree code, installed agent CLIs on this machine, cloned open-source CLI repos under `/tmp/orca-agent-resume-research`, and official provider docs where available. - -## Executive conclusion - -Orca can implement one-click agent resume on wake, but it cannot use terminal PTY IDs, pane keys, tab IDs, or worktree IDs as the agent session ID. Those are Orca terminal identifiers. The required value is the provider's conversation/session/thread ID. - -For several first-tier providers, the exact provider ID is available in hook payloads today, but Orca drops it during normalization: - -- Claude: hook payloads include `session_id`; resume command is `claude --resume `. -- Codex: hook runtime sends `session_id`; resume command is `codex resume `. -- Gemini: hook payloads include `session_id`; resume command is `gemini --resume `. -- Antigravity: hook payloads include `conversationId`; resume command is `agy --conversation `. -- OpenCode: Orca's managed plugin already reads OpenCode `sessionID`; resume command is `opencode --session `. -- Droid: hook payloads include `session_id`; resume command is `droid --resume `. -- Grok: hook payloads include `sessionId`/`session_id`; resume command is `grok --resume `. - -The current blocker is not primarily "how do we resume?" The exact resume action is to spawn a new agent process on wake in the same workspace/pane context using the provider-specific resume command. The blocker is that Orca's current hook event and status types have no durable provider-session field, so the ID is discarded before sleep. - -## Current Orca behavior - -Issue #1796 is accurate about current behavior. The issue says sleep loses agent type, session ID, last prompt, and resume command because `dropAgentStatusByWorktree` wipes rows before wake can offer resume. - -Verified current code path: - -- Sleep calls terminal shutdown with identifiers preserved, not provider conversation metadata. See `src/renderer/src/components/sidebar/sleep-worktree-flow.ts`. -- `shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true })` preserves terminal IDs/layout wake hints, then calls `dropAgentStatusByWorktree(worktreeId)`. See `src/renderer/src/store/slices/terminals.ts`. -- `agentStatusByPaneKey` is explicitly documented as "Real-time only - lives in renderer memory, not persisted to disk." See `src/renderer/src/store/slices/agent-status.ts`. -- `WorkspaceSessionState` persists terminal/browser/editor/SSH state, including terminal layouts and remote relay PTY IDs, but no sleeping-agent records. See `src/shared/types.ts`, `src/renderer/src/lib/workspace-session.ts`, and `src/shared/workspace-session-schema.ts`. -- `AgentStatusEntry` has `state`, `prompt`, timestamps, `agentType`, pane/tab/worktree attribution, tool previews, assistant preview, and orchestration context. It has no provider session ID or resume command field. See `src/shared/agent-status-types.ts`. - -The existing sleep persistence is terminal-resume metadata. The feature needs provider-conversation-resume metadata. - -## Hook pipeline gap - -The current local and SSH hook wire shapes drop provider IDs: - -- `AgentHookEventPayload` in `src/shared/agent-hook-listener.ts` includes pane/tab/worktree, prompt cache fields, hook event name, Claude tool/subagent IDs, and normalized `payload`. It has no provider session ID field. -- `AgentHookRelayEnvelope` in `src/shared/agent-hook-relay.ts` mirrors that normalized shape for SSH. Its comment states the relay normalizes before sending the envelope. Therefore remote/SSH events lose provider IDs at the relay boundary too unless the new field is added to the shared envelope. -- `ParsedAgentStatusPayload` in `src/shared/agent-status-types.ts` includes only status fields: `state`, `prompt`, `agentType`, `toolName`, `toolInput`, `lastAssistantMessage`, `interrupted`. - -Implementation implication: provider-session metadata must be extracted before normalization completes and must be carried through both the local HTTP path and the SSH relay path. - -## Installed CLI versions checked - -These exact binaries were available on this machine: - -| Agent | Version checked | -| --- | --- | -| Claude Code | `2.1.165` | -| Codex | `codex-cli 0.137.0` | -| Gemini | `0.44.0` | -| OpenCode | `1.15.13` | -| Cursor Agent | `2025.09.18-7ae6800` | -| Antigravity | `1.0.5` | -| Droid | `0.122.0` | -| Grok | `grok 0.2.22 (967574cb117) [stable]` | -| Pi | `0.72.1` | -| Amp, Copilot, Command Code, Hermes | Not installed locally | - -## Provider matrix - -Legend: - -- `Exact supported`: exact resume command and exact ID source are both verified. -- `Resume verified, ID capture missing`: CLI can resume, but current Orca hook path does not expose a durable exact ID. -- `ID seen, resume not verified`: Orca/source exposes an ID, but no exact resume CLI command was verified. -- `Out of current hook scope`: Orca has no current hook source for this TUI agent. - -| Agent | Resume command evidence | Exact ID source evidence | Current Orca support status | -| --- | --- | --- | --- | -| Claude | `claude --resume ` verified by local `claude --help` and Anthropic CLI docs. | Claude hook docs show hook input includes `session_id` and `transcript_path`; current Orca normalizer drops `session_id`. | Exact supported after adding provider-session metadata extraction. | -| Codex | `codex resume ` verified by local help and cloned Codex source `codex-rs/utils/cli/src/resume_command.rs`. | Cloned Codex source `codex-rs/core/src/hook_runtime.rs` sends `session_id` on `SessionStart`, `PreToolUse`, `PermissionRequest`, and `PostToolUse` hook requests. | Exact supported after extraction. | -| Gemini | `gemini --resume ` verified by local help and Gemini docs. | Gemini hooks reference says all hooks receive `session_id` and `transcript_path`. | Exact supported after extraction. | -| Antigravity | `agy --conversation ` verified by local help and Antigravity docs. | Antigravity hook docs list `conversationId` as the active conversation UUID. | Exact supported after extraction. | -| OpenCode | `opencode --session ` verified by local help, official docs, and cloned OpenCode TUI source. | Orca's OpenCode plugin already reads `event.properties?.sessionID` before posting. | Exact supported after preserving `sessionID`. | -| Droid | `droid --resume ` verified by local help and Factory CLI docs. | Factory hooks reference includes `session_id`; Orca installs Droid hooks and already subscribes to `SessionStart`. | Exact supported after extraction. | -| Grok | `grok --resume ` verified by local help. | Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`, but only to locate chat history. | Exact supported after preserving that same ID. | -| Cursor | `cursor-agent --resume ` and `cursor-agent resume` verified by local help and Cursor docs. | Current Orca Cursor hook install deliberately omits `sessionStart`/`sessionEnd`; the subscribed event set does not currently prove a chat ID source. `cursor-agent create-chat` was locally verified to return a UUID, but preallocation for Orca launches still needs an end-to-end test before relying on it. | Resume verified, ID capture missing. | -| Pi | `pi --session `, `pi --resume`, and `pi --continue` verified by local help and Pi docs. | Orca's bundled Pi/OMP extension posts status events but does not include session file path, session UUID, or session name in the status payload. | Resume verified, ID capture missing. | -| OMP | Same launch family as Pi in Orca. | Orca reuses Pi extension API shape and does not include exact session pointer. | Resume verified by inheritance only from Pi-like CLI contract; exact OMP ID capture missing. | -| Amp | Orca's managed plugin posts `threadId: event.thread.id`. | Amp is not installed locally, and no exact Amp CLI resume command was verified in this investigation. | ID seen, resume not verified. | -| Hermes | Orca's managed Hermes plugin selects `session_id` for session/LLM/tool events. | Hermes is not installed locally, and no exact Hermes CLI resume command was verified in this investigation. | ID seen, resume not verified. | -| Copilot | Orca installs broad Copilot hooks. | Local Copilot CLI not installed; no exact resume command or session ID contract was verified. | Not supported for exact resume yet. | -| Command Code | Orca installs hooks for `PreToolUse`, `PostToolUse`, and `Stop`. | Local Command Code CLI not installed; no exact resume command or session ID contract was verified. | Not supported for exact resume yet. | -| openclaude, autohand, aider, goose, kilo, kiro, crush, aug, cline, codebuff, continue, kimi, mistral-vibe, qwen-code, rovo, openclaw | Not investigated to exact-resume standard. | Not in `HOOK_SOURCE_BY_PATHNAME` today. | Out of current hook scope. | - -## Exact resume behavior by provider - -### Claude - -Verified: - -- Local `claude --help` shows `--continue`, `--resume [value]`, `--session-id `, `--fork-session`, and `--no-session-persistence`. -- Anthropic CLI docs show `claude -r "" "query"` and document `--resume` as resuming a specific session by ID. -- Claude hooks docs show hook input includes `session_id` and `transcript_path`. - -Resume command: - -```sh -claude --resume -``` - -If Orca stores `lastPromptSummary` separately, it should not pass that summary as a new user prompt by default. The one-click resume action should reopen the prior conversation. Auto-submitting a summary would create a new turn. - -### Codex - -Verified: - -- Local `codex resume --help` accepts `[SESSION_ID] [PROMPT]`, where the session target is a UUID or session name. -- Cloned Codex source `codex-rs/utils/cli/src/resume_command.rs` formats `codex resume `. -- Cloned Codex source `codex-rs/core/src/hook_runtime.rs` sends `session_id` on hook requests. -- Cloned Codex source has tests asserting resumed root sessions use the thread ID as session ID. - -Resume command: - -```sh -codex resume -``` - -Avoid `codex resume --last` for this feature when an exact ID is available. `--last` can resume the wrong conversation if another Codex session ran after the slept workspace. - -### Gemini - -Verified: - -- Local `gemini --help` shows `--resume`, `--session-id`, `--session-file`, and `--list-sessions`. -- Gemini official hooks reference states all hook input includes `session_id` and `transcript_path`. -- Gemini official configuration docs describe `--resume [session_id]` and state that a UUID can be supplied. - -Resume command: - -```sh -gemini --resume -``` - -### Antigravity - -Verified: - -- Local `agy --help` shows `--continue` and `--conversation`. -- Antigravity conversation docs show `agy --continue` for most recent and `agy --conversation ` for a specific session. -- Antigravity hooks docs define common hook input `conversationId`. - -Resume command: - -```sh -agy --conversation -``` - -### OpenCode - -Verified: - -- Local `opencode --help` shows `--continue` and `--session`. -- Official OpenCode docs list `--session` as the session ID to continue. -- Cloned OpenCode source `packages/opencode/src/cli/cmd/tui/thread.ts` defines `--session` and passes it into TUI args as `sessionID`. -- Cloned OpenCode source `validate-session.ts` validates the supplied session ID through the OpenCode client. -- Orca's managed OpenCode plugin reads `event.properties?.sessionID`. - -Resume command: - -```sh -opencode --session -``` - -### Droid - -Verified: - -- Local `droid --help` shows `--resume [sessionId]`, `--fork `, and `--cwd`. -- Factory CLI docs list resume behavior and `droid exec -s ` for exec mode. -- Factory hooks reference says `SessionStart` has a `resume` matcher and hook input includes `session_id`. - -Resume command: - -```sh -droid --resume -``` - -### Grok - -Verified: - -- Local `grok --help` shows `--resume []`, `--continue`, `--cwd`, and `--restore-code`. -- Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`. - -Resume command: - -```sh -grok --resume -``` - -### Cursor - -Verified: - -- Local `cursor-agent --help` shows `--resume [chatId]`. -- Cursor docs show listing prior chats with `cursor-agent ls`, resuming latest with `cursor-agent resume`, and resuming a specific conversation with `cursor-agent --resume="chat-id-here"`. -- Local help shows `cursor-agent create-chat` creates an empty chat and returns its ID. -- Running `cursor-agent create-chat` locally returned UUID `1fff2e62-9dd0-4cf3-9d8c-3569fea0aff7`, proving the command can preallocate a chat ID without an initial prompt. - -Not verified: - -- Current Orca Cursor hook install subscribes to `beforeSubmitPrompt`, `stop`, tool events, approval events, and `afterAgentResponse`, but intentionally does not subscribe to `sessionStart`/`sessionEnd`. -- I did not verify a current subscribed Cursor hook payload containing `chatId`. -- I did not run `cursor-agent --resume ` because that would start a real agent turn and may make workspace changes. - -Conclusion: exact Cursor resume is CLI-supported, but Orca currently needs either a verified hook payload chat ID or a launch preallocation strategy that is tested end to end. - -### Pi and OMP - -Verified: - -- Local `pi --help` shows `--continue`, `--resume`, `--session `, `--fork `, `--session-dir`, and `--no-session`. -- Pi docs state `/resume` opens a picker and `pi -r` opens the same picker at startup. -- Orca's Pi/OMP extension posts status events from Pi's extension API, but the status events do not include session path, UUID, or session name. - -Resume command: - -```sh -pi --session -``` - -Conclusion: exact Pi resume is CLI-supported, but current Orca capture is missing the exact pointer. - -## What Orca should store - -Store a durable record per sleeping agent pane, not per worktree only: - -```ts -type SleepingAgentSessionRecord = { - id: string - worktreeId: string - tabId?: string - paneKey: string - connectionId: string | null - agentType: TuiAgent - providerSession: { - kind: 'session_id' | 'conversation_id' | 'thread_id' | 'chat_id' | 'path' - value: string - } - resumeCommand: { - argv: string[] - cwd?: string - } - lastPrompt: string - lastAssistantMessage?: string - capturedAt: number - retentionUntil?: number | null -} -``` - -Important constraints: - -- `resumeCommand.argv` should be structured argv, not a shell string. This avoids cross-platform quoting bugs. -- Do not persist a terminal PTY ID as `providerSession.value`. -- For SSH, `cwd` and the command execute on the remote host. The provider session ID also belongs to the remote CLI's session store. -- For local workspaces, execute locally in the worktree path. -- For auto-resume, launch exactly one process per sleeping agent record and guard against double-click/duplicate wake races. - -## Provider extraction map - -This is the extraction map that is supported by the evidence above: - -| Hook source | Provider session field(s) to extract | Resume argv | -| --- | --- | --- | -| `claude` | `session_id` | `['claude', '--resume', id]` | -| `codex` | `session_id` | `['codex', 'resume', id]` | -| `gemini` | `session_id` | `['gemini', '--resume', id]` | -| `antigravity` | `conversationId` | `['agy', '--conversation', id]` | -| `opencode` | `sessionID` | `['opencode', '--session', id]` | -| `droid` | `session_id` | `['droid', '--resume', id]` | -| `grok` | `sessionId`, fallback `session_id` | `['grok', '--resume', id]` | -| `amp` | `threadId` | not enabled until Amp resume command is verified | -| `hermes` | `session_id` | not enabled until Hermes resume command is verified | - -## Required implementation shape - -1. Add a provider-session metadata type to shared hook types. -2. Extract provider session evidence in `parseAgentHookEvent` from raw `hookPayloadRecord` before normalizing to `ParsedAgentStatusPayload`. -3. Carry it in `AgentHookEventPayload`. -4. Carry it in `AgentHookRelayEnvelope` so SSH does not lose it. -5. Store latest provider-session evidence by pane key alongside live agent status. -6. On sleep, snapshot the live agent status plus latest provider-session evidence before `dropAgentStatusByWorktree`. -7. Persist sleeping-agent records in `WorkspaceSessionState` and schema. -8. On wake, render sleeping resumable rows separately from active rows and launch the provider-specific structured argv when the user resumes. -9. Clear records on explicit worktree removal, not on sleep. -10. If the provider session no longer exists, let the provider CLI fail and surface that failure without deleting the record until the user dismisses it. - -## Open questions that remain genuinely unverified - -- Cursor: whether any currently subscribed Cursor hook payload carries `chatId`. If not, the only plausible exact strategy is preallocating a chat with `cursor-agent create-chat` at launch and starting/resuming that known chat. That needs an end-to-end test before productizing. -- Pi/OMP: whether the extension API can expose the current session file/path/UUID. Current Orca extension does not forward it. -- Amp: whether the Amp CLI has an exact thread resume command matching `threadId`. -- Hermes: whether Hermes has an exact session resume CLI command matching `session_id`. -- Copilot and Command Code: exact resume command and exact hook session field were not verified because the CLIs were not installed locally and no sufficient primary docs/source were found during this pass. -- Non-hook TUI agents: no exact support should be promised until Orca adds a hook/metadata capture path for each. - -## Sources - -Local Orca source: - -- `src/shared/agent-hook-listener.ts` -- `src/shared/agent-hook-relay.ts` -- `src/shared/agent-status-types.ts` -- `src/shared/types.ts` -- `src/shared/tui-agent-config.ts` -- `src/renderer/src/store/slices/terminals.ts` -- `src/renderer/src/store/slices/agent-status.ts` -- `src/renderer/src/lib/workspace-session.ts` -- `src/shared/workspace-session-schema.ts` -- `src/main/opencode/hook-service.ts` -- `src/main/amp/hook-service.ts` -- `src/main/hermes/hook-service.ts` -- `src/main/pi/agent-status-extension-source.ts` -- `src/main/cursor/hook-service.ts` - -Cloned source: - -- `/tmp/orca-agent-resume-research/codex` -- `/tmp/orca-agent-resume-research/gemini-cli` -- `/tmp/orca-agent-resume-research/opencode` - -Official docs: - -- GitHub issue: https://github.com/stablyai/orca/issues/1796 -- Claude CLI reference: https://docs.anthropic.com/en/docs/claude-code/cli-usage -- Claude hooks reference: https://code.claude.com/docs/en/hooks -- Gemini hooks reference: https://geminicli.com/docs/hooks/reference/ -- Cursor CLI parameters: https://docs.cursor.com/en/cli/reference/parameters -- Cursor CLI usage: https://docs.cursor.com/en/cli/using -- OpenCode CLI docs: https://opencode.ai/docs/cli/ -- Antigravity conversation docs: https://antigravity.google/docs/cli-conversations -- Antigravity hooks docs: https://www.antigravity.google/docs/hooks -- Factory Droid hooks reference: https://docs.factory.ai/cli/configuration/hooks-reference -- Factory Droid CLI reference: https://docs.factory.ai/cli/configuration/cli-reference -- Pi docs: https://pi.dev/docs/latest/tree diff --git a/docs/reference/browser-local-https-certificate-trust.md b/docs/reference/browser-local-https-certificate-trust.md deleted file mode 100644 index d858e030cb9..00000000000 --- a/docs/reference/browser-local-https-certificate-trust.md +++ /dev/null @@ -1,926 +0,0 @@ -# Built-in Browser Local HTTPS and Certificate Trust - -## Status - -Implemented reference design for -[issue #8454](https://github.com/stablyai/orca/issues/8454). - -The branch includes scheme-less local-dev URL classification, certificate-specific -failure copy, `Try HTTPS`, exact per-WebContents certificate grants, -`Proceed Anyway (Unsafe)`, local IPC, remote/headless runtime propagation, and -focused unit and real-Electron coverage. A session-level request gate contains -Chromium's cached certificate continuation so approval cannot leak to sibling -tabs, assets, fetches, iframes, or WebSockets. The release gates below remain -the acceptance contract for Electron upgrades and cross-platform validation. - -## Summary - -Orca's built-in browser cannot open a local HTTPS development server when the -server presents an untrusted certificate. A scheme-less local address also -defaults to HTTP, so entering `localhost:3000` does not discover an HTTPS-only -server. - -This design keeps HTTP as the default for the scheme-less local forms that -already use it, adds a visible `Try HTTPS` recovery action after a failed local -HTTP navigation, and adds an explicit `Proceed Anyway (Unsafe)` path for an -untrusted local HTTPS certificate. Certificate approval is narrow and -temporary: it is bound to one browser WebContents, one secure endpoint, the -SHA-256 digest of the exact leaf certificate, and the specific certificate -error. A secure endpoint treats `https` and its companion `wss` scheme as one -TLS endpoint but still includes the canonical hostname and effective port. No -approval state is persisted. - -The certificate decision runs in the process that owns the browser page. Local -desktop webviews use the desktop main process; SSH/headless pages use the remote -runtime process and expose the decision through runtime RPC. - -## Problem - -There are three independent behaviors behind the current failure. - -1. `normalizeBrowserNavigationUrl` and the tab-create entry classifier prepend - `http://` to scheme-less loopback input. Explicit `https://` input is already - preserved. -2. Electron rejects an untrusted server certificate by default. Orca has no - `certificate-error` decision flow, so a self-signed development certificate - fails with a Chromium certificate error such as - `ERR_CERT_AUTHORITY_INVALID (-202)`. -3. Browser load errors are presented as generic connectivity failures. For a - loopback URL, Orca advises the user to check that the server is running even - when the server responded and only certificate verification failed. - -The same trust limitation exists in the offscreen browser backend used by -headless and SSH-owned browser pages. A desktop-only handler would fix the -visible local webview while leaving remote browser ownership inconsistent. - -## Goals - -- Show certificate-specific failure copy for main-frame certificate errors. -- Let a user explicitly approve an untrusted local development certificate. -- Bind approval to the exact browser surface, secure endpoint, leaf certificate - digest, and certificate error. -- Keep approval in memory only and clear it with the owning browser surface. -- Support both desktop webviews and SSH/headless offscreen browser pages. -- Give scheme-less local input a discoverable path to HTTPS without silently - changing the existing HTTP default. -- Preserve the current browser sandbox, navigation allowlist, session profiles, - and mixed-content policy. -- Keep browser failure state accurate across event-order races, early webview - attachment, reload, stale user actions, and remote latency. - -## Non-goals - -- Do not automatically trust a self-signed certificate. -- Do not disable `webSecurity`, enable insecure mixed content, or add - `--ignore-certificate-errors`. -- Do not install a certificate into the operating-system trust store. -- Do not persist certificate exceptions across app restarts, browser surface - recreation, or session-profile changes. -- Do not default every scheme-less localhost URL to HTTPS. -- Do not automatically probe HTTPS and then downgrade to HTTP. -- Do not add a general-purpose exception flow for public websites in v1. -- Do not make every Chromium certificate error overridable in v1. -- Do not extend localhost worktree-label proxying to HTTPS targets. - -## Product Decisions - -### Scheme-less local input continues to use HTTP - -`localhost:3000` remains `http://localhost:3000/`. HTTP is still the common -development-server default, and changing the inference globally would turn -working HTTP servers into certificate or TLS failures. - -When an HTTP navigation to a loopback URL fails, the browser failure overlay -offers `Try HTTPS`. The action replaces only the URL scheme and preserves the -host, explicit non-default port, path, query, and fragment. An absent port, or -an explicit `:80` normalized away by the URL parser, becomes HTTPS's default -port 443. Orca does not automatically retry because an invisible -cross-protocol retry would add latency, send an unexpected second request, and -make downgrade behavior opaque. - -Explicit input remains authoritative: - -- `http://localhost:3000` stays HTTP. -- `https://localhost:3000` stays HTTPS. -- URLs advertised by a workspace process keep their advertised scheme. - -The shared browser URL domain becomes the source of truth for the existing -scheme-less local-dev classification. The tab-create entry classifier calls -that shared logic instead of keeping a second regular expression and scheme -rule. This consolidation must not reuse the stricter certificate-eligibility -predicate: doing so would change current behavior for inputs such as -`0.0.0.0`, bracketed non-loopback IPv6 addresses, and `.localhost` subdomains. - -### V1 approval covers untrusted local authorities only - -The first version offers `Proceed Anyway (Unsafe)` only when all of these are -true: - -- the failure is for the main frame; -- the attempted URL is HTTPS; -- Electron reports `net::ERR_CERT_AUTHORITY_INVALID`, corresponding to - Chromium load error `-202`; -- the host is an eligible loopback host; -- the WebContents is a browser surface managed by Orca; and -- Orca retained a live pending challenge for that browser surface. - -Other certificate errors receive accurate copy but no bypass action. In -particular, v1 does not bypass revoked certificates, malformed certificates, -weak keys, date errors, or hostname/SAN mismatches. Expanding the allowlist -requires a separate security decision and dedicated tests. - -### Eligible loopback hosts - -The shared predicate accepts: - -- `localhost`, case-insensitively, with an optional trailing dot; -- valid subdomains of `.localhost`, also with an optional trailing dot; -- valid IPv4 addresses in `127.0.0.0/8`; and -- IPv6 loopback `::1`. - -The predicate does not treat `0.0.0.0`, `::`, arbitrary bracketed IPv6 -addresses, private LAN addresses, or a DNS name that merely resolves to -loopback as certificate-bypass eligible. Wildcard bind addresses should already -be normalized to a connectable loopback address before navigation. - -SSH local forwards are eligible when their browser-facing URL uses one of these -loopback hosts. Forwarding does not fix certificate names: a URL using -`127.0.0.1` still needs an IP SAN for `127.0.0.1`; a certificate containing only -`localhost` will fail with `ERR_CERT_COMMON_NAME_INVALID`, which v1 does not -bypass. Custom advertised DNS names remain subject to normal system trust in -v1, even if the name maps to loopback. - -### Approval lifetime - -An accepted grant is scoped to: - -```text -browser WebContents ID + secure endpoint + leaf SHA-256 digest + error name -``` - -The secure endpoint canonicalizes `wss:` to its companion `https:` endpoint and -includes the normalized hostname and effective port. This lets an approved -local page load same-endpoint HTTPS assets and its WSS development socket. A -grant for `https://localhost:3000` does not apply to port 3001, to -`https://127.0.0.1:3000`, to another browser tab, or to a replacement -certificate. - -Construct the secure endpoint from the parsed request URL: map `https:` and -`wss:` to `https:`, lowercase the hostname, remove one optional trailing dot, -use the URL parser's canonical IP representation, and use port 443 when the -parsed port is empty. Exclude credentials, path, query, and fragment. Reject -every other scheme. - -Accepted grants survive reloads and same-surface navigation back to the exact -endpoint. They are cleared when the WebContents commits a main-frame navigation -whose canonical secure endpoint differs from the grant's endpoint (reusing the -per-WebContents navigation-sequence tracking), when the WebContents is destroyed -or replaced, the session profile changes, the browser-owning process exits, or -the app restarts. Binding grant lifetime to the granting main-frame document -prevents a later top-level document in the same tab — for example a navigation -to a public site — from silently reusing the loopback grant for a same-endpoint -iframe, fetch, or WSS subrequest. -Clearing a grant affects future requests; it cannot retroactively terminate a -document that already loaded. Grants are not written to settings, browser -history, workspace session state, or logs. - -Chromium caches an accepted `certificate-error` continuation across the -Electron session partition. Orca therefore retains every endpoint/certificate -identity accepted in that partition until the browser-owning process exits and -gates all later HTTPS/WSS requests with `webRequest.onBeforeRequest`. Only the -WebContents holding the matching grant may reach a cached endpoint. A sibling -main-frame navigation receives a synthetic challenge for the known identity; -sibling subresources, fetches, iframes, workers, and WSS requests fail silently. - -Because a preflight request gate cannot inspect the certificate that will be -presented after Chromium's cache is consulted, one endpoint is locked to the -first accepted untrusted leaf identity for that process lifetime. If that -endpoint later presents a different untrusted leaf, strict verification rejects -it and Orca does not offer another bypass until the browser-owning process -restarts. A replacement system-trusted certificate may load normally; the -conservative request gate still keeps sibling WebContents blocked until restart. - -## User Experience - -### Local HTTP failure - -For a failed `http://` loopback navigation, keep the current connectivity -title and recovery hint. Add `Try HTTPS` when an HTTPS form can be constructed. - -Example: - -```text -Can't reach localhost:3000 -We couldn't connect to your local server. - -[Try HTTPS] [Retry] [Copy Address] [Open Externally] -``` - -`Try HTTPS` is the default button because it is the targeted recovery action -for a local HTTP failure. Keep the existing `Copy Address` recovery action; -`Retry` uses `outline`, while `Copy Address` and `Open Externally` stay quiet -secondary actions. `Try HTTPS` does not appear for an already-HTTPS URL or a -non-eligible host. - -### Certificate failure - -For `ERR_CERT_AUTHORITY_INVALID (-202)` on an eligible local endpoint, replace -the generic connectivity content with: - -```text -Connection isn't secure -Orca doesn't trust the authority that issued the certificate for localhost:3000. - -For local development, use a trusted local certificate when possible. - -[Open Externally] [Retry] [Copy Address] [Proceed Anyway (Unsafe)] -``` - -- `Open Externally` is the default safe recovery action when the URL is - reachable from the desktop. -- When `Open Externally` is unavailable, `Retry` becomes the default action. -- `Proceed Anyway (Unsafe)` uses `outline`; `Copy Address` stays quiet. -- `Proceed Anyway (Unsafe)` is always visibly labeled; it is not hidden behind - a tooltip. -- The unsafe action does not use the destructive color. It does not delete or - irreversibly mutate user data, and the explicit label carries the warning. -- Use a muted `ShieldAlert` icon from `lucide-react`; do not add a warning color - or new token. - -For a remote-owned page whose URL is remote localhost, `Open Externally` is -hidden because the desktop system browser cannot reach the remote loopback -address. It remains available for a desktop-owned page or a locally forwarded -URL. - -For certificate errors that v1 does not permit bypassing, show the same -certificate-specific title with error-specific body text, `Retry`, -`Copy Address`, and an eligible `Open Externally` action. `Copy Address` remains -present as a quiet secondary action in every certificate-failure branch, -eligible or not, so only `Try HTTPS` and `Proceed Anyway (Unsafe)` toggle in and -out across cases. The same default-action rule applies as for `-202`: -`Open Externally` is the default when the URL is reachable from the desktop, -otherwise `Retry` becomes the default. Do not show the local-server-running -hint. - -Use this presentation mapping, inserting the display host into the copy: - -| Chromium code | Body copy | Proceed in v1 | -| ----------------------- | -------------------------------------------------------------------------- | ----------------------------- | -| `-200` | `The certificate doesn't match {host}.` | No | -| `-201` | `The certificate for {host} isn't valid at the current date and time.` | No | -| `-202` | `Orca doesn't trust the authority that issued the certificate for {host}.` | Eligible local endpoints only | -| Other certificate error | `Orca couldn't verify the certificate for {host}.` | No | - -Keep the raw error name/code available to diagnostics and optional details, but -do not make users interpret it to understand the primary failure. - -### Interaction behavior - -- The overlay is persistent inline UI because the user must read and act on the - failure; do not use a toast. -- The overlay does not steal focus when it appears. -- Actions are reachable by Tab and have visible focus rings. -- Use `aria-live="polite"`; repeated background failures must not repeatedly - announce the same message. -- Clicking `Proceed Anyway (Unsafe)` disables the action immediately, and also - disables the other overlay actions (`Retry`, `Try HTTPS`, `Copy Address`, - `Open Externally`) for the duration of the approval round-trip so a stale - click cannot race the controller's own `loadURL`. Re-enable them once a - success or typed-failure response returns. -- Map every `proceedCertificate` failure reason to an overlay outcome: - `expired`, `changed`, `ineligible`, and `missing` keep the overlay open and - show the inline recovery message below; `navigated` shows no message because - the overlay for that navigation is already cleared. In every non-success case - `Proceed Anyway (Unsafe)` stays disabled until a fresh pending challenge is - announced rather than re-enabling on its own. -- Delay the spinner by 200 ms, matching the style guide's remote-latency rule. - After that delay, show the canonical `Loader2` with `Connecting…` and use a - fixed button width so the label swap cannot move adjacent actions. -- If approval expired or the certificate changed, keep the overlay open and - show `The certificate changed or the approval expired. Retry the page.` -- A successful main-frame navigation clears the visible certificate failure. - -The UI must use existing `background`, `foreground`, `muted-foreground`, -`border`, and `ring` tokens plus the existing shadcn `Button` primitive. No new -color, radius, or shadow tier is required. Do not apply a blanket opacity to -the overlay's interactive content; use semantic muted text classes so button -and focus-ring contrast remains intact. Put every new user-visible string -through the existing localization catalog. - -## Architecture - -### Ownership - -The browser-owning main process is the only authority that can accept a -certificate challenge. - -- Desktop-owned `` pages are controlled by the desktop main process. -- Headless/SSH-owned pages are controlled by the remote `orca serve` main - process through `OffscreenBrowserBackend`. -- The renderer only presents a pending challenge and requests approval. It - never decides whether a certificate is trusted. - -### New main-process controller - -Add `src/main/browser/browser-certificate-trust-controller.ts` to manage pending -certificate challenges, and -`src/main/browser/browser-certificate-request-guard.ts` to manage grants plus -the session-level cached-certificate request boundary. - -The controller owns: - -```ts -type BrowserCertificateFailure = { - challengeId: string - browserPageId: string - errorCode: number | null - error: string - origin: string - displayHost: string - canProceed: boolean - observedAt: number -} - -type PendingBrowserCertificateChallenge = { - challengeId: string - guestWebContentsId: number - browserPageId: string | null - navigationSequence: number - navigationUrl: string - origin: string - secureEndpoint: string - leafCertificateSha256: string - errorCode: number | null - error: string - expiresAt: number -} - -type BrowserCertificateGrant = { - guestWebContentsId: number - secureEndpoint: string - leafCertificateSha256: string - error: string -} -``` - -Generate challenge IDs with `randomUUID()`. Compute the certificate identity in -the browser-owning main process as SHA-256 over the DER bytes of Electron's leaf -`certificate.data`; do not depend on the undocumented formatting or algorithm -of `certificate.fingerprint`. Renderer and runtime payloads expose no -certificate bytes or digest—only the challenge ID, display host, origin, error -code, error name, `canProceed`, and observation time. - -Pending challenges expire after five minutes and are bounded to 32 entries. -Accepted grants are bounded to 32 entries per browser-owning process and are -also removed with their WebContents. Bounds evict the oldest entry and notify -the affected UI when a live challenge is evicted. Ordinary cleanup should keep -both collections much smaller. Accepted endpoint identities are not evicted: -Chromium's corresponding cache cannot be cleared narrowly, so forgetting one -would reopen cross-WebContents access until process exit. - -### Certificate event handling - -Register exactly one app-level `certificate-error` listener after Electron is -ready but before either a desktop window or the offscreen backend can create a -browser page. Every branch must call Electron's callback exactly once; an -unexpected exception fails closed with `callback(false)`. - -Electron supplies the certificate error as a string on this event, while -`did-fail-load` supplies the Chromium number later. Normalize the string to one -canonical error name and map only known names to numbers for presentation. An -unknown name keeps `errorCode: null` and is never bypass-eligible. - -For every event: - -1. Resolve the WebContents through `BrowserManager` and reject unmanaged, - retired, or popup WebContents. -2. Parse the request URL, normalize its secure endpoint, and compute the leaf - certificate SHA-256 digest. Invalid data fails closed. -3. Compare the normalized error name, endpoint, and digest against an accepted - grant for this exact WebContents. On an exact match, first retain the accepted - identity in the owning Electron session, then call `event.preventDefault()` - and `callback(true)`. -4. If no grant matches, reject non-main-frame failures without creating a - challenge. Subresources and iframes can consume an existing grant but cannot - mint one. -5. For a main-frame failure, record a pending challenge only when the URL and - error are eligible. Notify the owning page when its page ID is known, then - call `callback(false)`. - -Track a monotonically increasing main-frame navigation sequence per -WebContents. A new main-frame navigation invalidates its pending challenge in -both main and renderer state before the next certificate decision. Repeated -identical events within one sequence reuse the challenge ID; a retry or a -different navigation gets a new ID. This prevents an old button or compromised -renderer from approving a page the user has already left. - -The first failure is always rejected. User approval records a grant and reloads -the page; the next certificate event is the one that succeeds. - -Do not leave Electron's callback pending while waiting for user input. Rejecting -first gives Orca a normal load-failure lifecycle, avoids an unbounded blocked -navigation, and makes stale approval cleanup deterministic. - -Do not use `session.setCertificateVerifyProc`. It applies to an entire session -partition, cannot bind a decision to one browser page, and verification results -can be cached by Chromium's network service. - -### Session request gate and Electron upgrade guard - -The pinned Electron 43 binary caches `callback(true)` broadly enough that a -second WebContents in the same partition can load the endpoint without another -`certificate-error` event. The certificate callback alone is therefore not an -isolation boundary. - -Install exactly one `webRequest.onBeforeRequest` listener on every Orca browser -session before its first page loads. Electron permits only one listener for this -event, so the browser session registry owns installation and removal. Once an -endpoint identity has been accepted, the listener blocks every HTTPS/WSS -request to that endpoint unless `details.webContentsId` has the matching grant. -Requests without a WebContents ID, including background/worker traffic, fail -closed. A blocked main-frame request recreates the challenge and certificate -load error; other resource types are canceled without replacing the current -page's overlay. - -The real-Electron integration suite must prove same-tab reload, same-endpoint -HTTPS assets and WSS, different-tab documents and subresources, and -different-port isolation against the exact Electron version in -`pnpm-lock.yaml`. Any Electron upgrade that breaks those tests blocks release -until the design is revised; unit mocks are insufficient. - -### BrowserManager integration - -Add a narrow BrowserManager query API instead of exposing its internal maps: - -```ts -getManagedBrowserGuestContext(webContentsId: number): { - browserPageId: string | null - worktreeId: string | null - sessionProfileId: string | null - owner: 'desktop-webview' | 'offscreen' -} | null -``` - -For guest lifecycle, follow the existing extracted-controller precedent -(`browser-grab-session-controller.ts`, `browser-guest-ui.ts`, -`browser-download-destination.ts`), all of which BrowserManager drives by -calling a plain method on the controller directly from `registerGuest` / -`unregisterGuest`. Have BrowserManager call -`certificateTrustController.onGuestRegistered({ browserPageId, webContentsId })` -and `.onGuestRetired(webContentsId)` from those same bodies, rather than adding a -new listener/emitter registration API to BrowserManager — there is no such -pub-sub surface in `src/main/browser` today and this single consumer does not -warrant introducing one. - -`getManagedBrowserGuestContext` must recognize an attached primary desktop -guest before renderer registration, returning a null page ID, while rejecting -popup descendants that merely inherited browser policies. It must also -recognize offscreen pages after `registerOffscreenGuest` and stop recognizing a -guest immediately when BrowserManager retires or unregisters it. The controller -re-checks this ownership on every certificate event even if it still has an -in-memory grant. - -An attached desktop webview can encounter a certificate error before its -document reaches `dom-ready`, which is when BrowserPane currently registers the -guest. Keep pending challenges keyed by WebContents ID until registration -supplies the page ID, then flush the failure to the renderer. - -BrowserPane should also register idempotently on the webview's `did-attach` -event, with `dom-ready` retained as a fallback. `registerGuest` should report -whether the main process accepted the registration so a rare attach-policy race -can retry at `dom-ready`; the renderer must not cache the WebContents ID as -registered until it receives `true`. - -Offscreen pages already register before `loadURL`, so their page ID is normally -available when the certificate event fires. Bring their main-frame navigation -and `did-fail-load` observation under the same BrowserManager lifecycle used by -desktop guests; the current offscreen loader only logs a rejected `loadURL`, so -it cannot by itself populate runtime failure snapshots. - -### Renderer state - -Certificate challenges are transient runtime state and must not be added to -the persisted `BrowserPage` schema. - -Add a transient map to the browser store: - -```ts -browserCertificateFailuresByPageId: Record -``` - -The workspace session serializer must omit this map. Clear an entry when: - -- the page completes a successful main-frame navigation; -- the user starts a different navigation; -- the page or workspace closes; -- the owning browser WebContents is replaced; or -- the main process reports that the challenge expired. - -The existing `BrowserLoadError` remains the persisted diagnostic source. Add a -pure Chromium-error classifier for Electron 43's certificate range (`-200` -through `-219`, excluding the unused values) so certificate failures still -render accurate copy after restore even when no live challenge exists. A live -certificate failure's error code takes presentation precedence over a -synthesized `-1` Chromium-error-page fallback. Independently, only a live, -matching challenge controls whether `Proceed Anyway (Unsafe)` is enabled. - -Use both the page ID and attempted origin when combining the live challenge -with `BrowserLoadError`. A stale challenge from a prior navigation must never -add a proceed action to a different failure. Main-process navigation-sequence -invalidation is the security boundary; renderer cleanup is defense in depth and -keeps the UI honest. - -### Local IPC - -Extend the browser preload API with: - -```ts -onCertificateFailureChanged( - callback: (event: { - browserPageId: string - failure: BrowserCertificateFailure | null - }) => void -): () => void - -proceedCertificate(args: { - browserPageId: string - challengeId: string -}): Promise< - | { ok: true } - | { - ok: false - reason: 'expired' | 'changed' | 'ineligible' | 'missing' | 'navigated' - } -> -``` - -Register the handler with the existing browser IPC group and reuse -`isTrustedBrowserRenderer`. The main process resolves the page to its current -WebContents and then consumes the exact pending challenge. Renderer-provided -origin, error code, or certificate identity are never trusted. - -On success, main consumes the challenge, records the grant, and calls -`loadURL` with the challenge's main-owned `navigationUrl`. It must not use -`webContents.getURL()`, which may be `chrome-error://chromewebdata/` or a -previously committed page. It also must not reload if the page's current -navigation sequence no longer matches the challenge. The renderer does not -assign `webview.src` as a second side effect. - -### Remote runtime RPC - -The remote browser owner exposes equivalent behavior through: - -```text -browser.certificate.proceed -``` - -with the runtime worktree selector, remote browser page ID, and challenge ID. -Advertise support through a new `browser.certificate-trust.v1` capability only -when the runtime has both a browser backend and the certificate controller. -Keep the method out of the mobile-scope RPC allowlist; it is for an -authenticated runtime-scope Orca client presenting browser chrome, not a guest -page or unauthenticated caller. - -Extend `RuntimeMobileSessionBrowserTab` compatibly with optional `loadError` -and `certificateFailure` fields. Both are emitted from live runtime state. -`loadError` keeps the existing `BrowserPage` last-known diagnostic semantics, -including workspace persistence; `certificateFailure` is transient and is -never written to workspace persistence. Controller changes, challenge expiry, -navigation, and offscreen `did-fail-load` must mark the runtime session snapshot -dirty so clients do not wait for unrelated state to change. Web clients and -desktop clients reconcile these fields through the existing remote page-handle -mapping. Each full snapshot replaces the prior transient certificate failure -for that page, including clearing it when the field is absent. An older runtime -without the capability always clears any previously mirrored proceed action. - -When the active page has a remote owner, BrowserPane routes approval to that -environment. It must not call local desktop IPC. If the runtime lacks the new -capability, the UI still classifies the certificate failure accurately but does -not render `Proceed Anyway (Unsafe)`. `Try HTTPS`, retry, and other navigation -actions continue through the existing owner-aware navigation path rather than -touching a local webview for a remote-owned page. - -### Scheme recovery - -Add pure functions in the shared browser URL domain: - -```ts -classifySchemeLessLocalDevAddress(rawInput: string): URL | null -isEligibleLocalCertificateHost(hostname: string): boolean -toHttpsRecoveryUrl(rawUrl: string): string | null -``` - -`classifySchemeLessLocalDevAddress` preserves the exact current HTTP-default -input set and is shared by address-bar normalization and tab-create entry. It -is deliberately broader than certificate eligibility. - -`isEligibleLocalCertificateHost` accepts a parsed/canonical hostname; normalize -case and an optional trailing dot and handle the brackets returned by -`URL.hostname` for IPv6. Validate IPv4 octets and DNS label boundaries instead -of using a substring or loose suffix check. - -`toHttpsRecoveryUrl` returns a value only for an HTTP URL with an eligible -loopback host. It changes `protocol` to `https:` on a parsed `URL` object and -preserves credentials, hostname, an explicit non-default port, path, query, and -fragment. When no port is present, including when the parsed HTTP URL normalized -`:80` away, the result uses HTTPS's default port 443. It never probes either -protocol. - -Both address-bar submission and tab-create entry use the shared local-address -classifier. Existing advertised workspace-port URLs remain authoritative and -require no new probing. - -## Security Model - -### Invariants - -- Default certificate verification remains strict. -- Only a trusted Orca renderer or authenticated runtime-scope Orca client can - request approval. -- The renderer cannot choose the origin, certificate digest, error, - WebContents, or eligibility result. -- A grant is exact-match only and never applies across tabs, ports, - certificates, or browser-owning processes. -- Session-cached accepted endpoints remain request-gated until process exit, - even after their original WebContents or bounded grant is removed. -- Certificate PEM/DER data and certificate digests do not enter workspace - state, history, telemetry, or ordinary logs. -- Subresource and iframe certificate failures cannot mint an approval action; - they can consume an already-approved exact-endpoint grant on the same - WebContents. -- The proceed affordance exists only in Orca chrome; guest content has no - direct approval channel. Main-process challenge validation remains the - security boundary and does not assume proof of a physical click. -- A remote client cannot approve a certificate in a different runtime - environment. -- Starting another main-frame navigation invalidates the pending approval in - the browser-owning process, not only in renderer state. - -### Threat cases - -| Threat | Required behavior | -| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| Compromised web page sends forged IPC | Guest WebContents has no preload API; trusted-renderer check rejects it. | -| Compromised renderer invents a challenge | Main consumes only a server-created pending challenge tied to the current page and WebContents. | -| Certificate changes after approval | Leaf-digest mismatch rejects; a second untrusted leaf for that endpoint cannot be bypassed until restart. | -| Same host uses a different port | Secure-endpoint mismatch rejects. | -| Another tab visits the approved endpoint | WebContents mismatch rejects. | -| Approved tab later loads a foreign top document | Grant is invalidated when the main frame navigates to a different endpoint; the new document cannot reuse it. | -| Challenge is replayed | Challenge ID is single-use and expires. | -| User leaves the failed page before clicking | Navigation-sequence mismatch rejects without reloading the stale URL. | -| Approved page opens same-endpoint WSS/HMR | Existing endpoint grant permits it; WSS cannot create a grant by itself. | -| Public site presents an untrusted certificate | Host eligibility fails; no proceed action is available. | -| Remote page approval is sent locally | Ownership routing and remote page handles select the browser-owning runtime. | -| App restarts after approval | No persisted grant exists. | - -## Failure Handling - -- If a certificate event arrives before guest registration, retain it by - WebContents ID and flush after registration. -- If guest registration never completes, expire the pending challenge without - notifying unrelated pages. -- If `Proceed` arrives after expiry, navigation, or WebContents replacement, - return a typed failure and leave strict verification in place. -- If reload fails for a different reason, clear the certificate challenge and - present the new load error. -- If the same rejected challenge fires repeatedly in one navigation, coalesce - it without rotating the challenge ID or stacking announcements. -- If the certificate-error listener cannot parse the URL or leaf certificate, - reject normally and do not offer approval. -- If the remote connection drops during approval, keep the local UI stable and - surface the runtime error inline after the latency threshold. -- If an unexpected controller error occurs, call Electron's callback once with - `false`; never leave the request suspended. -- If an accepted main-frame page later opens a new same-endpoint TLS connection, - apply the exact grant to that HTTPS/WSS subrequest. Reject unmatched - subrequests silently without replacing the main-frame overlay. -- If an accepted endpoint presents a different untrusted leaf, reject it without - replacing the process-lifetime accepted identity or offering a second bypass. - -## Data and Privacy - -No migration is required. - -Add no new product analytics in v1. Do not record URLs, certificate digests, -challenge IDs, certificate subjects, or approval results. If product analytics -becomes a requirement later, define coarse actions in the existing typed -feature-interaction catalog as a separate reviewed change. - -Existing diagnostics may retain the Chromium numerical error code, as they do -for other browser load failures. Debug logging must contain at most the error -name, page ID, owner kind, and normalized loopback host class; omit query -strings, full URLs, and certificate digests. - -## Test Plan - -### Shared URL tests - -- Explicit HTTP and HTTPS local URLs remain unchanged. -- Scheme-less `localhost`, IPv4 loopback, and IPv6 loopback still normalize to - HTTP. -- Tab-create and address-bar local-address classification stay in parity for - the full legacy set, including wildcard and bracketed IPv6 inputs. -- The broader scheme-less classifier is not accidentally substituted for the - stricter certificate-eligibility predicate. -- `toHttpsRecoveryUrl` preserves host, an explicit non-default port, path, - query, and fragment; a URL with no parsed port changes from HTTP's default 80 - to HTTPS's default 443. -- `Try HTTPS` is unavailable for HTTPS, file URLs, public hosts, wildcard bind - hosts, private LAN hosts, and invalid URLs. -- Loopback eligibility covers `localhost`, trailing-dot localhost, - `.localhost` subdomains with and without a trailing dot, the full valid IPv4 - 127/8 range, and bracketed or unbracketed `::1` after URL parsing. -- Reject malformed IPv4, arbitrary bracketed IPv6, `0.0.0.0`, `::`, and names - that only contain the word `localhost`. - -### Main-process trust-controller tests - -- Unmanaged, retired, and popup WebContents are rejected without pending - challenges. -- A managed `-202` loopback failure creates one pending challenge and calls - `callback(false)`. -- Every event path calls its callback exactly once; only an accepted grant calls - `preventDefault()` and `callback(true)`. -- The digest is SHA-256 over DER leaf bytes and is stable across PEM formatting. -- An exact accepted grant covers same-endpoint HTTPS assets, WSS, and iframes on - the same WebContents without allowing those requests to mint a challenge. -- Wrong page, WebContents, origin, port, certificate digest, error type, and - challenge ID fail closed. -- A token is single-use and expires after five minutes. -- Repeated identical failures in one navigation coalesce; a new navigation - invalidates the token and rotates the challenge ID. -- Approving after navigation returns `navigated` and never reloads the old URL. -- Approval loads the main-owned challenged URL, not the current Chromium error - URL or a renderer-supplied URL. -- Certificate rotation fails closed and cannot replace the process-lifetime - accepted endpoint identity. -- WebContents destruction removes pending challenges and grants. -- Bounds evict the oldest pending challenge/grant. -- Registering a page after an early certificate event flushes the event to the - correct renderer. -- Concurrent tabs and session profiles remain isolated. - -### IPC and runtime tests - -- Browser certificate IPC rejects untrusted senders and malformed arguments. -- Renderer-supplied origin or certificate-identity fields are neither accepted - nor used. -- Local approval reloads only the currently mapped guest. -- Remote approval calls the owning environment's RPC with the remote page ID. -- Local IPC is not called for a remote-owned page. -- Mobile-scope runtime clients cannot call the approval method. -- Older runtimes without `browser.certificate-trust.v1` never show an enabled - proceed action. -- Runtime snapshots carry live load errors and certificate failure state, - publish immediately on changes, and do not add certificate challenges to - persisted session schemas. -- A snapshot that omits or clears a challenge removes the mirrored proceed - action rather than leaving a stale one behind. -- Two runtime environments with identical page IDs cannot approve each other's - challenge. - -### Renderer tests - -- `-202` renders certificate-specific copy and no server-running hint. -- A live certificate challenge still wins when Chromium's error-page fallback - produced diagnostic code `-1`. -- A live eligible challenge renders `Proceed Anyway (Unsafe)`. -- A restored `-202` load error without a live challenge remains accurate but - cannot proceed. -- Other certificate errors render accurate copy without v1 bypass. -- Local HTTP failures render `Try HTTPS`; HTTPS and non-loopback failures do not. -- Starting a new navigation clears stale certificate state. -- Success clears the overlay. -- Proceed disables immediately, delays the spinner, and preserves its width. -- Expired/changed responses show the inline recovery message. -- Keyboard focus and `aria-live` behavior do not regress the address bar. -- `Copy Address` remains available, interactive content is not blanket-dimmed, - and all new strings are present in the localization catalog. - -### Electron integration - -Run a local HTTPS server with a generated certificate whose SAN includes -`localhost` but whose authority is not trusted. - -1. `https://localhost:` fails with `-202` and shows certificate copy. -2. `Proceed Anyway (Unsafe)` reloads and renders the page. -3. HTTPS assets and a WSS echo/HMR endpoint on the same host and port load after - approval; a different-port asset remains blocked. -4. Reload in the same tab remains allowed without broadening the grant. -5. A second tab using the same session profile, origin, and certificate remains - blocked. This is a release-blocking assertion against the pinned Electron - binary, not a mocked controller test. -6. Navigating away before clicking makes the old challenge unusable and does - not pull the tab back to the failed URL. -7. Replacing the certificate keeps the original tab blocked and offers no - second bypass until the browser-owning process restarts. -8. Closing and recreating the browser surface removes the grant. -9. Restarting Orca removes the grant. -10. A system-trusted local certificate loads without an interstitial. -11. A public/non-loopback untrusted origin has no proceed action. -12. A hostname/SAN mismatch shows certificate copy but no proceed action. -13. `localhost:` first attempts HTTP and its failure offers `Try HTTPS`. - -Repeat the ownership-sensitive cases for: - -- a local desktop webview; -- an SSH local port forward opened in the desktop browser, using a certificate - whose SAN matches the browser-facing forwarded hostname; and -- a headless/offscreen browser page owned by `orca serve` under Linux/Xvfb. - -## Implementation Plan - -1. Add the shared scheme-less local classifier, strict certificate-host - predicate, secure-endpoint canonicalizer, and HTTPS-recovery function with - focused tests. -2. Consolidate address-bar and tab-create local classification without changing - current scheme defaults. -3. Add BrowserManager ownership/registration/retirement hooks, early - `did-attach` registration with a `dom-ready` fallback, and offscreen load - failure observation. -4. Add shared certificate failure/result types and the main-process trust - controller, including navigation-sequence invalidation and the app-level - listener. Its certificate-event handling resolves ownership through the - BrowserManager hooks from step 3, so those must land first. -5. Add local IPC, preload APIs, and transient renderer state. -6. Add the certificate-specific classification and copy as new exported - functions/branches in the existing `browser-notices.ts` (which already owns - load-failure copy via `formatLoadFailureDescription` / - `formatLoadFailureRecoveryHint`), and extract only the overlay JSX into a - focused `browser-load-failure-overlay.tsx` module instead of growing the - already grandfathered `BrowserPane.tsx`; do not add or extend a max-lines - disable. -7. Add certificate-specific and `Try HTTPS` UI, preserve existing recovery - actions, and update the localization catalog. -8. Add the offscreen/runtime capability, live snapshot fields and publication, - and approval RPC. -9. Add unit, IPC, renderer, runtime, and real-Electron integration coverage. -10. Run formatting, typecheck, lint, localization verification, targeted tests, - and cross-platform manual validation. - -## Rollout - -No feature flag is required for accurate error copy or `Try HTTPS` because both -paths preserve strict certificate verification. - -The proceed path should ship only after desktop and offscreen ownership tests -pass. If remote runtime support cannot land in the same release, gate the -button on `browser.certificate-trust.v1`; local desktop support may ship while -older/remote owners retain accurate error copy and trusted-CA guidance. - -Do not close issue #8454 until a packaged build has been validated against a -real self-signed local HTTPS server. Unit mocks alone do not establish Electron -certificate-event ordering. - -## UI Quality Bar - -- Follow `docs/STYLEGUIDE.md` and the adjacent browser failure overlay. -- Use only existing semantic tokens, shadcn buttons, and lucide icons. -- Keep the overlay quiet and monochrome; certificate failure must not introduce - an amber warning treatment. -- Copy must distinguish connection, DNS, and certificate failures without - claiming more than the Chromium error proves. -- Buttons must remain on one line at supported pane widths or wrap as one - deliberate group without overlap. -- Validate light/dark mode, macOS/Windows/Linux font metrics, and 200 ms remote - latency. -- Capture review evidence for local HTTP failure, eligible certificate failure, - ineligible certificate failure, accepted certificate, and the same browser - chrome after recovery. - -## Release Gates and Open Risks - -- The pinned Electron binary must prove that the session request gate blocks a - second tab, sibling HTTPS subresources/WSS, and a second port even when - Chromium does not re-emit `certificate-error`. If any isolation assertion - fails, omit the proceed path or redesign it around a dedicated ephemeral - partition; do not silently widen the grant. -- Same-endpoint WSS and fresh HTTPS subresource connections must work after - approval. A main document that renders while HMR/API traffic remains blocked - does not fix the local-development use case. -- A remote/headless certificate failure must reach the client as both accurate - load-error copy and a live challenge. Logging a rejected offscreen `loadURL` - is not sufficient. -- Stale approval must be rejected in the browser-owning process after any new - main-frame navigation, including when renderer cleanup or a remote snapshot - is delayed. -- SSH-forward validation must use a certificate whose SAN matches the - browser-facing hostname. Authority bypass must not mask a hostname mismatch. -- The app-level listener and real-certificate tests must pass on macOS, Windows, - and Linux/Xvfb. Platform trust-store differences do not justify enabling a - broader error allowlist. - -## Review Decisions and Follow-ups - -### From 2026-07-12 review - -- The integrated browser is part of the development workflow (including - automation, annotations, and remote ownership), so `Open Externally` and trusted - local-certificate guidance remain safer alternatives but do not replace an - explicit in-browser decision. -- V1 intentionally limits approval to `ERR_CERT_AUTHORITY_INVALID (-202)`. - Hostname/SAN, date, revoked, and malformed-certificate failures remain blocked; - broadening that set requires a separate security review and real-certificate - tests. -- Raw error details remain available to existing diagnostics rather than adding a - new disclosure affordance to the quiet recovery overlay. diff --git a/docs/reference/direct-url-or-file-entry.md b/docs/reference/direct-url-or-file-entry.md deleted file mode 100644 index 8664965147f..00000000000 --- a/docs/reference/direct-url-or-file-entry.md +++ /dev/null @@ -1,146 +0,0 @@ -# Direct URL Or File Entry - -## Problem - -The tab bar `+` menu only offers fixed actions: terminal, browser, new markdown, and open markdown in `src/renderer/src/components/tab-bar/TabBar.tsx`. It has no text entry point for a user who already knows the URL or file path they want. - -Quick Open already loads files for the active worktree through `listRuntimeFiles`, watches the active SSH target status, excludes nested linked worktrees, ranks via `prepareQuickOpenFiles`/`rankQuickOpenFiles`, and opens a selected match with `openFile`. That flow is modal and file-only. It does not live in the `+` menu, does not accept URLs, and cannot create a named new file from the typed query. - -The runtime file client has the required local/SSH/runtime primitives, with caveats: - -- `listRuntimeFiles(context, { rootPath, excludePaths })` returns relative paths only and can fail for auth, missing provider, ripgrep/size, or stale worktree reasons. -- `statRuntimePath(context, absolutePath)` is a one-path existence/type check. -- `createRuntimePath(context, absolutePath, 'file')` creates a single empty file and creates parent directories on the current local, SSH, and runtime-backed paths. Directory creation has separate no-clobber/recursive semantics elsewhere in the file stack, so v1 should create files directly and not expose directory creation as a separate action. - -## Goal - -Add an entry field at the top of the tab bar `+` menu so users can type: - -1. A URL to open an Orca browser tab. -2. An existing file name/path to open an editor tab. -3. A new relative file path to create in the active worktree and open. - -The behavior must work from both the titlebar tab strip and split-group tab strips. File operations must route through `RuntimeFileOperationArgs`; browser/editor creation must preserve the target group. - -## Non-goals - -- Do not replace global Quick Open. -- Do not add persisted launcher state. -- Do not support standalone directory creation. -- Do not open external URLs outside Orca. -- Do not make URL/file detection configurable. - -## Design - -1. Add a `TabBarCreateEntry` surface inside `TabBar`'s dropdown content above the fixed rows. Use the existing `DropdownMenu` shell, but render the input in a plain form/container, not as a `DropdownMenuItem`; Radix menu item typeahead/selection should not own text input keystrokes. Stop propagation only where required for input typing, and close the dropdown only after a successful submit. - -2. Extend `TabBarProps` with a presentation callback that resolves success by returning and failure by throwing: - - ```ts - onOpenEntry?: (args: { query: string; worktreeId: string; groupId: string }) => Promise - ``` - - `TabBar` owns input text, pending state, focus, and dropdown close behavior. It does not create browser tabs or files. `groupId` should be `groupId ?? worktreeId`, matching the existing `resolvedGroupId` fallback. - -3. Add a small hook/helper pair instead of duplicating Quick Open logic: - - `useTabEntryFileList` loads the file list when the menu opens, using the same inputs as `QuickOpen`: active worktree path, `getConnectionId(worktreeId)`, nested worktree exclusions, and active SSH target status. It cancels stale requests on close or key changes. - - A pure classifier/opening helper accepts the query, file-list snapshot, load/error state, worktree metadata, runtime context, and target group. - -4. Wire the callback in both owners: - - `Terminal.tsx` titlebar fallback resolves the current active worktree and target group the same way `handleNewTab` / `handleNewBrowserTab` do. - - `useTabGroupWorkspaceModel` passes its explicit `worktreeId` and `groupId`. Do not rely on ambient `activeGroupIdByWorktree`; split-group `+` can be invoked from an unfocused group. - -5. Classify submissions in this order: - - Empty after trim: reject inline. - - Explicit URL: accept only `http://` and `https://` URLs with a parseable host. - - Existing file: once the file-list snapshot is ready, normalize query separators for matching, prefer exact relative-path match, then exact basename match, then `rankQuickOpenFiles`. Before opening, `statRuntimePath` the matched absolute path and reject directories/stale missing matches instead of blindly opening stale list entries. - - Host-like URL: only after there is no existing file match, accept strict bare hosts such as `example.com`, `localhost:3000`, or `127.0.0.1:3000`, normalized to `https://...` when no scheme is present. Do not run host-like URL parsing for bare input containing `/` or `\`; `new URL('https://docs/readme.md')` parses, so parsing alone is not a path/file guard. Also reject common source/document filename extensions such as `md`, `ts`, `tsx`, `js`, `jsx`, `json`, `yml`, `yaml`, `toml`, `css`, `html`, and `py` so `README.md` and `src/foo.test.ts` stay file/create candidates. - - New file: only after file listing has completed successfully with no existing-file match and no host-like URL match. Treat the query as a relative worktree path. If listing fails, allow explicit `http://` / `https://` URLs only; keep bare host-like inputs blocked because they cannot be disambiguated from files. - -6. Validate new file paths before joining: - - Reject POSIX absolute paths, Windows drive paths, UNC paths, `~`, empty path, trailing slash, control characters, `.` / `..` segments, and empty raw segments such as `a//b`. - - Normalize `\` to `/` only after absolute-path checks, then run segment validation on the normalized path too so traversal like `a\..\b` is still rejected. - - Build the absolute path with `joinPath(worktreePath, relativePath)`, then create with `createRuntimePath(context, absolutePath, 'file')`. - - On `EEXIST` / "exists", immediately `statRuntimePath`; if it is now a file, open it. If it is a directory, show an error. This handles another window/process winning the create race. - -7. Open actions: - - URL: in paired web clients, call `createWebRuntimeSessionBrowserTab({ worktreeId, url, targetGroupId: groupId })`; otherwise call `createBrowserTab(worktreeId, url, { activate: true, targetGroupId: groupId, title: url })`. Do not use `openNewBrowserTabInActiveWorkspace`; it only opens the default URL. - - Existing/new file: call `openFile(fileInfo, { preview: false, targetGroupId: groupId })` with `language: detectLanguage(relativePath)`. Include the active runtime environment owner as `runtimeEnvironmentId` through the normal `openFile` fallback; do not suppress runtime ownership. - -8. Preserve current fixed actions. Existing `New Terminal`, `New Browser Tab`, `New Markdown`, `Open Markdown...`, and quick-launch rows remain below the entry and keep their current shortcuts/icons. - -9. Surface errors with existing toast or compact inline text. Keep the dropdown open and preserve the query on validation/runtime errors. For Quick Open's special ripgrep guidance, either extract its parser/UI deliberately or show the cleaned error string; do not duplicate a private parser inline. - -## Data Flow - -- User opens the `+` menu. -- `TabBarCreateEntry` focuses the input and `useTabEntryFileList` starts/reuses the menu-local file-list request. -- User types; the menu may show the best existing-file match or "create file" affordance based on the current snapshot. -- Enter calls `onOpenEntry({ query, worktreeId, groupId })`. -- Helper classifies and dispatches: - - URL -> browser tab creation with target group. - - Existing file -> stat matched path -> `openFile(..., { targetGroupId })`. - - New file -> validate -> `createRuntimePath(..., 'file')` -> `openFile(..., { targetGroupId })`. -- Success closes the dropdown. Failure keeps it open. - -## Edge Cases - -- No active worktree: disable the entry, including URL entry, because Orca browser tabs are worktree-scoped. -- SSH/runtime connection not ready: mirror Quick Open by keying the list request on active target status. Non-URL submissions are disabled while loading/connecting to avoid creating a duplicate before the real list arrives. -- File listing failure: explicit `http://` / `https://` URL submissions still work; file and bare host-like submissions are blocked with the cleaned list error. -- Ambiguous host-like/file names: an existing listed file wins over bare host-like URL normalization. Explicit `http://` or `https://` input is the escape hatch when the user wants a browser tab despite a file-name collision. -- File list stale because another window/process added or removed a file: stat before opening matched files; handle create `EEXIST` by stat-and-open. -- Existing directory match: show an error; do not open as an editor tab. -- Internal spaces in file paths are allowed. Leading/trailing whitespace is trimmed. Control characters are rejected. -- Windows-style separators match existing relative paths after normalization, but Windows absolute and UNC paths are rejected for creation. -- Duplicate Enter while pending is disabled. -- Browser creation in paired web/mobile clients must use `createWebRuntimeSessionBrowserTab`; local desktop uses `createBrowserTab`. -- External file-watch invalidation is not required for v1. The menu-local list reloads on each open and on worktree/connection/status changes; successful create can close the menu without mutating the list. - -## Test Plan - -- Unit test URL classification: schemes, host-like domains, localhost/IP ports, listed file named `example.com` winning over host-like normalization, `README.md`/`readme.md`, `src/foo.test.ts`, `docs/readme.md`, whitespace, and invalid schemes. -- Unit test path validation: Windows/POSIX absolute paths, UNC, `~`, traversal, empty segments, trailing slash, control characters, spaces, and separator normalization. -- Unit test existing-file selection with `prepareQuickOpenFiles`/`rankQuickOpenFiles`: exact path beats basename, basename beats fuzzy, stale stat failure blocks open, directory stat blocks open. -- Unit/helper test new-file creation with `RuntimeFileOperationArgs`, `createRuntimePath`, `statRuntimePath`, EEXIST stat-and-open, and SSH/runtime connection context. -- Component test `TabBar`: input renders above fixed rows, focuses on open, typing does not close the menu, Enter awaits the callback, success closes, failure preserves text, fixed actions still fire. -- Store/model tests: titlebar and split-group callbacks pass `targetGroupId`; URL creation uses `createWebRuntimeSessionBrowserTab` in web runtime and `createBrowserTab` otherwise. -- Electron validation: open URL, open existing file by exact path/name, create nested new file, invalid traversal/absolute path error, and smoke-test existing menu actions. - -## UI Quality Bar - -- Follow `docs/STYLEGUIDE.md` and existing `DropdownMenu`/`Input` tokens. Do not add custom colors, shadows, or a modal-like panel inside the menu. -- The input is the first focus target and must not break menu keyboarding. -- The menu remains compact; loading, match preview, create preview, and error rows fit at the current menu width without overlap or awkward height jumps. -- Long typed paths scroll/truncate within the input; fixed rows retain icons, shortcuts, hover states, and dense spacing. - -## Review Screenshots - -Attach evidence to the PR conversation; do not commit images. - -1. `+` menu open in a normal workspace with the entry focused and fixed rows visible. -2. Typed URL state. -3. Typed existing-file query with visible best match. -4. Typed new-file path/create state. -5. Rejected absolute/traversal path error. -6. Adjacent-feature smoke: fixed menu rows still visible and aligned. - -## Rollout - -1. Add classifier/path-validation helper and tests. -2. Add menu-local file-list hook by extracting the reusable Quick Open loading inputs, not by copying private UI-only parsing. -3. Add `TabBarCreateEntry` and wire it into `TabBar`. -4. Add titlebar and split-group callbacks. -5. Add component/store tests. -6. Run typecheck, lint, targeted tests, UI review, and Electron validation with screenshots. - -## Lightweight Eng Review - -- Scope: Correctly scoped to the tab bar `+` menu. The implementation must not mutate Quick Open behavior except for extracting reusable search/listing code. -- Architecture/data flow: Good if `TabBar` stays presentational and all worktree/runtime/group decisions live in owners plus a shared helper. The original `onOpenEntry(query, groupId?)` shape was under-specified; include `worktreeId` and a resolved `groupId`. -- Failure modes: Must explicitly handle loading list, failed list, stale list, directory matches, create races, no active worktree, no SSH provider yet, and paired web runtime browser creation. -- Feasibility: Specific URL browser tabs cannot go through `openNewBrowserTabInActiveWorkspace` because that action uses the default URL. `createRuntimePath(..., 'file')` is feasible and creates parent directories for current local/SSH/runtime paths, but do not expose recursive directory creation as a separate v1 behavior. -- Concurrency/invalidation: A menu-local file list is acceptable if every matched file is statted before open and `EEXIST` is handled on create. No global cache is needed. -- Performance/blast radius: Listing on menu open has Quick Open's cost class, but the menu is more casually opened than the modal; cancel stale loads and key requests tightly by worktree path, connection, exclusions, and SSH status. -- Tests: Add pure helper tests first; then component and store/model tests. Electron screenshots are required because Radix menu focus/input behavior is the highest-risk UI part. -- Residual risk: Host-like URL heuristics can surprise users. Keep the heuristic narrow and prefer file matches over host-like normalization whenever the query contains path separators or matches a listed file. diff --git a/docs/reference/feature-discovery-interaction-tracking.md b/docs/reference/feature-discovery-interaction-tracking.md deleted file mode 100644 index a9e9eb1a46f..00000000000 --- a/docs/reference/feature-discovery-interaction-tracking.md +++ /dev/null @@ -1,68 +0,0 @@ -# Feature Discovery Interaction Tracking - -This document defines local feature-interaction state used to decide whether Orca should still teach a feature with an education surface such as a tour or feature tip. - -## Decision - -Track first meaningful interaction plus local interaction count for education-targeted features in `PersistedUIState.featureInteractions`. - -Do not upload this state as broad analytics. Product analytics should continue to use bounded telemetry events and downstream product events. Local interaction state answers a different question: "Has this user already found enough of this feature that education would be redundant?" - -## Rules - -- Add a `FeatureInteractionId` in `src/shared/feature-interactions.ts` before using it. -- Preserve `firstInteractedAt`, and increment `interactionCount` on each later meaningful interaction. -- Prefer explicit actions over passive visibility. -- Passive visibility is acceptable only when opening the surface is itself the product use, such as opening Tasks. -- Record after persisted UI is hydrated by using `recordFeatureInteraction(...)`; it no-ops before hydration. -- Keep IDs stable. If the meaning changes materially, add a new ID. -- Do not include user text, paths, URLs, repo names, branch names, hostnames, commands, prompts, or tokens in this state. -- Old records without `interactionCount` hydrate as `interactionCount: 1`. - -## Feature Catalog - -| Feature | Interaction ID | Record when | Education use | -| ------------------------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. | -| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. | -| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. | -| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Keep the existing Floating Workspace tour, and suppress future education about the global terminal/browser/markdown workspace. | -| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. | -| Computer Use setup | `computer-use-setup` | Computer Use was selected in legacy onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. | -| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. | -| Mobile pairing | `mobile-pairing` | Mobile is enabled or a mobile pairing QR/code is generated. | Suppress future mobile-pairing tips. No contextual tour is planned for this branch. | -| Browser element grab | `browser-grab` | Browser grab mode is started, an element is copied, or an element screenshot is copied. | Suppress future tips about grabbing page context once the user has used the element picker. | -| Browser annotations | `browser-annotations` | Browser annotation mode is started, an annotation is added, browser annotations are copied, or browser annotations are cleared. | Suppress future tips about annotating local pages and sending concrete UI feedback to an agent. | -| Cookie import | `cookie-import` | Browser cookies are imported from another browser or file, imported through runtime browser profile methods, or default imported cookies are cleared. | Suppress future cookie-import education without conflating cookie setup with Agent Browser Use itself. | -| Workspace board actions | `workspace-board-actions` | A card/status action, lane configuration, density change, pin drop, or board drag action is used. | Keep the existing workspace-board tour, and avoid repeating deeper board-action education after real use. | -| Automation creation | `automation-created` | A local automation or external Hermes cron is created. | Keep the existing Automations tour, and suppress creation-focused education after the user creates one. | -| Automation run | `automation-run` | A local or external automation run is manually queued. | Keep the existing Automations tour, and suppress run/inspection education after the user queues a run. | -| Resource Manager | `resource-manager` | The Resource Manager status-bar popover is opened, or its status-bar visibility is toggled in Appearance/status-bar controls. | Suppress future tips about CPU, memory, session, daemon, and workspace disk-scan controls after the user has found the manager. | -| Workspace cleanup / disk space | `workspace-cleanup` | The Space page opens, a workspace disk scan starts/cancels, or cleanup removes scanned workspace rows. | Suppress future tips about scanning workspace disk usage and reclaiming old workspace storage. | -| Ports | `ports` | The Ports popover opens, the Ports status-bar item is configured, external ports are expanded, or a port is opened/copied/stopped. | Suppress future tips about discovering and acting on workspace ports. | -| SSH | `ssh` | SSH status opens, SSH status-bar visibility changes, a target is added/imported/tested, or an SSH target is connected/disconnected. | Suppress future SSH setup/status tips after the user has interacted with remote target controls. | -| Provider usage tracking | `usage-tracking` | Stats & Usage is opened, Claude/Codex/OpenCode usage analytics are enabled, provider usage details are opened from the status bar, Gemini usage/OAuth is configured, or provider usage status-bar toggles are changed. | Suppress future tips about where to find token/rate-limit/usage tracking for Claude, Codex, Gemini, OpenCode, and related providers. | -| Claude account switching | `claude-account-switching` | A Claude managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about using multiple Claude accounts once the user has started account management. | -| Codex account switching | `codex-account-switching` | A Codex managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about Codex account switching and restart follow-up flows. | -| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. These are real workspace tabs, not workspace board cards. | -| Split panes | `terminal-panes` | A split group is created from the pane shortcut/menu, a split is resized, or panes are merged. | Suppress future tips about pane-level split workflows. | -| Tab splits | `tab-splits` | A workspace tab is moved into another pane or split into a new pane from a tab split/drop action. | Suppress future tips about tab-level split workflows separately from pane creation/resizing. | -| Agent Browser Use setup | `agent-browser-setup` | Browser Use was selected in legacy onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Browser Use setup. | -| Agent Browser Use | `agent-browser-use` | A successful non-profile `browser.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually driven Orca's browser. | -| Agent Orchestration setup | `agent-orchestration-setup` | Orchestration was selected in legacy onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. | -| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. | -| Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. | - -## Surface-Level Features - -Orca also records surface-level interactions for feature areas where opening the surface is itself a meaningful discovery signal: - -- `workspace-board`: workspace board opened -- `workspace-agent-sessions`: workspace terminal surface opened with agent split controls visible -- `browser`: non-blank browser page viewed -- `tasks`: Tasks page opened -- `automations`: Automations page opened -- `floating-workspace`: floating workspace opened -- `workspace-creation`: workspace creation flow opened - -These remain intentionally separate from action-level IDs such as `workspace-board-actions`, `automation-created`, and `automation-run`. Surface-level IDs answer "has the user entered the feature area?" Action-level IDs answer "has the user performed the deeper workflow?" diff --git a/docs/reference/feature-education-retention-analytics.md b/docs/reference/feature-education-retention-analytics.md deleted file mode 100644 index df259f43172..00000000000 --- a/docs/reference/feature-education-retention-analytics.md +++ /dev/null @@ -1,182 +0,0 @@ -# Feature Education Retention Analytics - -This document records how Orca should use contextual-tour telemetry to evaluate retention and feature adoption. - -## Decision - -Track only contextual tour exposure and outcome in PostHog: - -- `contextual_tour_shown` -- `contextual_tour_outcome` - -Do not add broad telemetry that mirrors local education state, such as "feature interaction first recorded." Local state like `featureInteractions` exists to suppress redundant education on the user's machine; it is not the analytics source of truth. - -Feature adoption analysis should join tour cohorts to existing downstream product events. Add new telemetry only for concrete user actions that are not already represented by an existing event. - -Automatic contextual tours are limited to new users for this rollout. The local `contextualToursAutoEligible` flag is set once after persisted UI and onboarding state load: users still in first-run onboarding become eligible; users with closed onboarding become ineligible. This avoids surprising existing users with education for surfaces they may already understand. - -## Product Questions - -Use the data to answer: - -- Do users who see contextual tours retain better at D1 and D7? -- Do completed tours correlate with higher downstream feature adoption than skipped or cancelled tours? -- Which tours have high skip/cancel rates and should be revised or suppressed? -- Which tours introduce features that users later use in real workflows? - -## Event Contract - -### `contextual_tour_shown` - -Emitted once when a contextual tour first renders a measured target. - -Payload: - -- `tour_id`: `workspace-board`, `workspace-agent-sessions`, `browser`, `tasks`, `automations`, or `workspace-creation` -- `source`: bounded source enum from `src/shared/feature-education-telemetry.ts` -- `was_feature_previously_interacted`: boolean from local education state at the moment the tour is shown - -### `contextual_tour_outcome` - -Emitted once when a contextual tour ends. - -Payload: - -- `tour_id`: same enum as `contextual_tour_shown` -- `source`: same bounded source enum -- `outcome`: `completed`, `skipped`, or `cancelled` -- `steps_seen`: bounded integer -- `total_steps`: bounded integer - -## Dashboard Plan - -Create a PostHog dashboard named **Feature Education Retention**. - -### Tile: Tour Exposure Volume - -Question: How often are tours shown? - -Insight: - -- Event: `contextual_tour_shown` -- Breakdown: `tour_id` -- Visualization: stacked time series by day - -Action: - -- If volume is unexpectedly high, inspect gating before expanding tours. -- If a tour is never shown, verify the surface gate and target selectors. - -### Tile: Tour Outcome Rate - -Question: Which tours are completed, skipped, or cancelled? - -Insight: - -- Event: `contextual_tour_outcome` -- Breakdown: `tour_id` and `outcome` -- Visualization: stacked bar or table - -Action: - -- High skipped rate means copy, timing, or audience targeting needs revision. -- High cancelled rate means UI state is likely interrupting tours or targets are disappearing. - -### Tile: Median Steps Seen - -Question: How far do users get before leaving? - -Insight: - -- Event: `contextual_tour_outcome` -- Metric: median `steps_seen` -- Breakdown: `tour_id` -- Optional derived ratio: `steps_seen / total_steps` - -Action: - -- If most users see only step 1, shorten or reprioritize the tour. -- If later steps are rarely reached, do not rely on those steps for critical education. - -### Tile: D1/D7 Retention By Tour Outcome - -Question: Does tour status correlate with retention? - -Insight: - -- Cohort A: users with `contextual_tour_outcome` where `outcome = completed` -- Cohort B: users with `contextual_tour_outcome` where `outcome = skipped` -- Cohort C: users with `contextual_tour_outcome` where `outcome = cancelled` -- Cohort D: users with no `contextual_tour_shown` -- Retention: return to `app_opened` on day 1 and day 7 -- Breakdown: `tour_id` where PostHog supports it, otherwise build one insight per tour - -Action: - -- Keep or expand tours whose completed cohort outperforms skipped/no-tour cohorts. -- Rework or suppress tours whose shown cohort underperforms no-tour users. - -### Tile: Downstream Feature Adoption By Tour - -Question: Do users later use the feature taught by the tour? - -Build funnels from tour events to real product events: - -- `tasks`: `contextual_tour_outcome` -> workspace creation from task-linked flows where existing telemetry exposes the source; if the current event is insufficient, add a targeted task-started-workspace event. -- `workspace-creation`: `contextual_tour_outcome` -> `workspace_created` -- `browser`: `contextual_tour_outcome` -> browser grab/annotation events. If these do not exist, add targeted events for the concrete actions, not a broad feature-interaction event. -- `automations`: `contextual_tour_outcome` -> automation creation/run events. If these do not exist, add targeted events for create/run. -- `workspace-board`: `contextual_tour_outcome` -> board-specific actions such as card moved/status changed. If these do not exist, add targeted events for those actions. -- `workspace-agent-sessions`: `contextual_tour_outcome` -> terminal pane split actions and later workspace creation. - -Recommended funnel shape: - -1. `contextual_tour_shown` filtered by `tour_id` -2. `contextual_tour_outcome` filtered by same `tour_id`, broken down by `outcome` -3. Downstream action event within 1 day and 7 days - -Action: - -- If completion improves downstream action conversion, keep the tour. -- If shown-but-skipped users still convert, the surface may be discoverable without a tour. -- If no downstream event exists, add the smallest action-specific telemetry needed for that feature. - -## Telemetry Cost Rule - -At 1,000 DAU, this PR should emit at most: - -- One `contextual_tour_shown` per shown tour -- One `contextual_tour_outcome` per shown tour -- Currently one contextual tour per session - -Expected launch volume: under 2,000 events/day at 1,000 DAU and lower for mature cohorts because existing users are not auto-eligible and `contextualToursSeenIds` fills in for eligible new users. - -Do not add recurring, render-loop, polling, passive snapshot, or local-state-mirror events for this analysis. - -## Privacy Rule - -Payloads must remain low-cardinality and bounded. Do not include: - -- prompts -- commands -- paths -- URLs -- hostnames -- repo names -- branch names -- user-entered text -- raw errors -- tokens or IDs beyond the existing anonymous telemetry identity - -## Existing Users - -Existing users should not receive automatic contextual tours from this rollout. We cannot reliably know whether they have already viewed or used every covered surface, so the least surprising default is to leave them alone and measure this launch on new-user cohorts. - -Implementation: - -- `contextualToursAutoEligible: false` for profiles whose onboarding is already closed when the rollout first runs -- `contextualToursAutoEligible: true` for profiles still in first-run onboarding when the rollout first runs -- automatic tour requests require `contextualToursAutoEligible === true` -- local `featureInteractions` still records supported surface entry for all users after UI hydration - -For existing-user analysis, compare users by actual tour exposure and outcome only when they have exposure. Most existing users should fall into the no-tour baseline for this PR. If a later release adds manual tour entry points or truly new feature education, evaluate that release separately instead of reusing this rollout's eligibility rule. diff --git a/docs/reference/feature-education-state.md b/docs/reference/feature-education-state.md deleted file mode 100644 index 950d4823d48..00000000000 --- a/docs/reference/feature-education-state.md +++ /dev/null @@ -1,75 +0,0 @@ -# Feature Education State - -Orca uses local persisted UI state to decide which education surfaces should appear for a user. Keep these concepts separate; they answer different questions and should not be collapsed into one flag. - -For the feature-interaction catalog, see [`feature-discovery-interaction-tracking.md`](./feature-discovery-interaction-tracking.md). For retention analysis and dashboard construction, see [`feature-education-retention-analytics.md`](./feature-education-retention-analytics.md). - -## State Types - -`featureTipsSeenIds` answers: "Has Orca already surfaced this tip?" - -Set this when a restart tip is opened, dismissed, or acted on. It prevents the same tip from reappearing after restart, crash, or dismissal. It does not mean the user used the feature. - -`contextualToursSeenIds` answers: "Has Orca already surfaced this contextual tour?" - -Set this after a tour has rendered a measured target, or when the user skips/completes the tour. It prevents the same tour from showing again. It does not mean the user used the feature. - -`contextualToursAutoEligible` answers: "May this profile receive automatic contextual tours from this rollout?" - -Missing means Orca has not classified the profile yet. On first hydrated launch after this change, the renderer sets it once: - -- `true` when first-run onboarding is still open, which covers new users from this rollout forward -- `false` when onboarding is already closed, which covers existing users - -Automatic contextual tours require this flag to be `true`. Existing users still get local feature interaction state recorded when they enter supported surfaces, but they are not auto-toured by this rollout. - -`featureInteractions` answers: "Has the user actually interacted with this feature?" - -This is the product-state signal for already-discovered features. It lives in `PersistedUIState.featureInteractions`, keyed by `FeatureInteractionId`, and stores the first local interaction timestamp plus a local interaction count: - -```ts -{ - tasks: { - firstInteractedAt: 1716500000000, - interactionCount: 3 - } -} -``` - -Use this to suppress tips or tours that would teach a feature the user has already found on their own. - -## Rules - -- Define every trackable feature in `src/shared/feature-interactions.ts`. -- The `interaction` text must clearly state what counts as "used." Avoid vague labels like "seen" or "visited" unless passive visibility is truly the intended signal. -- Record only the first interaction. Repeated opens should not churn the user-data file. -- Record interactions only after persisted UI state is hydrated, so startup defaults cannot overwrite real user data. -- Do not rename or reuse ids. If semantics change materially, add a new id and leave the old one readable. -- Unknown or malformed persisted ids are ignored during hydration for forward/backward compatibility. -- This state is local product state, not telemetry. Analytics should use the bounded feature-education telemetry events (`contextual_tour_shown` and `contextual_tour_outcome`) plus existing downstream action events, rather than reading or uploading the persisted state blob. -- Automatic contextual tours are new-user-only for this rollout. Do not reset `contextualToursAutoEligible` for existing users unless the product decision changes for a later release. - -## Adding A Tip - -When a tip should not show after the user has already used the feature, add the relevant interaction id to the tip definition: - -```ts -{ - id: 'voice-dictation', - completedByFeatureInteractions: ['voice-dictation'] -} -``` - -Then make sure the feature records its interaction at the meaningful product moment. For example, voice dictation records only after a session reaches `listening`, not when the settings pane opens. - -## Adding A Tour - -Tours still use `contextualToursSeenIds` to avoid repeating the same tour. If opening or using the toured surface should also suppress future education for that feature, call `recordFeatureInteraction(featureId)` from the surface's meaningful interaction point. - -For contextual tours in this branch, `useContextualTour(...)` records the matching interaction once the surface is enabled and persisted UI is ready. Surface-specific gates decide what "enabled" means; for example, the browser tour waits for a non-blank local browser page. - -## Test Profiles - -Completed-onboarding E2E profiles should preseed both education exposure and feature interaction state. That keeps first-run education from covering unrelated UI under test while preserving production behavior for real profiles. - -Profiles that model existing users should also set `contextualToursAutoEligible: false`; profiles that explicitly exercise contextual tours can set it to `true`. diff --git a/docs/reference/keyboard-layout-shortcut-dispatch.md b/docs/reference/keyboard-layout-shortcut-dispatch.md deleted file mode 100644 index 33a2a4d867e..00000000000 --- a/docs/reference/keyboard-layout-shortcut-dispatch.md +++ /dev/null @@ -1,66 +0,0 @@ -# Keyboard Layout Shortcut Dispatch - -## Problem - -Keyboard shortcuts must follow the user's active keyboard layout. A shortcut like `Cmd+W` -means "Command plus the key that produces `w`", not "Command plus the physical key labeled -W on a US keyboard." Physical-position matching breaks Dvorak, Colemak, AZERTY, JIS, and -other non-US layouts, and it also makes user keybinding overrides impossible to reason about. - -## Decision - -Orca app shortcuts dispatch by logical key by default. - -The shared keybinding registry in `src/shared/keybindings.ts` is the source of truth for -app commands, configurable commands, shortcut recording, labels, conflict detection, browser -guest forwarding, and terminal pane commands. Code handling a user-facing app command must -call `keybindingMatchesAction`, `keybindingMatchesInput`, or a policy function built on those -helpers. - -Physical `KeyboardEvent.code` may only decide a shortcut when the key is layout-invariant or -the platform cannot provide a real logical key. - -Allowed physical-code uses: - -- Modifier key release tracking, such as left/right Control release for held `Ctrl+Tab`. -- Layout-invariant keys, such as arrows, Tab, Enter, Escape, Backspace, Delete, Insert, - PageUp, PageDown, and explicit numpad bindings. -- Dead, unidentified, or missing logical keys where `KeyboardEvent.key` cannot describe the - produced key. -- Terminal byte encoding where the intent is a physical terminal escape sequence rather than - an Orca command. - -Disallowed physical-code uses: - -- Letter shortcuts for app actions. -- Punctuation shortcuts for app actions when `KeyboardEvent.key` reports the produced - punctuation. -- Clipboard shortcuts that are exposed as app or terminal UI commands. -- Hardcoded undo/redo/new/close/copy/paste handling outside the shared registry. - -## Terminal Boundary - -Terminal handling has two different jobs: - -1. Orca commands that act on terminal UI, such as copy selection, paste, search, clear, - pane focus, split, and close. These are app shortcuts and must be layout-aware. -2. Bytes sent to the shell, such as readline escapes and Option-as-Alt sequences. These may - use physical key positions when terminal compatibility requires it. - -This boundary is intentional. It lets non-US layouts use Orca commands naturally while -preserving shell behavior where users expect physical terminal-control sequences. - -## Regression Requirements - -Shortcut tests must cover both directions of a non-QWERTY swap: - -- The key that produces the configured logical character must match, even if its physical code - differs. -- The physical US key must not match when it produces a different logical character. - -Tests must also cover intentional exceptions: - -- Dead or missing key fallback. -- Shifted punctuation aliases. -- Numpad-specific bindings. -- Terminal byte-encoding paths that intentionally use physical codes. diff --git a/docs/reference/new-user-parallel-work-telemetry.md b/docs/reference/new-user-parallel-work-telemetry.md deleted file mode 100644 index abe64e6f881..00000000000 --- a/docs/reference/new-user-parallel-work-telemetry.md +++ /dev/null @@ -1,245 +0,0 @@ -# New User Parallel Work Telemetry - -## Goal - -Measure whether the new parallel-work tours and the eight-step `Getting started` flow improve new-user activation and retention. - -The product question is not "did the tour render?" It is: - -- Did users who were eligible for the new parallel-work education retain better at D1, D3, and D7? -- Did seeing or completing the tour increase the behaviors that the activation plan expects: terminal splits, two agents in one worktree, two worktrees within 72 hours, task/review source use, setup script configuration, notifications, and agent capability setup? -- Where do users leave the education flow: before the tour starts, during a tour, after skipping a tour, after opening `Getting started`, or partway through the eight setup steps? - -This document implements the telemetry portion of [`new-user-parallel-work-activation.md`](./new-user-parallel-work-activation.md). - -Validation mode: the current branch logs the would-be event name and payload to the console instead of sending these new feature-education events through product telemetry. Keep the logged payload shape identical to the event payloads below so reviewers can verify what would be emitted before product telemetry is re-enabled. - -## Product Decisions - -Question: Should Orca keep, revise, or disable the automatic `workspace-agent-sessions` tour for new users? - -Decision owner/use: Product and growth review the retention and activation read after rollout. If exposed users retain and activate better than eligible-but-unexposed users, keep or expand the tour. If exposed users underperform or skip/cancel heavily, revise copy, timing, or gating. - -Question: Which part of the education flow is weak? - -Decision owner/use: If users usually stop on a specific tour step, revise that step or shorten the tour. If users complete the tour but do not progress through `Getting started`, improve the handoff. If users open `Getting started` but stall before the first two parallel-work steps, revise the checklist actions. - -Dashboard: `New User Parallel Work Activation` - -Action: Use the dashboard to decide whether to continue rollout, adjust the tour, reorder checklist steps, or add missing downstream action telemetry. - -## Existing Telemetry To Reuse - -Keep the existing contextual-tour events as the primary tour exposure and outcome contract: - -- `contextual_tour_shown` -- `contextual_tour_outcome` - -`contextual_tour_outcome` already carries: - -- `tour_id` -- `source` -- `outcome`: `completed`, `skipped`, or `cancelled` -- `steps_seen` -- `total_steps` - -For the current branch, `workspace-agent-sessions` is the activation tour. Its source should distinguish automatic first-workspace exposure from checklist-triggered education: - -- `workspace_agent_sessions_visible` for automatic surface-driven tour exposure -- `setup_guide_parallel_work` for the explicit checklist `Try it out` path - -Do not add one telemetry event per tour opportunity or step render. Opportunity checks are driven by local UI eligibility, retries, target visibility, modal state, and session guards; emitting every "not shown" reason would turn tour gating into passive eligibility telemetry. One shown event plus one outcome event keeps volume low and still answers whether users saw, skipped, cancelled, completed, and how far they got. - -## Tour Depth Improvement - -Add stable step-depth fields to `contextual_tour_outcome` so the analysis can tell whether a user reached the actual late steps, not only how many visible steps happened to render. - -Additive schema fields: - -- `furthest_step_index`: integer `1..8` -- `defined_step_count`: integer `1..8` - -Keep `steps_seen` and `total_steps`: - -- `steps_seen` remains the count of unique rendered visible steps. -- `total_steps` remains the visible-step denominator for the user session. -- `furthest_step_index` is the highest 1-based index from the tour definition reached by the user. -- `defined_step_count` is the number of steps defined for that tour, regardless of target skipping. - -This handles target-skipping correctly. A user who sees tour steps 1, 4, and 5 should report `steps_seen: 3`, `total_steps: 3`, `furthest_step_index: 5`, and `defined_step_count: 5`. - -## New Setup Guide Events - -The eight `Getting started` steps need a small telemetry contract because they are not contextual-tour steps. They are durable activation milestones. - -### `setup_guide_opened` - -Emit once when the modal opens. - -Payload: - -- `source`: `sidebar`, `contextual_tour`, `settings`, `feature_wall`, `help_menu`, or `unknown` -- `initial_completed_count`: integer `0..8` -- `total_steps`: integer `8` -- `first_incomplete_step_id`: one of the eight setup step ids, or `none` - -Use: - -- Measures whether users reach the checklist after the tour. -- Establishes starting depth so existing progress does not look like checklist-driven progress. - -### `setup_guide_closed` - -Emit once when a shown setup guide closes or unmounts. - -Payload: - -- `source`: same value used by `setup_guide_opened` -- `outcome`: `completed`, `dismissed`, or `interrupted` -- `initial_completed_count`: integer `0..8` -- `final_completed_count`: integer `0..8` -- `total_steps`: integer `8` -- `active_step_id`: one of the eight setup step ids, or `none` - -Use: - -- Measures whether users treat the checklist as useful or dismiss it without progress. -- Gives a compact "how far through the 8 steps" read without uploading a progress snapshot on every render. - -### `setup_guide_step_completed` - -Emit once per setup step when that step first transitions from incomplete to complete while the setup guide is visible. Hidden/background progress is persisted locally as baseline state but not emitted, because async setup refresh cannot reliably distinguish old completions from new user action. - -Payload: - -- `step_id`: one of `two-worktrees`, `browser`, `notifications`, `default-agent`, `task-sources`, `setup-script`, `add-two-repos`, `agent-capabilities` -- `section_id`: `parallel-work` or `setup` -- `completed_count`: integer `1..8` -- `total_steps`: integer `8` -- `setup_guide_visible`: boolean - -Use: - -- Measures the in-guide depth through the eight steps without backfilling old setup state on launch. -- Lets retention analysis bucket users by `0`, `1`, `2`, `3-4`, `5-7`, and `8` completed setup steps. - -Implementation detail: persist a local set of emitted setup-guide step ids, similar in spirit to `contextualToursSeenIds`, so inferred durable state does not re-emit on every launch. Do not upload that local set. - -## Downstream Activation Events - -Reuse existing downstream events whenever possible: - -- `workspace_created` for worktree/workspace creation depth -- `agent_started` for agent activity and follow-up actions -- `settings_changed` for supported settings such as notifications -- setup script prompt events for setup-script configuration paths -- task/source-specific events where they already exist - -Add action-specific downstream telemetry only when the retention question cannot be answered from existing events. The first likely gap is terminal splitting. The activation plan names first-session terminal split as a primary success criterion, but the branch currently records `terminal-pane-split` as local feature-interaction state, not product telemetry. - -If no existing telemetry captures terminal split, add: - -### `terminal_pane_split` - -Emit when a split succeeds, capped to the first split for each `source` + `direction` pair per UTC day on the local profile. This keeps the event useful for activation cohorts without making heavy terminal users dominate telemetry volume. - -Payload: - -- `source`: `contextual_tour`, `keyboard`, `context_menu`, `command`, `unknown` -- `direction`: `vertical` or `horizontal` - -Do not include pane ids, worktree ids, paths, commands, prompts, repo names, branch names, hostnames, or terminal content. - -## Retention Analysis - -Use new-user eligibility as the least-biased baseline: - -1. Eligible baseline: new-user cohorts represented by first-run/onboarding events rather than uploading local tour eligibility state. -2. Exposed cohort: users with `contextual_tour_shown` where `tour_id = workspace-agent-sessions`. -3. Tour outcome cohorts: users with `contextual_tour_outcome` broken down by `outcome`. -4. Tour depth cohorts: `furthest_step_index / defined_step_count` and `steps_seen / total_steps`. -5. Checklist depth cohorts: latest `setup_guide_step_completed.completed_count`, bucketed as `0`, `1`, `2`, `3-4`, `5-7`, and `8`. - -Primary retention read: - -- D1, D3, and D7 return to `app_opened` -- Compare new-user baseline, shown, completed, skipped, cancelled, and checklist-depth cohorts - -Primary activation read: - -- terminal split in first workspace session -- two agent sessions in one worktree -- two worktrees within 72 hours -- follow-up/manual agent action -- task/review source use -- setup script configured -- notifications configured -- agent capabilities completed - -Interpretation rules: - -- A completed tour with no downstream activation means the tour is educational but not converting. -- A skipped tour with strong downstream activation means the feature may be discoverable without the tour. -- A high cancelled rate usually points to target loss, modal conflicts, or timing problems. -- Strong checklist-depth retention with weak tour retention means the checklist is the value driver, not the automatic tour. - -## Volume Estimate - -Telemetry volume estimate: - -- Trigger: contextual tour shown/outcome, setup guide open/close, setup step first completion, optional terminal split -- Expected events/user/day: 2-6 for new users who encounter the flow -- Max events/user/day: about 14 on a heavy first day: 2 tour events, 2 setup guide events, 8 setup step completions, 1-2 split events -- At 1,000 DAU: expected 2,000-6,000 events/day -- Monthly at 1,000 DAU: expected 60,000-180,000 events/month -- Approval note: The events answer the rollout decision directly and are bounded by explicit user actions or one-time durable setup completions. There are no timers, render-loop events, polling snapshots, or passive heartbeats. - -If observed volume exceeds expectation, remove or sample `setup_guide_opened` / `setup_guide_closed` before removing completion events. Completion events are the durable activation-depth read. - -## Privacy - -All payloads must be low-cardinality and schema-bounded. - -Never include: - -- prompts -- terminal commands -- paths -- URLs -- hostnames -- repo names -- branch names -- task titles -- user-authored text -- raw errors -- tokens -- pane ids, worktree ids, repo ids, or other persistent product identifiers - -## Implementation Plan - -1. Extend `contextual_tour_outcome` schema in `src/shared/telemetry-events.ts` with optional `furthest_step_index` and `defined_step_count`. -2. Track the highest reached defined tour step in `ContextualTourOverlay.tsx` alongside the existing `steps_seen` set. -3. Add setup-guide event schemas and bounded enums in `src/shared/telemetry-events.ts`. -4. Add a setup-guide telemetry helper in `src/renderer/src/lib/feature-education-telemetry.ts` or a dedicated setup-guide telemetry module. -5. Emit `setup_guide_opened` and `setup_guide_closed` from `SetupGuideModal.tsx`. -6. Emit `setup_guide_step_completed` from a small hook that observes visible setup-guide progress transitions, persists emitted step ids locally, and ignores hidden/background completions except as local baseline state. -7. Add `terminal_pane_split` only if no existing telemetry captures successful terminal splits. -8. Add schema tests for accepted payloads, rejected raw strings, count bounds, and enum bounds. -9. Add renderer tests that prove each modal open emits one open event, each close emits one close event, each step completion emits once, rerenders do not re-emit, and pre-completed persisted steps are not backfilled repeatedly. - -## Dashboard Tiles - -Create a PostHog dashboard named `New User Parallel Work Activation`. - -Tiles: - -- Tour exposure: `contextual_tour_shown` by `tour_id` and `source` -- Tour outcome: `contextual_tour_outcome` by `outcome` -- Tour depth: median `furthest_step_index / defined_step_count` -- Setup guide reach: `setup_guide_opened` by `source` -- Setup guide close outcome: `setup_guide_closed` by `outcome` -- Eight-step depth: latest `setup_guide_step_completed.completed_count` bucket -- Activation funnel: tour shown -> tour outcome -> setup guide opened -> first two setup steps complete -> D1/D3/D7 return -- Downstream actions: tour shown -> terminal split -> two agents in one worktree -> two worktrees within 72 hours - -Do not publish dashboard tiles that require raw user content or identifiers. diff --git a/docs/reference/opencode-commit-message-stdin.md b/docs/reference/opencode-commit-message-stdin.md deleted file mode 100644 index c7141fb4469..00000000000 --- a/docs/reference/opencode-commit-message-stdin.md +++ /dev/null @@ -1,105 +0,0 @@ -# OpenCode Commit Message Stdin Delivery - -## Problem - -OpenCode's preset Source Control AI path passes the full generated prompt as the final `opencode run` argv positional. Commit-message prompts include branch, staged file summary, user instructions, and the staged patch. The patch is capped by `STAGED_DIFF_BYTE_BUDGET = 200_000`, but the implementation uses JavaScript string length, not encoded byte length, so argv can still be much larger than command-line limits on Windows and some POSIX/SSH wrapper paths before OpenCode starts. - -Verified current code: - -- `src/shared/commit-message-agent-spec.ts:24-32` defines `promptDelivery` as the argv/stdin contract for large diffs. -- `src/shared/commit-message-agent-spec.ts:322-337` sets OpenCode to `promptDelivery: 'argv'` and appends `prompt`. -- `src/shared/commit-message-plan.ts:108-123` already routes `promptDelivery === 'stdin'` into `stdinPayload`. -- `src/main/text-generation/commit-message-text-generation.ts:470-601` pipes local `stdinPayload` to child stdin. -- `src/main/providers/ssh-git-provider.ts:122-140` forwards `stdinPayload` as relay `stdin`; `src/relay/agent-exec-handler.ts:160-303` writes it to the remote child. -- `src/shared/commit-message-plan.test.ts:36` currently locks in the broken OpenCode argv behavior. - -## Feasibility Gate - -`opencode --version` on the local machine reports `1.15.13`. `opencode run --help` confirms the required args exist: `--model`, `--agent`, `--format`, and `--variant`, with optional `[message..]` positionals. It does not document stdin prompt input. - -Shipping gate: before changing the preset, run a controlled smoke test using the exact no-message shape, and verify stdout clearly reflects the stdin prompt rather than an empty task: - -```sh -printf '%s\n' 'Write: test commit' | opencode run --model --agent build --format default -``` - -Also smoke `--variant ` if the selected model exposes thinking levels. If credentials, model access, or an unsupported OpenCode version block validation, do not treat `--help` alone as proof. If stdin fails, do not add an argv fallback; stop and choose a different no-argv transport or version-gated behavior. - -## Root Cause - -The planner and both local/SSH execution paths already know how to carry stdin payloads. The mismatch is the OpenCode preset args. - -OpenCode is not the only argv-delivered path in the registry: Cursor and custom commands that explicitly use `{prompt}` still put prompts in argv. They are out of scope here and should not be described as fixed by this change. - -## Non-Goals - -- Do not change prompt wording, diff truncation, output cleanup, model discovery, or model selection. -- Do not change interactive OpenCode terminal launch behavior. -- Do not change GitHub/GitLab/provider review flows. -- Do not change Cursor argv delivery or custom command `{prompt}` semantics. -- Do not add a second OpenCode execution path. - -## Design - -1. Change the OpenCode preset spec to `promptDelivery: 'stdin'`. -2. Add a short why-comment near that setting: OpenCode prompt input can contain large staged diffs and must stay out of argv. -3. Remove `prompt` from OpenCode `buildArgs`. The preset args must be exactly `run --model --agent build --format default`, plus `--variant ` when present. -4. Keep `modelSource`, `modelDiscovery`, default model, and dynamic-model fallback behavior unchanged. -5. Keep preset `agentCommandOverride` behavior unchanged: an override such as `npx opencode` prefixes the same OpenCode args, while the prompt remains `stdinPayload`. -6. Accept the shared-spec blast radius: OpenCode prompts for commit-message, PR-field, and branch-name generation all move to stdin because they all call `planCommitMessageGeneration(...)`. If the requirement is commit-message-only, this design is wrong and needs operation-aware delivery instead. - -## Data Flow - -- Source Control AI receives a staged context and builds the prompt. The context is not atomic: branch, staged summary, and staged patch are read by separate git commands before generation. -- `planCommitMessageGeneration(...)` validates the agent, model, and thinking level against the existing dynamic/static model rules. -- Because OpenCode becomes stdin-delivered, the planner passes an empty prompt into `buildArgs` and stores the real prompt in `plan.stdinPayload`. -- Local generation writes `stdinPayload` to the child process stdin. -- SSH generation serializes the same plan to the relay as `{ stdin: plan.stdinPayload }`, and the relay writes it to the remote child process stdin after spawn. - -This fix removes the prompt from process argv only. It does not reduce prompt memory, model latency, token use, or diff truncation pressure. Remote execution still sends the prompt in one JSON-RPC frame; the relay frame cap is 16 MB, and today's prompt budget is under that, but future budget increases must account for it. - -## Edge Cases - -- Large staged diff: the prompt must be absent from `args`, including no empty-string placeholder. -- Thinking level selected: preserve `--variant `. -- No thinking level selected: omit `--variant`. -- Dynamic OpenCode model not present in the seed catalog: preserve the existing dynamic fallback and still pass the selected model id. -- Agent command override: `npx opencode` becomes `binary: 'npx'`, `args: ['opencode', 'run', ...]`, with prompt in `stdinPayload`. -- Windows batch shims: removing the prompt from argv also removes prompt metacharacters from the batch command line; do not route the prompt through `{prompt}` for this preset. -- SSH: the prompt is JSON-serialized as `stdin`, not shell-quoted, and is written by the relay after spawn. -- SSH payload size: this is safe for the current prompt budget, not an unbounded transport. Anything near 16 MB needs streaming or another transport. -- OpenCode ignores stdin: the smoke test must catch this before shipping because `--help` does not document stdin. -- Output cleanup remains unchanged; OpenCode stderr/status chatter should be handled by existing failure and cleanup logic. - -## Consistency And Concurrency - -- No cache invalidation is introduced. Each generation plans from the request's current settings and the staged context supplied to that request. -- The staged context is not a true snapshot. If the index changes between the summary and patch reads, or after the context is read, the generated message can describe a mixed or stale view. That is existing behavior and not fixed here. -- Multiple windows can request generation for the same worktree. Local execution does not serialize same-`cwd` requests; the cancel map is keyed by `operation:local:` and the newest request overwrites the reachable cancel token. Do not claim this change fixes local multi-window races. -- SSH execution queues matching `operation + cwd` lanes inside a `SshGitProvider` instance. The relay itself tracks only one active child per lane and cancels a prior active child if another request for the same lane reaches it, which matters if separate provider instances/windows bypass the same queue. -- Settings or command overrides changed while a generation is in flight affect the next request only; the current child keeps its immutable plan. - -## Test Plan - -- `src/shared/commit-message-agent-spec.test.ts`: add OpenCode coverage asserting `promptDelivery === 'stdin'`, no prompt or empty placeholder in `buildArgs`, variant preservation, and no variant when absent. -- `src/shared/commit-message-plan.test.ts`: update OpenCode expectations so `stdinPayload` receives the prompt and argv excludes it; include an OpenCode command-override case. -- Because `planCommitMessageGeneration(...)` is reused by commit-message, PR-field, and branch-name generation, the plan tests are the main regression coverage for the shared blast radius. -- Keep existing Codex stdin, custom-command stdin, local child stdin, SSH provider, and relay stdin tests passing. -- Run targeted Vitest for the touched shared tests, plus `pnpm typecheck` and `pnpm lint` for the implementation branch. -- Run the OpenCode stdin smoke test from the feasibility gate. If it cannot run, do not mark implementation risk as resolved. - -## Lightweight Eng Review - -- Scope: shared preset-agent plumbing and shared tests, with an explicit shared-operation blast radius for OpenCode commit-message, PR-field, and branch-name prompts. No renderer redesign, settings migration, hosted review flow, or provider-specific review behavior. -- Architecture/data flow: use the existing `promptDelivery` contract; local and SSH execution already honor `stdinPayload`. -- Failure modes covered: prompt re-enters argv, `--variant` is dropped, unknown dynamic model ids regress, command overrides change stdin behavior, remote JSON-RPC frame limits are ignored, or OpenCode accepts the flags but ignores stdin. -- Performance/blast radius: no renderer, watcher, IPC subscription, or startup cost. Local argv shrinks; remote payload size is unchanged because the prompt still crosses JSON-RPC as stdin data in one frame. -- UI quality bar: no visual change; skip visual-design review. -- Screenshots: none required for this headless invocation change. If PR evidence is needed, prefer unit-test output and the controlled OpenCode stdin smoke result. -- Residual risk: OpenCode stdin prompt input is not documented by `opencode run --help`; remote hosts may have different OpenCode versions; existing local multi-window cancellation races and staged-context non-atomicity remain. - -## Rollout - -1. Update OpenCode spec delivery and args. -2. Update shared spec and planner tests. -3. Run targeted tests, typecheck, lint, and the OpenCode smoke test before shipping. diff --git a/docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md b/docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md deleted file mode 100644 index c27fe37b255..00000000000 --- a/docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md +++ /dev/null @@ -1,972 +0,0 @@ -# Manual Network Address Entry — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let users type a custom IPv4 address or Tailscale MagicDNS hostname into the desktop mobile-pairing Network Interface dropdown, so the QR code can target a host the OS hasn't auto-discovered. - -**Architecture:** Replace the inner `Select` of `MobileNetworkInterfaceSection` with a `Popover + Command` ("combobox") pattern modeled on the existing `AgentCombobox`. A new shared pure function `parseManualNetworkAddress` enforces the input grammar; a new helper `buildComboboxEntries` builds the row list (filtered interfaces + an optional `Use ""` row). The custom selection is session-scoped. - -**Tech Stack:** TypeScript, React 18, shadcn/ui primitives (`Command`, `Popover`, `Button`) from `src/renderer/src/components/ui/`, vitest, `@testing-library/react`. No new dependencies. - -**Spec:** [`docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md`](../2026-06-27-orca-mobile-manual-network-address-design.md) - ---- - -## File map - -**Create:** -- `src/shared/network/manual-address.ts` — pure parser. -- `src/shared/network/manual-address.test.ts` — parser unit tests. -- `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx` — UI render tests. - -**Modify:** -- `src/renderer/src/components/settings/mobile-network-interface-selection.ts` — swap `mergeForSelect` for `buildComboboxEntries` (or add `buildComboboxEntries` and keep the old one if other call sites exist; confirm with grep before deleting). -- `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` — replace `mergeForSelect` tests with `buildComboboxEntries` tests. -- `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` — swap `Select` for `Popover + Command`. - -**Reference (read-only, do not modify):** -- `src/renderer/src/components/ui/command.tsx` — `Command`, `CommandEmpty`, `CommandInput`, `CommandItem`, `CommandList`, `CommandSeparator`. -- `src/renderer/src/components/ui/popover.tsx` — `Popover`, `PopoverTrigger`, `PopoverContent`. -- `src/renderer/src/components/ui/button.tsx` — `Button`. -- `src/renderer/src/components/agent/AgentCombobox.tsx` — full reference implementation of the controlled cmdk pattern. -- `src/renderer/src/components/agent/agent-combobox-command-state.ts` — `createAgentComboboxCommandState`, `resolveAgentComboboxCommandState`, `updateAgentComboboxCommandValue`. -- `src/renderer/src/components/settings/MobilePairingQrSection.tsx` — consumer of `selectedAddress`; unchanged. - -**Out of scope:** mobile-side endpoint override (`mobile/app/pair-scan.tsx`); main-process code; persistent storage of custom addresses; IPv6, ports, non-Tailscale hostnames. - ---- - -## Task 1: Add `parseManualNetworkAddress` (TDD) - -**Files:** -- Create: `src/shared/network/manual-address.ts` -- Create: `src/shared/network/manual-address.test.ts` - -- [ ] **Step 1: Confirm no existing call site for `mergeForSelect`** - - Run: - ```bash - grep -rn "mergeForSelect" src/ docs/ 2>/dev/null - ``` - Expected: no matches. (If there are matches, stop and update Task 2 to keep `mergeForSelect` and only add `buildComboboxEntries` alongside it.) - -- [ ] **Step 2: Write the failing tests** - - Create `src/shared/network/manual-address.test.ts` with this exact content: - - ```ts - import { describe, it, expect } from 'vitest' - import { parseManualNetworkAddress } from './manual-address' - - describe('parseManualNetworkAddress', () => { - describe('IPv4', () => { - it('accepts canonical IPv4', () => { - expect(parseManualNetworkAddress('192.168.1.24')).toEqual({ - ok: true, - address: '192.168.1.24' - }) - expect(parseManualNetworkAddress('100.64.1.20')).toEqual({ - ok: true, - address: '100.64.1.20' - }) - }) - - it('accepts boundary IPv4 values', () => { - expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true) - expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true) - }) - - it('rejects malformed IPv4', () => { - for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) { - expect(parseManualNetworkAddress(bad)).toEqual({ - ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' - }) - } - }) - - it('rejects leading zeros in octets', () => { - expect(parseManualNetworkAddress('01.02.03.04')).toEqual({ - ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' - }) - expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true) - }) - }) - - describe('Tailscale MagicDNS hostname', () => { - it('accepts short MagicDNS names', () => { - expect(parseManualNetworkAddress('my-mac.ts.net')).toEqual({ - ok: true, - address: 'my-mac.ts.net' - }) - }) - - it('accepts tailnet-qualified MagicDNS names', () => { - expect(parseManualNetworkAddress('my-mac.tail-abcd.ts.net')).toEqual({ - ok: true, - address: 'my-mac.tail-abcd.ts.net' - }) - expect(parseManualNetworkAddress('a.b.c.d.ts.net').ok).toBe(true) - }) - - it('is case-insensitive', () => { - expect(parseManualNetworkAddress('MY-MAC.TS.NET').ok).toBe(true) - }) - - it('rejects non-Tailscale hostnames', () => { - for (const bad of ['my-mac', 'my-mac.ts.com', '-foo.ts.net', 'my-mac.com']) { - expect(parseManualNetworkAddress(bad).ok).toBe(false) - } - }) - }) - - describe('length and whitespace', () => { - it('rejects inputs longer than 253 chars', () => { - const long = `${'a'.repeat(250)}.ts.net` - expect(long.length).toBeGreaterThan(253) - expect(parseManualNetworkAddress(long).ok).toBe(false) - }) - - it('trims leading and trailing whitespace before validating', () => { - expect(parseManualNetworkAddress(' 192.168.1.24 ')).toEqual({ - ok: true, - address: '192.168.1.24' - }) - }) - }) - }) - ``` - -- [ ] **Step 3: Run tests to verify they fail** - - Run from repo root: - ```bash - pnpm vitest run src/shared/network/manual-address.test.ts - ``` - Expected: error like `Cannot find module './manual-address'`. This is the failing-test step — do not skip it. - -- [ ] **Step 4: Implement `parseManualNetworkAddress`** - - Create `src/shared/network/manual-address.ts` with this exact content: - - ```ts - // Why: pure shared helper so the same validation runs in renderer - // today and in any future CLI/main-process caller without duplicating - // the IPv4 + Tailscale MagicDNS grammar. - const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])' - const IPV4 = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}` - // MagicDNS hostname: lowercase letters/digits/hyphens, dot-separated, ending in .ts.net. - // Labels may not start or end with a hyphen; max 63 chars per label (DNS limit). - const MAGICDNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' - const MAGICDNS = `(?:${MAGICDNS_LABEL}\\.)+ts\\.net` - - const HOSTNAME_MAX_LENGTH = 253 - const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname' - - export type ParseManualAddressResult = - | { ok: true; address: string } - | { ok: false; error: string } - - export function parseManualNetworkAddress(input: string): ParseManualAddressResult { - const trimmed = input.trim() - if (trimmed === '' || trimmed.length > HOSTNAME_MAX_LENGTH) { - return { ok: false, error: ERROR_MESSAGE } - } - if (/\s/.test(trimmed)) { - return { ok: false, error: ERROR_MESSAGE } - } - - const ipv4Regex = new RegExp(`^${IPV4}$`) - if (ipv4Regex.test(trimmed)) { - return { ok: true, address: trimmed } - } - - const magicRegex = new RegExp(`^(?:${MAGICDNS})$`, 'i') - if (magicRegex.test(trimmed)) { - return { ok: true, address: trimmed } - } - - return { ok: false, error: ERROR_MESSAGE } - } - ``` - -- [ ] **Step 5: Run tests to verify they pass** - - Run: - ```bash - pnpm vitest run src/shared/network/manual-address.test.ts - ``` - Expected: all tests pass. - -- [ ] **Step 6: Lint and typecheck** - - ```bash - pnpm lint src/shared/network/ - pnpm typecheck - ``` - Expected: no errors. Fix any and re-run before continuing. - -- [ ] **Step 7: Commit** - - ```bash - git add src/shared/network/manual-address.ts src/shared/network/manual-address.test.ts - git commit -m "feat(mobile-pairing): add parseManualNetworkAddress validator" - ``` - ---- - -## Task 2: Replace `mergeForSelect` with `buildComboboxEntries` (TDD) - -**Files:** -- Modify: `src/renderer/src/components/settings/mobile-network-interface-selection.ts` -- Modify: `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` - -- [ ] **Step 1: Read the current contents of both files** - - Read the full source of: - - `src/renderer/src/components/settings/mobile-network-interface-selection.ts` - - `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` - - Confirm: `mergeForSelect` is the only exported function besides the `MobileNetworkInterface` type and `selectRefreshedNetworkAddress`. If `selectRefreshedNetworkAddress` is called from `MobileNetworkInterfaceSection.tsx`, keep it. - -- [ ] **Step 2: Write the failing tests for `buildComboboxEntries`** - - Replace the entire body of `mobile-network-interface-selection.test.ts` with: - - ```ts - import { describe, it, expect } from 'vitest' - import { - buildComboboxEntries, - selectRefreshedNetworkAddress, - type MobileNetworkInterface - } from './mobile-network-interface-selection' - - const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } - const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } - - describe('buildComboboxEntries', () => { - it('returns only interface entries when query is empty', () => { - const entries = buildComboboxEntries([LAN, TAILNET], '') - expect(entries).toEqual([ - { kind: 'interface', iface: LAN }, - { kind: 'interface', iface: TAILNET } - ]) - }) - - it('filters interfaces by substring on address or name (case-insensitive)', () => { - const entries = buildComboboxEntries([LAN, TAILNET], 'TAIL') - expect(entries).toEqual([{ kind: 'interface', iface: TAILNET }]) - }) - - it('appends a use-query entry when the query is a valid address not in the list', () => { - const entries = buildComboboxEntries([LAN, TAILNET], 'my-mac.tail-abcd.ts.net') - expect(entries).toEqual([ - { kind: 'interface', iface: LAN }, - { kind: 'interface', iface: TAILNET }, - { kind: 'use-query', address: 'my-mac.tail-abcd.ts.net' } - ]) - }) - - it('suppresses use-query when query equals an existing interface address', () => { - const entries = buildComboboxEntries([LAN, TAILNET], '100.64.1.20') - expect(entries).toEqual([ - { kind: 'interface', iface: LAN }, - { kind: 'interface', iface: TAILNET } - ]) - }) - - it('returns only use-query when interfaces are empty and query is valid', () => { - const entries = buildComboboxEntries([], '1.2.3.4') - expect(entries).toEqual([{ kind: 'use-query', address: '1.2.3.4' }]) - }) - - it('omits use-query when query is invalid', () => { - const entries = buildComboboxEntries([LAN, TAILNET], 'not-an-address') - expect(entries).toEqual([ - { kind: 'interface', iface: LAN }, - { kind: 'interface', iface: TAILNET } - ]) - }) - }) - - describe('selectRefreshedNetworkAddress', () => { - // Existing behavior is preserved verbatim from the spec. - it('keeps the selected address when refresh discovers a new tailnet interface', () => { - expect(selectRefreshedNetworkAddress(LAN.address, [LAN, TAILNET])).toBe(LAN.address) - }) - - it('selects the first refreshed interface when there is no current address', () => { - expect(selectRefreshedNetworkAddress(undefined, [TAILNET, LAN])).toBe(TAILNET.address) - }) - - it('prefers a tailnet address when no address is selected yet', () => { - expect(selectRefreshedNetworkAddress(undefined, [LAN, TAILNET])).toBe(TAILNET.address) - }) - - it('moves to the first refreshed interface when the current address disappeared', () => { - expect(selectRefreshedNetworkAddress('10.0.0.4', [TAILNET, LAN])).toBe(TAILNET.address) - }) - - it('moves to a tailnet address when the current address disappeared', () => { - expect(selectRefreshedNetworkAddress('10.0.0.4', [LAN, TAILNET])).toBe(TAILNET.address) - }) - - it('clears the selection when no interfaces are available', () => { - expect(selectRefreshedNetworkAddress(LAN.address, [])).toBeUndefined() - }) - }) - ``` - -- [ ] **Step 3: Run the test file to verify the new tests fail** - - ```bash - pnpm vitest run src/renderer/src/components/settings/mobile-network-interface-selection.test.ts - ``` - Expected: `buildComboboxEntries is not a function` (or import error) for the new block; the `selectRefreshedNetworkAddress` block still passes. - -- [ ] **Step 4: Rewrite `mobile-network-interface-selection.ts`** - - Replace the entire file with: - - ```ts - import { isTailnetIPv4Address } from '../../../../shared/tailnet-address' - import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' - - export type MobileNetworkInterface = { - name: string - address: string - } - - export type ComboboxEntry = - | { kind: 'interface'; iface: MobileNetworkInterface } - | { kind: 'use-query'; address: string } - - // Why: the UI needs a single ordered list to render inside CommandList. - // Behavior branches on whether the query parses as a valid address — - // valid queries show the full interface list (so users can pivot to an - // existing interface mid-typing), invalid queries substring-filter and - // fall back to the full list when nothing matches. - export function buildComboboxEntries( - interfaces: readonly MobileNetworkInterface[], - query: string - ): readonly ComboboxEntry[] { - const trimmed = query.trim() - if (trimmed === '') { - return interfaces.map((iface) => ({ kind: 'interface' as const, iface })) - } - - const parsed = parseManualNetworkAddress(trimmed) - - let visible: readonly MobileNetworkInterface[] - if (parsed.ok) { - // Valid address: keep every interface visible. - visible = interfaces - } else { - // Invalid: substring-filter; fall back to full list when nothing matches. - const lowered = trimmed.toLowerCase() - const filtered = interfaces.filter( - (iface) => - iface.address.toLowerCase().includes(lowered) || - iface.name.toLowerCase().includes(lowered) - ) - visible = filtered.length > 0 ? filtered : interfaces - } - - const entries: ComboboxEntry[] = visible.map((iface) => ({ - kind: 'interface' as const, - iface - })) - - if (parsed.ok && !visible.some((iface) => iface.address === parsed.address)) { - entries.push({ kind: 'use-query', address: parsed.address }) - } - - return entries - } - - export function selectRefreshedNetworkAddress( - currentAddress: string | undefined, - interfaces: readonly MobileNetworkInterface[] - ): string | undefined { - if (interfaces.length === 0) { - return undefined - } - if (currentAddress && interfaces.some((iface) => iface.address === currentAddress)) { - return currentAddress - } - return ( - interfaces.find((iface) => isTailnetIPv4Address(iface.address))?.address ?? - interfaces[0]!.address - ) - } - ``` - - The relative import paths (`../../../../shared/tailnet-address`, `../../../../shared/network/manual-address`) must match the file's location in the tree. If they don't resolve, adjust based on the existing path style in the same directory (e.g. `../foo` vs `@/foo`). - -- [ ] **Step 5: Run the test file to verify everything passes** - - ```bash - pnpm vitest run src/renderer/src/components/settings/mobile-network-interface-selection.test.ts - ``` - Expected: all tests pass. - -- [ ] **Step 6: Lint and typecheck** - - ```bash - pnpm lint src/renderer/src/components/settings/mobile-network-interface-selection.ts - pnpm typecheck - ``` - -- [ ] **Step 7: Commit** - - ```bash - git add src/renderer/src/components/settings/mobile-network-interface-selection.ts \ - src/renderer/src/components/settings/mobile-network-interface-selection.test.ts - git commit -m "feat(mobile-pairing): buildComboboxEntries for network interface combobox" - ``` - ---- - -## Task 3: Rewrite `MobileNetworkInterfaceSection` UI to use Popover + Command - -**Files:** -- Modify: `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` -- Create: `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx` - -- [ ] **Step 1: Read `AgentCombobox.tsx` end-to-end** - - Read `src/renderer/src/components/agent/AgentCombobox.tsx` and `agent-combobox-command-state.ts` in full. You will be borrowing: - - The `Popover` + `Command` + `CommandInput` + `CommandList` + `CommandItem` JSX structure. - - The controlled `commandState`/`commandValue` pattern that mirrors `AgentCombobox`. - - The `setInputNode`/`focusSearchInput` pattern for keyboard accessibility. - - Do not import agent-specific helpers (e.g. `searchAgentPickerEntries`, `agent-combobox-command-state` is shared but the search logic isn't needed here). - -- [ ] **Step 2: Read the current `MobileNetworkInterfaceSection.tsx` in full** - - Read the file. Note the props contract: - ```ts - type MobileNetworkInterfaceSectionProps = { - networkInterfaces: MobileNetworkInterface[] - selectedAddress: string | undefined - onSelectedAddressChange: (address: string) => void - refreshingNetworkInterfaces: boolean - onRefreshNetworkInterfaces: () => void - loading: boolean - hasQrCode: boolean - onGenerateQr: () => void - } - ``` - The contract **must not change** — only the inner control does. `MobilePairingQrSection.tsx` consumes `selectedAddress` and is unaffected. - -- [ ] **Step 3: Write the failing render tests** - - Create `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx`: - - ```tsx - import React from 'react' - import { describe, it, expect, vi } from 'vitest' - import { render, screen } from '@testing-library/react' - import userEvent from '@testing-library/user-event' - import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' - import type { MobileNetworkInterface } from './mobile-network-interface-selection' - - const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } - const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } - - function renderSection(overrides: Partial> = {}) { - const onSelectedAddressChange = vi.fn() - const onRefreshNetworkInterfaces = vi.fn() - const onGenerateQr = vi.fn() - const props: React.ComponentProps = { - networkInterfaces: [LAN, TAILNET], - selectedAddress: TAILNET.address, - onSelectedAddressChange, - refreshingNetworkInterfaces: false, - onRefreshNetworkInterfaces, - loading: false, - hasQrCode: false, - onGenerateQr, - ...overrides - } - const user = userEvent.setup() - const utils = render() - return { ...utils, user, onSelectedAddressChange, onRefreshNetworkInterfaces, onGenerateQr } - } - - describe('MobileNetworkInterfaceSection', () => { - it('renders the trigger with the currently selected address', () => { - renderSection() - expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)') - }) - - it('lets the user type a custom address and confirms via the Use row', async () => { - const { user, onSelectedAddressChange } = renderSection() - await user.click(screen.getByRole('combobox')) - const input = screen.getByPlaceholderText(/search or type/i) - await user.type(input, 'my-mac.tail-abcd.ts.net') - await user.click(screen.getByRole('option', { name: /Use "my-mac\.tail-abcd\.ts\.net"/ })) - expect(onSelectedAddressChange).toHaveBeenCalledWith('my-mac.tail-abcd.ts.net') - }) - - it('shows an inline error and no Use row when the query is invalid', async () => { - const { user } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.type(screen.getByPlaceholderText(/search or type/i), 'not an address') - expect(screen.getByText(/Enter an IPv4 address or Tailscale MagicDNS hostname/i)).toBeInTheDocument() - expect(screen.queryByRole('option', { name: /Use / })).not.toBeInTheDocument() - }) - - it('suppresses the Use row when the typed address matches an existing interface', async () => { - const { user } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.type(screen.getByPlaceholderText(/search or type/i), '192.168.1.24') - expect(screen.getByRole('option', { name: '192.168.1.24 (en0)' })).toBeInTheDocument() - expect(screen.queryByRole('option', { name: /Use "192\.168\.1\.24"/ })).not.toBeInTheDocument() - }) - - it('renders the (custom) label on the trigger after a custom selection', () => { - renderSection({ selectedAddress: 'my-mac.tail-abcd.ts.net' }) - expect(screen.getByRole('combobox')).toHaveTextContent('my-mac.tail-abcd.ts.net (custom)') - }) - - it('shows No interfaces found when the list is empty', () => { - renderSection({ networkInterfaces: [], selectedAddress: undefined }) - expect(screen.getByRole('combobox')).toHaveTextContent(/no interfaces found/i) - }) - }) - ``` - -- [ ] **Step 4: Run the test file to verify it fails** - - ```bash - pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx - ``` - Expected: failures on every assertion — the component still uses `Select`. This is the failing-test step. - -- [ ] **Step 5: Rewrite the component** - - Replace the entire body of `MobileNetworkInterfaceSection.tsx` with: - - ```tsx - import { useCallback, useMemo, useState } from 'react' - import { ChevronDown, ExternalLink, Loader2, QrCode, RefreshCw, Wifi } from 'lucide-react' - import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger - } from '../ui/accordion' - import { Button } from '../ui/button' - import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList, - CommandSeparator - } from '../ui/command' - import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' - import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' - import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' - import { translate } from '@/i18n/i18n' - import { - buildComboboxEntries, - type MobileNetworkInterface - } from './mobile-network-interface-selection' - - const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download' - const TRIGGER_LABEL_CUSTOM = 'custom' - const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname' - - type MobileNetworkInterfaceSectionProps = { - networkInterfaces: MobileNetworkInterface[] - selectedAddress: string | undefined - onSelectedAddressChange: (address: string) => void - refreshingNetworkInterfaces: boolean - onRefreshNetworkInterfaces: () => void - loading: boolean - hasQrCode: boolean - onGenerateQr: () => void - } - - function formatInterfaceLabel(iface: MobileNetworkInterface): string { - return `${iface.address} (${iface.name})` - } - - function isCustomLabel(name: string): boolean { - return name === TRIGGER_LABEL_CUSTOM - } - - export function MobileNetworkInterfaceSection({ - networkInterfaces, - selectedAddress, - onSelectedAddressChange, - refreshingNetworkInterfaces, - onRefreshNetworkInterfaces, - loading, - hasQrCode, - onGenerateQr - }: MobileNetworkInterfaceSectionProps): React.JSX.Element { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - // Why: session-scoped custom selection (see spec). Cleared automatically - // when the settings pane unmounts because the state lives in this component. - const [customAddress, setCustomAddress] = useState(null) - - const selectedIface = useMemo(() => { - if (!selectedAddress) return null - const matched = networkInterfaces.find((iface) => iface.address === selectedAddress) - if (matched) return matched - if (selectedAddress === customAddress) { - return { name: TRIGGER_LABEL_CUSTOM, address: selectedAddress } - } - return null - }, [networkInterfaces, selectedAddress, customAddress]) - - const triggerLabel = selectedIface - ? formatInterfaceLabel(selectedIface) - : translate( - 'auto.components.settings.MobileNetworkInterfaceSection.b2c384cfd6', - 'No interfaces found' - ) - - const entries = useMemo( - () => buildComboboxEntries(networkInterfaces, query), - [networkInterfaces, query] - ) - - const queryParse = useMemo(() => parseManualNetworkAddress(query), [query]) - const showInlineError = query.trim() !== '' && !queryParse.ok - - const handleSelectInterface = useCallback( - (iface: MobileNetworkInterface) => { - setCustomAddress(null) - setQuery('') - setOpen(false) - onSelectedAddressChange(iface.address) - }, - [onSelectedAddressChange] - ) - - const handleSelectUseQuery = useCallback( - (address: string) => { - setCustomAddress(address) - setQuery('') - setOpen(false) - onSelectedAddressChange(address) - }, - [onSelectedAddressChange] - ) - - return ( -
-
- - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.406a35121c', - 'Network Interface' - )} - -
-

- {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.d536b5e20d', - 'Choose which network address to advertise in the QR code. Use your LAN address for same-network pairing, or an overlay network address (Tailscale, ZeroTier) for cross-network access.' - )} -

-
-
- - - - - - - - - - {showInlineError ? ERROR_MESSAGE : translate( - 'auto.components.settings.MobileNetworkInterfaceSection.new-combobox-empty', - 'No matching interfaces' - )} - - {entries.map((entry, index) => { - if (entry.kind === 'interface') { - return ( - handleSelectInterface(entry.iface)} - > - {formatInterfaceLabel(entry.iface)} - - ) - } - const isFirstUseQuery = index > 0 - return ( -
- {isFirstUseQuery ? : null} - handleSelectUseQuery(entry.address)} - > - Use "{entry.address}" - -
- ) - })} -
-
-
-
- - - - - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d', - 'Refresh network interfaces' - )} - - -
- {showInlineError ? ( -

- {ERROR_MESSAGE} -

- ) : null} - -
- - - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.39fad211d9', - 'Connect outside your Wi-Fi with a tailnet' - )} - - -

- {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.9fc5d203ff', - 'Orca Mobile connects directly to this computer. To use it away from the same local network, put your computer and phone on the same private overlay network, then generate the QR code with that network address selected.' - )} -

-
    -
  1. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.51d29927eb', - 'Install' - )}{' '} - {' '} - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.668016be7a', - 'on your computer and phone.' - )} -
  2. -
  3. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.1f7c26d36a', - 'Sign in to the same tailnet on both devices.' - )} -
  4. -
  5. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.87985ba6f5', - 'In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z IP.' - )} -
  6. -
  7. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.63d5e4ae1e', - 'Regenerate the QR code and scan it from the Orca mobile app.' - )} -
  8. -
-
-
-
-
- ) - } - ``` - - Notes for the implementer: - - `Command shouldFilter={false}` because we already filter in `buildComboboxEntries`. Without this, cmdk's default fuzzy filter would re-filter the manually-injected `__use__` row out of view. - - The `value` prop on each `CommandItem` keeps cmdk happy but does not affect our filtering; we own filtering. - - `useMemo` is used for `selectedIface`, `entries`, and `queryParse` so re-renders triggered by parent prop changes don't churn the combobox state. - - If `isCustomLabel` is unused after a lint pass (no caller in the final file), delete the helper — the spec lists it but the implementation inlines the check. - -- [ ] **Step 6: Run the new test file** - - ```bash - pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx - ``` - Expected: all six tests pass. - - If any fail: - - `toHaveTextContent` mismatches → check the trigger label format. - - `getByRole('option', { name: /Use "…"/ })` not found → verify `Command shouldFilter={false}` is set. - - Inline error not appearing → verify `showInlineError` is computed against `queryParse.ok`. - -- [ ] **Step 7: Run the full test suite for the settings folder** - - ```bash - pnpm vitest run src/renderer/src/components/settings/ - ``` - Expected: all green. Confirm nothing else broke (e.g. `MobilePairingQrSection.test.tsx` if it exists). - -- [ ] **Step 8: Lint, typecheck, build** - - ```bash - pnpm lint - pnpm typecheck - pnpm build - ``` - Expected: all green. If `pnpm lint` complains about an unused import (e.g. `isCustomLabel` or `CommandSeparator`), remove it and re-run. - -- [ ] **Step 9: Commit** - - ```bash - git add src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx \ - src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx - git commit -m "feat(mobile-pairing): combobox with manual address entry" - ``` - ---- - -## Task 4: End-to-end verification & PR prep - -**Files:** none modified; verification only. - -- [ ] **Step 1: Run the four CI checks locally** - - ```bash - pnpm lint - pnpm typecheck - pnpm test - pnpm build - ``` - Expected: all green. This is exactly what `.github/workflows/` runs. - -- [ ] **Step 2: Manual smoke check** - - If Orca can be launched locally with the desktop build: - 1. Open Settings → Mobile. - 2. Click the Network Interface dropdown — confirm both interfaces list. - 3. Type a tailnet address — confirm the `Use "…"` row appears. - 4. Click `Use "…"` — confirm the trigger switches to `… (custom)`. - 5. Click Refresh — confirm the custom selection survives. - 6. Type an invalid string — confirm the inline error appears and no `Use …` row is shown. - 7. Type an address that already matches an interface (e.g. `192.168.1.24`) — confirm the `Use` row is hidden and selecting the interface row does NOT show `(custom)`. - - Skip any step that requires a running desktop build and note it in the PR description. - -- [ ] **Step 3: Capture before/after screenshots** - - Save screenshots to a temp directory (NOT to the repo — `CONTRIBUTING.md` and `AGENTS.md` forbid committing PR evidence images). Use gstack browse if running the production binary: - ```bash - $HOME/.claude/skills/gstack/browse/dist/browse screenshot /tmp/orca-mobile-section-before.png --selector ... - ``` - Attach the screenshots to the PR conversation (never use `gh-attach`). - -- [ ] **Step 4: Push the branch and draft the PR** - - ```bash - git push origin HEAD - gh pr create \ - --title "feat(mobile-pairing): combobox with manual network address entry" \ - --body-file /tmp/pr-body.md - ``` - - The PR body should follow `pull_request_template.md`: - - **Summary:** explain that the desktop Network Interface dropdown now accepts a manually-typed IPv4 or Tailscale MagicDNS hostname. - - **Screenshots:** link to the conversation-attached screenshots. - - **Testing:** check all four `pnpm` boxes; note that `pnpm test` includes the three new test files (`manual-address.test.ts`, `mobile-network-interface-selection.test.ts`, `MobileNetworkInterfaceSection.test.tsx`). - - **AI Review Report:** confirm the review checked cross-platform (no platform-specific code; uses shadcn primitives + cmdk patterns already in the codebase), SSH/remote/local compatibility (no change to main-process IPC), agent/integration compatibility (no change), performance (UI re-render cost is bounded by `useMemo` over the interface list), UI quality (follows STYLEGUIDE.md; uses `text-statusRed` for the error), security (no new IPC; the address flows through the existing `selectedAddress` prop which `MobilePairingQrSection` already validated). - - **Security Audit:** no new IPC, no new auth surface, no new dependency, no new env var. The parser rejects malformed input early so the QR endpoint never sees invalid data. - - **Notes:** none — single platform-agnostic renderer change. - - **X handle:** include the contributor's X handle per CONTRIBUTING.md so maintainers can shout out. - -- [ ] **Step 5: Request review** - - ```bash - gh pr edit --add-reviewer - ``` - Or comment `@` in the PR if reviewer auto-assignment isn't available. - ---- - -## Definition of done - -- All four `pnpm` checks pass locally. -- All new and existing tests pass. -- The component renders correctly in a smoke test (or smoke-test steps are documented as skipped in the PR). -- The PR is open with a body matching `pull_request_template.md`. -- The contributor's X handle is in the PR description. diff --git a/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md b/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md deleted file mode 100644 index ac3249d306e..00000000000 --- a/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md +++ /dev/null @@ -1,131 +0,0 @@ -# SSH Repo Host Reconciliation Design - -Date: 2026-07-10 - -## Problem - -The proven flow is **Settings -> SSH -> remove host -> re-add the same host**. Main matches the -removal tombstone and `Store.reassignSshTargetId` moves the persisted repo from the old target ID to -the new target ID without changing `Repo.id`. A renderer catalog merge can then retain the cached -old-target row alongside the fetched new-target row because repo rows are keyed by execution host -and repo ID. - -The stale row can route a terminal or destructive action to a removed SSH target. PR #7997 made -worktree deletion host-scoped and fail closed; this follow-up removes the superseded renderer row -without weakening that boundary. - -## Why UUID Inference Is Unsafe - -The same `Repo.id` can legitimately exist on multiple hosts. For example, a local checkout and a -checkout on an SSH server can share the repository UUID. Removing the SSH host without re-adding it -must keep the SSH ghost visible so the user can forget it. A local or runtime row with the same UUID -does not prove that the SSH row moved. - -Main already has exact migration evidence. `readoptOrphanedWorkspacesForTarget` selects a removed -target tombstone, and `Store.reassignSshTargetId` knows which repo IDs it moved from that old target -to the new target. The renderer must consume this evidence instead of inferring migration from live -siblings or SSH target metadata. - -## Ordering Gap - -Main sends `repos:changed` before `ssh:addTarget` or `ssh:importConfig` returns. Either order is -possible in renderer state: - -1. The catalog transaction can merge old and new rows before the add response supplies migration - evidence. -2. The add response can supply evidence while renderer state still contains only the old row. - -Evidence must therefore remain pending until the corresponding new direct-SSH row arrives. At that -point the renderer removes only the mapped `(repoId, oldTargetId)` row and its old host setup, and -moves cached worktree ownership onto the new host. - -## Non-Goals - -- Do not re-key repo or worktree UUIDs or introduce compound serialized IDs. -- Do not infer host migration from labels, paths, target-list absence, or same-UUID siblings. -- Do not remove PR #7997's execution-host context from destructive operations. -- Do not change git-provider behavior or add provider-specific assumptions. - -## Design - -1. `Store.reassignSshTargetId` returns the exact repo IDs it moved instead of only a count. -2. Main records a list of `{ oldTargetId, newTargetId, repoIds }` for each add/import operation. -3. The SSH IPC add and import results return the changed targets plus this migration evidence. Main - still sends `repos:changed` when at least one repo moved. -4. Both desktop SSH management surfaces record the evidence in the repo slice immediately after the - invoke resolves. The web preload returns an empty evidence list because target management is - desktop-only. -5. The repo slice merges duplicate evidence and keeps unresolved repo IDs pending. Every local, - targeted-runtime, and all-host catalog transaction reconciles against the latest pending set. -6. Reconciliation requires the exact new direct-SSH owner to exist before pruning the exact old - direct-SSH owner. Local rows, runtime-owned rows, unrelated SSH hosts, and same-UUID siblings are - not considered evidence. -7. When a repo row is pruned, its matching `ProjectHostSetup` is removed. The desktop catalog owns - local and direct-SSH setups; `runtime:` setups remain authoritative on the remote - Orca server. -8. Cached visible and detected worktree rows for the migrated repo are moved from the exact old SSH - host to the new host. If a new-host worktree row already arrived, it wins and the stale duplicate - is discarded. -9. A host-scoped worktree response is ignored if its captured repo/host owner no longer exists. - Repo catalog transactions also reapply pending worktree migration, covering responses that land - after evidence arrives but before the new repo row. - -```text -main: remove target -> tombstone -main: re-add matching identity -> reassign old target ID to new target ID -main: return exact (old target, new target, moved repo IDs) -renderer: retain evidence until catalog includes the exact new SSH owner -renderer: remove only the exact old SSH row/setup and migrate cached worktree ownership -``` - -## Safety Constraints - -- A local or runtime sibling with the same UUID never supersedes an SSH ghost. -- A mapping is not consumed until the exact new direct-SSH row exists. -- Runtime-owned SSH rows and `runtime:` setup rows remain untouched. -- Catalog transactions reconcile inside the Zustand updater so overlapping responses use the latest - repos and pending evidence. -- Missing, offline, or unhydrated target metadata is not deletion or migration evidence. - -## Test Plan - -- Persistence returns exact migrated repo IDs and leaves other hosts untouched. -- Tombstone re-adoption returns exact old/new/repo mappings for manual add and multi-host import. -- SSH IPC returns the evidence, sends `repos:changed`, and clears operation-local evidence. -- Pure renderer tests prove exact pruning, pending evidence, unrelated-host preservation, runtime - ownership preservation, and evidence deduplication. -- Store tests cover both event orders: catalog first and evidence first. -- Remove-without-re-add keeps a forgettable SSH row and setup when a local repo shares its UUID. -- Old direct-SSH setups are removed only for proven migrations; runtime setups remain. -- Old/new cached worktree duplicates collapse to the authoritative new-host row, and an old-only - worktree row migrates to the exact new host. -- A deferred old-host worktree response cannot restore old visible or detected ownership after the - new repo catalog consumes the migration evidence. -- Targeted runtime and all-host catalog transactions reconcile against the latest state. - -Run focused tests, typecheck, lint, `pnpm check:max-lines-ratchet`, and the production build required -by the repository. - -## Electron Validation - -Use an isolated profile and a throwaway Linux SSH target: - -1. Add a repo through the throwaway SSH target. -2. Remove the SSH target and re-add the same identity without restarting the renderer. -3. Confirm exactly one SSH project row remains and its worktree opens. -4. Confirm Explorer lists the remote files without an ambiguous-host routing error. -5. Repeat the cycle and confirm the stale row does not return. -6. Keep an adjacent local project visible and confirm it remains unchanged. - -There is no new control or copy. The behavioral evidence is the stable row, successful workspace -routing, and deterministic store assertions. - -## Performance And Scope - -Renderer reconciliation indexes direct SSH repo owners once, then processes each migrated repo ID -once. Setup cleanup is linear in current repos and setups. The change adds no polling, listeners, -subprocesses, or persistent renderer state. IPC payload growth is bounded by the repo IDs actually -migrated during the user-triggered add/import operation. - -Windows and POSIX paths do not participate in reconciliation. SSH identity matching remains the -existing strict alias or host/user/port logic, and git-provider identity is not used. diff --git a/docs/reference/project-ordering-mode.md b/docs/reference/project-ordering-mode.md deleted file mode 100644 index 4cdc159551b..00000000000 --- a/docs/reference/project-ordering-mode.md +++ /dev/null @@ -1,108 +0,0 @@ -# Project Ordering Mode - -## Problem - -The sidebar can group workspaces by project, but project header order is currently coupled to workspace sorting instead of having its own user choice. - -- `src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx:59` defines one `Sort by` control for workspaces; it has no separate project-order setting. -- `src/renderer/src/store/slices/ui.ts:723` stores only `sortBy`, and `src/shared/types.ts:2543` persists the same workspace sort field. -- `src/renderer/src/components/sidebar/worktree-list-groups.ts:36` derives `ProjectGroupOrdering` from `sortBy`, so project headers follow the highest-ranked visible workspace in Recent/Smart and fall back to manual order otherwise. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:3765` computes the ordered workspace ids, and `src/renderer/src/components/sidebar/WorktreeList.tsx:4060` feeds the derived project ordering into `buildRows(...)`. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:826` only enables project-header drag when project grouping is manual and there are no Project Groups. -- `src/renderer/src/components/sidebar/project-header-drag.ts:1` already implements pointer-based project header dragging for virtualized rows, and `src/renderer/src/store/slices/repos.ts:699`, `src/main/ipc/repos.ts:1029`, and `src/main/persistence.ts:2497` already persist whole-repo manual reorders. - -Result: users cannot choose Manual vs Recent ordering specifically for projects, the default is not Manual, and changing workspace sort can unexpectedly reorder projects. - -## Goal - -Add a project-only ordering preference with two modes: - -- `Manual`, the default. Project headers render in the persisted manual project order, and users can drag project headers to reorder them. -- `Recent`. Project headers render by the most recent visible workspace activity in each project. - -This must affect only project header order in `groupBy: 'repo'`. Worktree/workspace rows inside each project must continue to use the existing workspace `sortBy`, filtering, pinning, lineage, status, and manual-order behavior. - -## Non-goals - -- Do not change `sortBy` semantics or the workspace `Sort by` control. -- Do not change worktree `manualOrder`, `sortOrder`, `lastActivityAt`, or `buildWorktreeComparator(...)`. -- Do not reorder worktrees inside a project when the project ordering mode changes. -- Do not change Project Group header order; groups continue to use `ProjectGroup.tabOrder`. -- Do not add GitHub-specific behavior. Repo/project ordering must remain provider-neutral and work for GitLab, folder projects, local repos, SSH repos, and runtime environments. -- Do not add telemetry in this pass unless a later product requirement asks for it. - -## Design - -1. Add a persisted project-order preference. - - Introduce `ProjectOrderBy = 'manual' | 'recent'` in `src/shared/types.ts` or a concrete sidebar ordering module. - - Add `projectOrderBy: ProjectOrderBy` to `PersistedUIState`, defaulting to `'manual'` in `getDefaultUIState()` (`src/shared/constants.ts:387`). - - Add `projectOrderBy` and `setProjectOrderBy` to the UI slice next to `sortBy` (`src/renderer/src/store/slices/ui.ts:723`). Like `setSortBy`, this setter is a bare `set({...})` — it does not persist on its own. - - Persist through the debounced UI writer, not the setter. `sortBy`/`groupBy` reach disk only via the explicit field list in `App.tsx`'s `window.api.ui.set({...})` effect (`src/renderer/src/App.tsx:1019`) plus its dependency array (`src/renderer/src/App.tsx:1042`). Add `projectOrderBy` to both, or the value lives only in memory and silently resets to `'manual'` every restart. - - Normalize persisted values in `src/main/persistence.ts` the same way `sortBy` is normalized at `src/main/persistence.ts:323`, and wire the normalizer into both `getUI()` (`src/main/persistence.ts:3116`) and `updateUI()` (`src/main/persistence.ts:3147`); invalid or missing values resolve to `'manual'`. - - Hydration in `src/renderer/src/store/slices/ui.ts:1862` should read the normalized value without migrating existing `sortBy`. - -2. Expose the choice in the sidebar options menu. - - In `SidebarWorkspaceOptionsMenu`, keep the existing `Sort by` submenu as workspace sorting. - - Add a second radio submenu labeled `Project order` with `Manual` and `Recent`. - - Show it only when `groupBy === 'repo'`, because project ordering has no visible effect in `none`, `workspace-status`, or `pr-status`. - - Use existing `DropdownMenuRadioGroup` and sidebar/menu styling from `docs/STYLEGUIDE.md`; no new tokens or custom colors. - - Copy should make the scope clear: Manual description `Drag projects to arrange them`; Recent description `Most recent workspace activity`. - -3. Decouple project header ordering from workspace sorting. - - Replace `getProjectGroupOrdering(groupBy, sortBy)` with a project-order resolver that depends on `groupBy` and `projectOrderBy`, not workspace `sortBy`. - - Update `buildRows(...)` so `groupBy !== 'repo'` ignores project order, `projectOrderBy === 'manual'` uses persisted manual ranks, and `projectOrderBy === 'recent'` sorts project header entries by a per-repo recent timestamp. - - Recent must be timestamp-based, not first-encounter. Today's `'visible-worktree-order'` path (`src/renderer/src/components/sidebar/worktree-list-groups.ts:548`) works only because the caller pre-sorts the worktree array by recency when `sortBy` is `recent`/`smart`. Decoupling from `sortBy` removes that guarantee — the incoming array may be name- or manual-sorted — so the Recent resolver must explicitly compute `max(lastActivityAt)` per repo rather than relying on encounter order. - - Recent project rank should be the maximum `lastActivityAt` among that repo's visible, unpinned worktrees passed into `buildRows(...)`. Empty placeholder projects and imported-worktree-card-only projects fall back to `Repo.addedAt`, then manual rank, then label. - - Do not reorder `group.items`; only reorder the project header entries before `appendOrderedGroups(...)`. This preserves the existing `orderMainWorktreeFirst(...)` and workspace row ordering at `src/renderer/src/components/sidebar/worktree-list-groups.ts:632`. - - With Project Groups, keep group headers ordered by `tabOrder`; apply Manual/Recent only to project entries within each group or the ungrouped bucket. - -4. Make Manual drag/drop project-scoped (no-Project-Groups case only in v1). - - Enable project header dragging when `groupBy === 'repo' && projectOrderBy === 'manual'`, regardless of workspace `sortBy`. Today `canReorderRepoHeaders` is additionally gated on `!hasProjectGroups` (`src/renderer/src/components/sidebar/WorktreeList.tsx:827`); the only change here is dropping the `sortBy`-derived `projectGroupOrdering` input in favor of `projectOrderBy === 'manual'`. The `!hasProjectGroups` gate stays. - - Keep using pointer events from `project-header-drag.ts` unchanged; the virtualized-row reasoning at `src/renderer/src/components/sidebar/project-header-drag.ts:3` still applies. For the no-Project-Groups case it already does exactly what we need: a flat whole-list permutation committed through `reorderRepos(...)`, preserving current behavior and IPC rejection semantics. - - Continue to suppress click-to-collapse only after a promoted drag, matching `src/renderer/src/components/sidebar/project-header-drag.ts:142`. - - Defer grouped (within-Project-Group) drag to a follow-up. `useRepoHeaderDrag` is built for a single flat `orderedRepoIds` permutation: `endDrag` (`src/renderer/src/components/sidebar/project-header-drag.ts:130`) computes `fromIndex`/`insertAt` over one array and calls `onCommit` → `reorderRepos`, and `onHandlePointerDown` (`src/renderer/src/components/sidebar/project-header-drag.ts:300`) snapshots every on-screen `[data-repo-header-id]` with no notion of group boundaries. Supporting in-group reorder needs new hook logic — bucket-aware drop targets, rejecting drops outside the source sibling bucket, computing a midpoint between neighbor `projectGroupOrder` values, and a second commit mode that calls `moveProjectToGroup(projectId, sameGroupId, order)` (`src/renderer/src/store/slices/repos.ts:138`) instead of `reorderRepos`. This is its own change and is out of scope for v1. - - In v1, Project-Groups users keep the project actions menu as the move surface (it already calls `moveProjectToGroup`). Manual project order still applies to their headers via the row-builder sort (step 3); only drag-to-reorder is unavailable inside groups. - - Do not turn a project reorder drag into a cross-group move in any version. The existing project actions menu remains the cross-group move surface. - -5. Keep persistence and runtime parity intact. - - Local no-group manual reorder continues through `repos:reorder` (`src/main/ipc/repos.ts:1029`) and `Store.reorderRepos(...)` (`src/main/persistence.ts:2497`). - - Remote no-group manual reorder continues through `repo.reorder` (`src/renderer/src/store/slices/repos.ts:723`). - - Grouped manual reorder via drag is deferred (see step 4); the follow-up would route through `projectGroup.moveProject` / `moveProjectToGroup(...)`, which already handles local and runtime-environment calls. In v1, grouped projects move only through the existing actions menu. - - The project-order preference is renderer UI state, not repo metadata. It persists through the `App.tsx` debounced `window.api.ui.set({...})` writer (step 1), not through any repo record. - -6. Tests. - - Add row-builder tests proving `projectOrderBy: 'manual'` orders project headers by `repoOrder` without changing workspace row order. - - Add row-builder tests proving `projectOrderBy: 'recent'` orders project headers by max visible `worktree.lastActivityAt` while preserving each project's child row order. - - Add Project Group tests for Manual and Recent within groups, plus unchanged `ProjectGroup.tabOrder`. - - Rewrite, do not extend, the existing Recent/`getProjectGroupOrdering` tests. The current cases at `src/renderer/src/components/sidebar/worktree-list-groups.test.ts:639` ("orders repo headers by first encounter…"), `:663` ("…highest-ranked visible child"), and the `getProjectGroupOrdering` block at `:752` all assert the old first-encounter coupling and are semantically incompatible with timestamp-based Recent. Replace them with tests for the new resolver and the `max(lastActivityAt)` ordering. - - Add UI slice/persistence normalization tests for default Manual, invalid value fallback, and hydration. Include a persistence-writer test (or note) that `projectOrderBy` is part of the `App.tsx` `ui.set` payload so it actually round-trips across restart. - - Grouped manual drag persistence tests are deferred with the grouped-drag feature (step 4). - -## Edge cases - -- Existing users with no `projectOrderBy` get Manual, even if their workspace `sortBy` is Recent. -- Changing workspace `sortBy` must not change project header order unless `projectOrderBy === 'recent'` and the visible worktree set/activity data changes. -- Changing `projectOrderBy` must not mutate any worktree `manualOrder` or repo order by itself. -- Manual drag while a project is added or removed can race. Whole-repo reorder (the only v1 drag path) keeps the existing permutation rejection and refetch behavior. (Grouped midpoint writes and their refetch-on-failure handling come with the deferred grouped-drag follow-up.) -- In Recent mode, projects with no visible worktrees should still render when they are placeholders or imported-worktree-card candidates; they sort after projects with activity. -- Pinned worktrees remain in the Pinned section. They should not make their project jump in Recent ordering unless an unpinned visible workspace in that project is also recent. -- Filters and hidden sleeping/default-branch workspaces affect the visible worktree set. Recent project order should reflect the rows the user can currently see. -- Project Group collapse state must not change when the order mode changes. -- Dragging a project inside a collapsed Project Group is impossible because its repo headers are not mounted; no special handling is needed. -- Dragging across Project Group boundaries should not silently move the project; cross-group moves stay on the actions menu. (In v1 there is no in-group drag at all — see step 4 — so this only constrains the deferred grouped-drag follow-up.) -- SSH/runtime projects must use the same store actions as local projects. No local filesystem path assumptions are needed. -- Folder projects have synthetic worktrees and should participate through the same `lastActivityAt` and manual repo order paths. -- Multi-window or external mutations are last-writer-wins through existing persistence. A rejected whole-repo permutation refetches repos (existing behavior); a failed grouped move would do the same once grouped drag lands. -- Behavior change on upgrade: existing users on `sortBy: recent`/`smart` currently see project headers bubble to follow workspace activity. After this change they default to Manual project order, so headers stop bubbling until they pick Recent in the new submenu. This is intended (matches the "no `projectOrderBy` → Manual" edge case) but is a visible change worth calling out in release notes. - -## Rollout - -1. Add `ProjectOrderBy` types/defaults/normalization, the UI slice setter, the `App.tsx` debounced-writer field, and persistence `getUI()`/`updateUI()` wiring. -2. Add the `Project order` submenu in `SidebarWorkspaceOptionsMenu`. -3. Update `WorktreeList` to read `projectOrderBy`, pass it to row construction, and enable project drag only in Manual project order (no-Project-Groups case, keeping the `!hasProjectGroups` gate). -4. Update `worktree-list-groups.ts` to order project headers by Manual or Recent independently from workspace `sortBy`, with Recent using `max(lastActivityAt)` per repo. -5. Add focused row-builder and UI persistence/round-trip tests; rewrite the incompatible first-encounter/`getProjectGroupOrdering` tests. -6. Run targeted Vitest for sidebar row ordering and repo slice tests, then `pnpm typecheck` and `pnpm lint`. -7. Validate in Electron: default startup shows Manual project order, the choice survives restart, project drag/drop reorders project headers only (ungrouped), Recent project order follows workspace activity, and worktree rows inside each project do not change when toggling project order. - -Deferred follow-up (separate change): extend `useRepoHeaderDrag` for grouped sibling buckets — bucket-aware drop targets and a second commit mode using `moveProjectToGroup(...)` midpoint ordering inside Project Groups — plus its grouped-drag persistence tests. diff --git a/docs/reference/reliability-gates-implementation-plan.md b/docs/reference/reliability-gates-implementation-plan.md deleted file mode 100644 index 818bdb175e1..00000000000 --- a/docs/reference/reliability-gates-implementation-plan.md +++ /dev/null @@ -1,783 +0,0 @@ -# Reliability Gates Implementation Plan - -Date: 2026-07-02 - -Source context: [`docs/reference/reliability-pain-points-2026-06-30.md`](./reliability-pain-points-2026-06-30.md). - -## Current Status - -Last updated: 2026-07-03. - -The first repo-side reliability batch is useful, but it should be treated as a first-layer regression net, not a finished terminal reliability program. Before final merge claims, rebase or retarget the stack onto fresh `origin/main` and verify it still includes the protections that landed there after the branch was cut. - -Stack topology facts the rest of this plan depends on (verified 2026-07-02): - -- `brennanb2025/fix-terminal-reliability` is simultaneously the integration worktree and the head branch of #7001, and it is the base branch of all five child PRs (#7004-#7008). Committing broad product changes to it would turn #7001 into the single large terminal PR this plan forbids and would silently change every child PR's diff. -- The committed branch contains only the manifest, checker, checker tests, and these docs (a 5-gate manifest as committed). Everything else this plan describes — the expanded manifest, product hardening, and new or updated tests across roughly 39 modified files plus 3 untracked files — exists only as uncommitted working-tree changes in this worktree. No evidence run below is reproducible from any pushed ref until the split branches are committed and pushed. -- The branch's merge-base with `origin/main` is roughly 97 commits stale. Fresh main renamed `terminal-webgl-paste-recovery.{ts,test.ts}` to `terminal-webgl-atlas-recovery.{ts,test.ts}` in #6949, so the current `xterm-addon.boundary-containment` manifest entry and its documented command break on rebase; see Fresh-Eyes Corrections. -- The manifest is the single source of truth for gate counts and per-gate status. Where this document records counts they are dated snapshots; `pnpm run check:reliability-gates` prints the current gate total. - -Known Orca PR stack: - -- [#7001](https://github.com/stablyai/orca/pull/7001), `Add reliability gate manifest`: merged on 2026-07-03. The manifest, checker, and plan docs are on main; [#7295](https://github.com/stablyai/orca/pull/7295) hardens the checker policy and registers the merged #7133/#7148/#7173/#7192 regression tests as partial gates with fresh main-side evidence. -- [#7005](https://github.com/stablyai/orca/pull/7005), `Add terminal snapshot freshness contract gate`: depends on #7001 and protects the stale-liveness/newborn-PTY class behind `terminal-session.snapshot-freshness`. -- [#7008](https://github.com/stablyai/orca/pull/7008), `Expand provider session replay ownership gate`: depends on #7001 and protects the agent/provider ownership class behind `agent-session.provider-ownership`. -- [#7004](https://github.com/stablyai/orca/pull/7004), `Contain xterm addon failures to terminal panes`: depends on #7001. The current branch has a first executable xterm containment slice for addon load, link-provider, search, and WebGL failure containment; it still needs live typed input/output survival before promotion. -- [#7006](https://github.com/stablyai/orca/pull/7006), `Prove visible terminal size convergence`: depends on #7001. The current branch now has a first deterministic renderer/main contract slice for post-spawn reconcile, split-right 0x0 recovery, applied `pty:getSize`, and resume-time size reassertion; it still needs live shell, SSH/remote, and Windows ConPTY geometry proof before promotion. -- [#7007](https://github.com/stablyai/orca/pull/7007), `Add persisted session upgrade fixture gate`: depends on #7001. The current manifest entry is still commandless in this branch, so treat it as a registered startup-upgrade gap until immutable fixture commands are wired. - -Merged related product hardening: - -- [#7054](https://github.com/stablyai/orca/pull/7054), `Prevent hidden terminal TUI overlap`: merged on 2026-07-01 with `verify`, Wayland terminal input, golden Linux E2E, golden mac E2E, and community tracking checks passing. This fixes the hidden regular-terminal TUI overlap class by scheduling bounded post-parse WebGL atlas recovery for risky hidden TUI redraws while keeping hidden bytes on the background write path. -- [#7133](https://github.com/stablyai/orca/pull/7133), `Fix stale terminal frames on worktree return`: merged on 2026-07-02 as a high-priority regression fix, root-caused on a live repro. Hidden-output restore skipped the destructive clear for alternate-screen snapshots, `?1049h` is a no-op on a pane already on the alt screen, and serialized frames skip blank cells, so the pre-hide frame bled through the final frame until a select or resize. The fix writes an alt-only `\x1b[?1049h\x1b[2J\x1b[H` preamble before alt-screen snapshot data (normal-buffer scrollback untouched, still no `3J`) and hardens the WebGL reveal path: dispose-on-bail with a single-addon invariant, refresh-always atlas reset, and a settled-frame pane-scoped repaint on tab reveal, worktree resume, and window wake. It supersedes the reverted #7058 approach and closes the alt-screen hole that #7054's hidden-output skip machinery made load-bearing. Its deterministic tests — the alt-only-clear-before-snapshot assertions in `pty-connection.test.ts`, plus `pane-webgl-renderer.test.ts`, `pane-reveal-repaint.test.ts`, and `terminal-visibility-resume.test.ts` — are the seed assertions for `terminal-output.scrollback-restore` and the WebGL side of `xterm-addon.boundary-containment` once the stack rebases. - -- [#7148](https://github.com/stablyai/orca/pull/7148), `Match main terminal mirror character widths to the renderer`: merged on 2026-07-02, hours after #7133. Root cause of the residual post-#7133 bottom-row tears: the renderer xterm measures with Unicode 11 + ZWJ joining while the daemon headless mirror used default-width tables, so every positioned write after an emoji lands shifted and the tears are baked into the very mirror that restores replay — #7133's clear made restores faithful to the mirror and exposed it. The fix moves the unicode provider to `src/shared/terminal-unicode-provider.ts` and activates it in the headless emulator. Its red-green width test, `src/main/daemon/headless-emulator-unicode-width.test.ts` on main, is the seed assertion for the new `terminal-mirror.parser-parity` gate, and the shared provider plus `pane-terminal-options.ts` are the shared-construction seams that gate must assert on. #7148 also touches `pane-lifecycle.ts`, which both this worktree and #7004's branch modify — another confirmed rebase-conflict site. -- [#7173](https://github.com/stablyai/orca/pull/7173), `Fix Codex pane output tearing after app occlusion`: merged on 2026-07-03. A delayed background-origin Codex chunk carrying a stateful query could take the hidden live-write path after a newer snapshot restore had already rebuilt the renderer, overwriting newer state; an adversarial review pass added seq-restart guards so revived sessions (hibernation wake, kill plus cold-restore under the same PTY id) cannot silently freeze hidden output against a stale seq high-water mark. Its three red-proven tests in `pty-connection.test.ts` on main — reference-parse buffer equality over the ordered raw stream plus both revival guards — are exactly the exactness-oracle shape this plan prescribes and seed the hidden/live interleaving slice of `terminal-output.scrollback-restore`. -- [#7192](https://github.com/stablyai/orca/pull/7192), `Keep runtime mirror sized with desktop terminal resizes`: merged on 2026-07-03. Desktop `pty:resize` updated the PTY and renderer but never the runtime mirror, so the mirror parsed all subsequent bytes at its birth width, soft-wrapped TUI repaints into a corrupted ladder, and baked that corruption into every restore snapshot; the fix fans accepted desktop resizes to the mirror and serializes mirror reflow with queued output writes on the per-PTY write chain. This exposed a plan gap now fixed: the runtime mirror is a geometry authority in its own right, and renderer-vs-mirror convergence checks cannot see corruption upstream of the mirror. Its red-proven tests in `orca-runtime.test.ts` and `pty.test.ts` on main seed the mirror-geometry slice of `terminal-geometry.visible-convergence`. - -Factory and skill status: - -- [orca-internal #491](https://github.com/stablyai/orca-internal/pull/491), `Require reliability gates in factory reviews`: merged on 2026-07-01. This updates the factory review flow so terminal/session/provider/startup/release changes must name the relevant reliability class, invariant, gate or accepted gap, and validation evidence. -- [orca-internal #493](https://github.com/stablyai/orca-internal/pull/493), `Add Playwright reliability test skill`: as last reviewed, this still needed rebase/regeneration before merge because the reliability plan depends on Playwright tests being evidence-based, low-flake, and tied to named invariants. - -Remaining work: - -- Rebase or retarget the reliability stack on fresh `origin/main` before making final merge claims. Current main already contains terminal fixes that the stack must absorb, including hidden TUI overlap, input-lag, scrollback replay, Windows ConPTY keyboard reset, resume width flicker, remote mirror polling, and the #7133 stale alt-screen frame fixes. As part of the rebase, mechanically verify every declared `testFiles` entry in the manifest exists on fresh `origin/main`; the #6949 WebGL rename is a confirmed break. #7133 (merged 2026-07-02) touches `pty-connection.{ts,test.ts}` and `use-terminal-pane-global-effects.test.ts`, which this worktree also modifies, and `pane-webgl-renderer.{ts,test.ts}`, which #7004's branch also edits — expect real conflicts there. #7148 (merged 2026-07-02) additionally touches `pane-lifecycle.ts` and `headless-emulator.ts`, so the worktree's and #7004's pane-lifecycle edits must absorb its shared unicode-provider wiring. #7173 (merged 2026-07-03) rewrites the hidden live-write path in `pty-connection.{ts,test.ts}` and #7192 (merged 2026-07-03) touches `pty.test.ts` — both carry heavy worktree modifications, so the conflict surface keeps widening the longer the rebase waits. This worktree's replay/SSH/backpressure changes and #7004's containment changes must be rebased over #7133's alt-clear preamble, #7148's width fix, #7173's seq filtering, #7192's mirror resize fan-out, and the reveal-repaint hardening without weakening any side. -- Materialize the split: commit each uncommitted working-tree slice to its owning branch per the file assignment in the PR Split Plan, reset diverged child branches from the worktree versions of their files, and push. Only manifest, checker, and doc changes may be committed to `brennanb2025/fix-terminal-reliability` itself. -- Wire the aggregated non-blocking `reliability-gates` CI job (see Evidence Provenance And CI Wiring) so runtime and flake history accrue with machine provenance instead of hand-entered evidence. -- Split the current integration branch into focused PRs before final review and merge. Do not land this as one large terminal reliability PR; each PR should have its own invariant, evidence command, residual gaps, dependency note, and follow-up relationship to the rest of the stack. -- #7001 merged on 2026-07-03. Merge the gate-specific PRs only after resetting each from the worktree slice per the file assignment. -- Before merging each child PR, recheck it against the latest #7001/main because the shared manifest file can create normal stack drift or conflicts even when the PR currently reports clean. -- After merge, run the registered gate commands in soak and record runtime/flake evidence before promoting any gate to blocking. -- Update the factory skills again after the repo-side gate commands and CI jobs are stable, so agents can invoke exact commands instead of only requiring reliability evidence. -- Keep #7054's accepted gap visible: deterministic Claude-like TUI streams validated the renderer path, but live Claude Code was not part of that PR's validation. #7133 has since closed the alt-screen snapshot-restore hole in the same hidden-output machinery, and its buffer-merge root cause is exactly the class the `terminal-output.scrollback-restore` gate must make hard to reintroduce; the live-Claude validation gap remains. - -## PR Split Plan - -The current `brennanb2025/fix-terminal-reliability` branch is an integration branch, not the desired final review shape. It should be split into smaller PRs so reviewers can verify one reliability class at a time, bisect failures, and avoid merging a broad terminal lifecycle change without understanding which protection each file belongs to. - -Branch and manifest ownership rules: - -1. `brennanb2025/fix-terminal-reliability` is #7001's head branch. Only manifest, checker, checker-test, and plan-doc changes may be committed to it. Split items 2-6 go to their own branches, stacked on this branch and retargeted to `main` after #7001 merges. -2. #7001 may only declare gates whose `testFiles` all exist on `origin/main`. The checker's declared-file rule makes this self-enforcing: a manifest entry whose test files land in a later PR cannot pass CI inside #7001. -3. Every later PR owns its own manifest entries. It adds or upgrades the gates it implements — entry, `evidenceRuns`, and `assertionRefs` — in the same PR as the tests and hardening they reference. The manifest file is shared, but each entry has exactly one owning PR, and manifest conflicts are resolved by entry ownership, never by hand-merging divergent copies of an entry. -4. Commandless registered-gap entries (`protection: "none"`, no test files) may ride in #7001 because they reference nothing that can drift. -5. The integration worktree is the single authoritative copy of every overlapping file. Child branches that have diverged (for example, `resume-sleeping-agent-session.ts` on #7008's branch is ~156 diff lines behind this worktree) must be reset from the worktree slice, not reconciled by hand. - -Recommended split: - -1. Reliability gate manifest and plan docs. - This PR owns `config/reliability-gates.jsonc`, the manifest checker hardening, checker tests, and this plan document. It should merge first because later PRs reference gate ids, evidence metadata, and promotion rules from the manifest. It should not include terminal product behavior changes. - -2. Provider ownership and session replay safety. - This PR owns the agent/provider identity invariant: activation, restore, sleep, hibernate, dedupe, clearing, and reconnect paths must not replay a provider session that is already owned, queued, pending, retained, or live in the workspace. It should include the provider-session ownership tests, repeat-activation lower-layer proofs, automatic resume claim coverage, same-session/wrong-session hook coverage, and any narrowly required product hardening. - -3. Startup hydration, provider fanout, and targeted liveness. - This PR owns startup-adjacent PTY registry hydration, targeted `pty.hasPty(id)` liveness, no-hot `pty:listSessions()` paths, Resource Manager polling scoping, SSH/provider unknown-liveness semantics, degraded-daemon fail-closed behavior, and related tests. It should depend on the manifest PR and should explicitly say that live SSH, WSL, remote-runtime, and production startup reconcile remain follow-up gates unless implemented in the same PR. - -4. Terminal lifecycle observability. - This PR owns compact anomaly breadcrumbs and bounded lifecycle traces for terminal lifecycle diagnosis. It should make failures easier to debug without claiming to prevent them. It should prove that diagnostics are compact, deduped or bounded, privacy-conscious, and included in the right local crash/diagnostic surfaces. Diagnostics-bundle export and live forbidden-transition artifacts should remain visible follow-ups until proven. - -5. PTY output, IPC, and performance hardening. - This PR owns daemon stream batching, main-to-renderer pending output caps, ACK/backpressure behavior, replay queue bounds, runtime/mobile stream byte budgets, and related perf-sensitive contracts. It needs the hardest review because small behavior changes here can cause input latency, hidden-output, memory, or ordering regressions. It should include explicit runtime budgets and should not promote any perf gate to blocking without soak evidence. - -6. Live local PTY and E2E reliability slice. - This PR owns the first live Electron/local PTY liveness gate. It should stay small: spawn a real terminal, prove one active PTY, type through focused xterm, verify process-visible output, hide/restore the workspace without duplicating or losing the PTY, resize and verify applied size, then exit and verify cleanup. It should remain `experimental` until CI/runtime history proves it is not flaky. Tab switch, app restart, SSH, WSL, Windows ConPTY, and scrollback restore should be explicit follow-ups unless included with equally strong oracles. - -Mapping to the open stack and file assignment: - -The six split items and the five open child PRs overlap; this mapping is authoritative. Every working-tree file must land in exactly one PR below. Four files contain changes from multiple reliability classes and must be split by hunk, with the earlier PR in the merge order owning its hunks first: `config/reliability-gates.jsonc` (per-entry ownership), `src/main/ipc/pty.ts` and its test, `src/renderer/src/components/terminal-pane/pty-connection.ts` and its test, and `src/renderer/src/store/slices/terminals.ts`. - -1. #7001 (split item 1, manifest and plan docs): `config/scripts/check-reliability-gates.mjs`, `config/scripts/check-reliability-gates.test.mjs`, `docs/reference/reliability-gates-implementation-plan.md`, and the manifest restricted to gates whose test files exist on `origin/main` plus commandless gap entries. -2. #7008 (split item 2, provider ownership and replay safety): `src/renderer/src/lib/resume-sleeping-agent-session.ts` and test, plus its ownership slice of `terminals.ts`. Reset the branch from the worktree slice; keep the PR number for review continuity. -3. #7005 (snapshot freshness and targeted renderer liveness): `terminal-dead-session-reconcile.ts` and test, `use-terminal-pane-lifecycle.ts` and test, `use-terminal-pane-global-effects.test.ts`, and the `pty.hasPty` preload/API plumbing (`src/preload/api-types.ts`, `src/preload/index.ts`, `src/renderer/src/web/web-preload-api.ts`, the `hasPty` slice of `src/main/ipc/pty.ts`). -4. New PR (split item 3, startup hydration, provider contracts, no-hot listing): `hydrate-local-pty-registry.ts` and test, `daemon-pty-adapter.ts` and test, `daemon-pty-router.ts` and test, `degraded-daemon-pty-provider.ts` and test, `pty-session-id.test.ts`, `src/shared/pty-session-id-format.ts`, `src/main/providers/ssh-pty-provider.ts`, `ResourceUsageStatusSegment.tsx`, `resource-manager-session-polling.test.ts` (currently untracked), `store-session-cascades.test.ts`, and the SSH/liveness slices of `pty.ts`, `pty-connection.ts`, and `terminals.ts`. -5. New PR (split item 4, lifecycle observability): `terminal-lifecycle-diagnostics.ts` and `terminal-lifecycle-diagnostics.test.ts` (currently untracked). -6. New PR (split item 5, PTY output/IPC/perf hardening): `daemon-stream-data-batcher.ts` and test, `terminal-output-batching.test.ts`, `terminal-subscribe-buffer.test.ts`, and the pending-output-cap and replay/ACK slices of `pty.ts` and `pty-connection.ts`. -7. #7004 (xterm addon containment, outside the six split items): keep as its own PR; reset `pane-lifecycle.ts` and test from the worktree versions, and adopt fresh main's renamed WebGL test files during rebase. -8. #7006 (visible geometry, outside the six split items): keep as its own PR; any geometry hunks in `pty-connection.ts` belong to it. -9. #7007 (persisted upgrade fixtures): the gate is commandless with `protection: "none"`, so its manifest entry rides in #7001 under rule 4. Keep #7007 open only if it will deliver the actual fixture corpus and command; otherwise close it as absorbed. -10. New PR (split item 6, live local PTY E2E slice): `tests/e2e/terminal-live-pty-liveness.spec.ts` (currently untracked). - -Merge order: #7001, then #7008, then #7005, then startup hydration/provider contracts, then lifecycle observability, then PTY output/IPC/perf hardening, then #7004, then #7006, then the live E2E slice. #7007 can merge any time after #7001 or be closed. The stack stays flat on #7001; each child rebases onto the merged state immediately before its own merge, and manifest conflicts are resolved by entry ownership. - -If one later PR reveals a missing invariant in an earlier PR, update the earlier manifest entry or PR description before promotion rather than silently broadening the later PR's claim. The split is part of the reliability plan because reviewability, dependency clarity, and targeted rollback are themselves reliability controls. - -Each split PR should follow Brennan's PR process before it is considered ready: write a scoped design doc, run delegated design review, implement from the reviewed doc, verify completeness against the original request and reliability gate, audit headline behavior, run `perf`, run the code-review/fix loop, run merge-confidence validation, open an unmerged PR, verify pushed state, and stabilize CodeRabbit/check findings. The PR text should describe that process as "Brennan's PR process"; do not mention the internal skill name in PR-facing text. - -## PR Proof Contract - -Every split PR must include a short proof section in its PR description before it can be treated as ready. This section should use direct evidence, not confidence language. Avoid absolute claims such as "this guarantees terminal reliability" or "this cannot regress performance." The valid claim is narrower: "this PR is safe to merge because the affected invariant, performance budget, and residual gaps were checked with the evidence below." - -Required PR description sections: - -- **Reliability invariant:** the class-level rule this PR protects and the recent issue, PR, or accepted gap that motivated it. -- **Material impact:** why this will meaningfully reduce escaped regressions, including which known bug class becomes harder to reintroduce. -- **Product change type:** docs/tooling, test-only gate, product hardening, diagnostics, perf hardening, or mixed. -- **Performance risk inventory:** whether the PR touches typing, focus, tab/workspace switch, visibility resume, resize, render, startup, provider listing, hidden output, PTY output, store subscribers, git/worktree scans, SSH, WSL, Windows, or mobile/relay paths. -- **No-regression evidence:** the exact count test, deterministic contract test, metric artifact, typecheck, or live run that proves the PR did not add unbounded scans, polling, hidden-pane wake loops, startup awaits, subprocess churn, renderer jank, or output buffering growth. -- **Correctness evidence:** the exact oracle and command proving the invariant. Tests that only prove implementation shape are not enough. -- **Provider/platform coverage:** local, daemon, SSH, WSL, remote-runtime, mobile/relay, macOS, Linux, and Windows must be marked covered, unaffected, or accepted-gap. -- **Residual gaps:** what this PR still does not prove. These gaps must remain in the manifest until a later PR removes them. -- **Promotion status:** whether the gate is `none`, `partial`, or `active`, and why it is not promoted beyond its current maturity. -- **Rollback or demotion rule:** what signal should cause the PR's gate to be demoted, disabled, or followed up. - -Performance rule: - -- A reliability PR is incomplete if it adds polling, global session listing, provider fanout, hidden-pane renderer wakeups, startup-critical awaits, repeated resize IPC, subprocess churn, unbounded output queues, broad store subscriptions, or uncapped diagnostics without a deterministic count test or metric budget. -- `pty:listSessions()` remains a global management/diagnostic operation, not a hot-path liveness primitive. Typing, focus, tab/workspace switch, visibility resume, resize, render, and per-pane liveness paths should use targeted APIs or cached ownership unless a PR provides measured proof that the broader call is safe. -- Any PR touching git/worktree scans, SSH git providers, WSL paths, cleanup, or startup repository enumeration must include the `git-crash-perf` style proof: bounded subprocesses, timeout/abort behavior, provider parity, and no startup-critical scan. -- Any PR touching terminal/session/provider/startup code must use the terminal-session reliability contract plus a performance budget. A green reliability test without a perf proof is not enough. -- Any PR that changes PTY output, IPC batching, ACK/backpressure, replay, or hidden-output behavior (split item 5 especially) must include a before/after run of an existing terminal perf artifact — `test:e2e:terminal-perf:scale:report` or the typing-latency specs — on the same machine, with both results in the PR description. Deterministic byte/count contracts prove bounds; only the perf artifact proves interactive latency did not regress. -- The gates themselves are a performance surface. Every command-backed gate must declare a runtime budget in the manifest, unit-layer gate commands should stay in single-digit seconds, and the aggregated CI job in Evidence Provenance And CI Wiring must stay inside its own wall-clock budget so reliability coverage does not degrade the development loop it protects. - -Per-PR proof expectations: - -1. Reliability gate manifest and plan docs. - Performance proof: no runtime product path changes. Checker runtime should stay cheap and deterministic, and manifest validation should not depend on network calls or live providers. - Materiality proof: the checker prevents overclaiming by requiring evidence metadata, existing test files, command-backed assertions, covered scope, and clear `none`/`partial`/`active` status. - -2. Provider ownership and session replay safety. - Performance proof: activation and replay checks must be bounded by indexed workspace-local state, not repeated provider listing or O(records x pending-startups) scans. Tests should include repeat activation and bounded-work assertions where scans are introduced. - Materiality proof: this blocks the class where an inactive or hidden but valid provider session is mistaken for unowned and resumed again. - -3. Startup hydration, provider fanout, and targeted liveness. - Performance proof: startup hydration must not block first usable UI, must expose bounded counters, and must not multiply provider/git scans across repos and worktrees without a budget. Hot interaction paths must prove zero broad `pty:listSessions()` calls. - Materiality proof: this prevents stale global snapshots or failed provider observations from closing or replacing the wrong terminal, while reducing session-listing fanout. - -4. Terminal lifecycle observability. - Performance proof: breadcrumbs and traces must be capped, primitive-only, deduped or ring-buffered, and must not serialize terminal output, filesystem trees, scrollback, or large state. Tests must prove caps and sanitization. - Materiality proof: this does not itself prevent regressions, but it materially shortens future diagnosis by preserving the lifecycle facts needed to debug ownership, liveness, restore, resize, and replay failures. - -5. PTY output, IPC, and performance hardening. - Performance proof: this PR must carry the strongest perf evidence in the stack: byte caps, queue caps, ACK/backpressure ordering, active-output priority, hidden-output behavior, and no unbounded renderer/main buffering. It should not become blocking until soak data shows stable runtime and no unexplained flakes. - Materiality proof: this protects the freeze/input-lag/memory-growth class where background or high-volume terminal output harms the active terminal or the app. - -6. Live local PTY and E2E reliability slice. - Performance proof: the Playwright gate must be small, serial where needed, artifact-producing, and timed. It should prove the user path without becoming a broad slow/flaky suite. Runtime and flake history are required before promotion. - Materiality proof: this covers the layer that fake providers cannot prove: real Electron terminal spawn, keyboard input, process-visible output, hide/restore, resize, and cleanup. - -## Factory And Skill Follow-Up - -The current factory skills are close enough that the next step should be targeted edits, not a large new skill stack. The terminal-specific skill already exists, and Brennan's PR process already requires design review, perf review, code review, validation, pushed-state checks, and PR stabilization. The missing factory behavior is that reviewers and validators should explicitly fail PRs that omit the PR Proof Contract above. - -Recommended skill/factory updates: - -1. Update `terminal-session-reliability`. - Add the PR Proof Contract as a required output for terminal/session/provider/startup PRs. The skill should require reliability invariant, material impact, performance risk inventory, no-regression evidence, provider/platform coverage, residual gaps, promotion status, and rollback/demotion rule. - -2. Update `review-code`. - Make missing proof-contract sections a review finding for P0-capable terminal/session/provider/startup/release surfaces. Reviewers should not accept "tests pass" as enough when the PR lacks a named invariant, perf budget, provider/platform matrix, or accepted-gap statement. - -3. Update `brennan-test-changes`. - Require validation reports to say whether the relevant reliability gate command ran, whether the evidence proves the user-visible invariant, whether the test layer is sufficient, and whether any skipped SSH, WSL, Windows, remote-runtime, mobile, or live-Electron path is an accepted gap. - -4. Update `playwright-reliability-tests`. - Add the live-terminal reliability bar directly: Playwright terminal tests must prove real input/output or visible degraded state, use deterministic event oracles instead of blind sleeps, record runtime/artifacts, and stay `experimental` until flake history supports promotion. - -5. Update `perf`. - Add the PR Proof Contract's no-regression evidence language so perf reviewers require deterministic count tests or metric artifacts for polling, global session listing, provider fanout, hidden-pane wakeups, startup awaits, subprocess churn, output queue growth, and broad store subscriptions. - -6. After the repo-side gate commands stabilize, update factory prompts to invoke exact gate ids and commands. - Do this after the PRs land or after their commands are stable enough to reference. Before then, the factory should require the gate class and evidence, but should not hard-code commands that may still split or rename during PR extraction. - -Do not add a separate broad "terminal reliability reviewer" skill unless the existing skills fail to enforce this contract after these edits. Too many overlapping skills can make agents diffuse responsibility; the better shape is one terminal reliability contract plus required calls from design, review, perf, Playwright, and validation stages. - -## Coverage Reality Check - -This first stack is useful, but it must not be described as "terminal reliability is covered." The executable protection in this branch covers a deliberately small first slice of recent escaped classes: - -- stale local/daemon liveness snapshots closing newer PTY bindings; -- provider-session replay ownership for several active, inactive, queued, pending, live, retained, same-session hook, and wrong-session hook records, with real hook timing through Electron still explicitly partial; -- targeted `hasPty` liveness after visibility resume instead of hot-path global `listSessions`; -- degraded daemon fallback fail-closed behavior for restored worktree-scoped and legacy/non-scoped ids; -- SSH provider listing/`hasPty` failures treated as unknown rather than destructive ownership evidence, plus mocked provider/renderer coverage for deferred SSH attach, passphrase cancellation, transient deferred reattach failure, and expired deferred relay fallback; -- remote replay coalescing, FIFO ordering, and bounded burst-tail coalescing at the renderer unit layer; -- visible geometry contract behavior for delayed split layout settle, 0x0 split-right recovery, applied-vs-requested `pty:getSize`, and resume-time size reassertion; -- boot-time local PTY registry hydration counters for repo/worktree enumeration, skipped SSH repos, adapter/session listing, registration/skips, duration, and failure phase; -- terminal lifecycle anomaly breadcrumbs recorded into renderer crash diagnostics with dedupe and compact identity fields; -- live local Electron PTY spawn, active PTY listed exactly once, focused xterm keyboard input, process-visible output, repeated workspace hide/restore of the same PTY, actual resize propagation, and exit cleanup. - -Those gates should catch re-regressions in those classes, and the tests are intentionally deterministic. They mostly use renderer-unit, renderer-state, provider-contract, or fixture layers because those layers provide precise oracles with low flake risk. That is a strength for the classes they cover. - -The manifest also contains commandless registered gaps for persisted-session upgrade fixtures, Windows ConPTY, startup color-query handling, and other future gates. Those entries are useful planning structure, not executable protection. Visible geometry convergence, xterm addon containment, IME/native text forwarding, runtime/mobile streams, and startup hydration counters now have first executable contract slices, but they remain `experimental` until they have repeatable CI/runtime history and red-green evidence. Output backpressure now has daemon stream and main pending-output contract slices, but the broader live perf budget remains experimental until it has repeatable metric artifacts, runtime history, and red-green evidence. The live local PTY gate now has a first executable Playwright slice that includes repeated workspace hide/restore of the same PTY, but it remains `experimental` until it has repeatable CI/runtime history and red-green evidence. - -The critique that this stack is still shallow is fair. The current gates do not yet prove: - -- tab-switch restore, scrollback after restore, app restart persistence, and non-local live providers; -- Windows ConPTY behavior, including shell resolution, cursor restore, rewrite repaint, CJK/IME edge cases, kitty/input protocol behavior, and activation hangs; -- terminal input latency, throughput, hidden-output pressure, renderer CPU, and store/subscriber hot paths under load; -- IME and synthetic input forwarding across macOS, Windows, Linux/fcitx/Sogou, Vietnamese, CJK, Arabic, paste, and modifier paths; -- focus recovery after browser/terminal handoffs, tab switches, app restart, and non-local provider restore; -- daemon degraded spawns, stale bootstrap, remote-runtime startup, SSH/WSL provider boundaries, and mirror polling; -- scrollback clearing/restoration correctness across hidden output, snapshot replay, and restore. - -Those missing classes already appear in the full pain-points report, but this implementation plan should keep them visible so the first stack does not become false confidence. - -## Fresh-Eyes Corrections - -The follow-up reviews changed the plan in several concrete ways: - -- Windows/IME/CJK are not covered by the current executable stack. Orca has useful unit coverage and product logic for local Windows ConPTY compatibility, including withholding Kitty keyboard protocol for local native Windows panes, but no live Windows ConPTY gate proves post-agent ordinary keys, shell parity, CJK repaint, cursor restore, resize readback, or real IME composition. -- SSH restore is better covered than the first manifest draft said, but only at store/provider/main/renderer contract layers. The current gate asserts deferred wake metadata, deferred attach/passphrase/expired-relay contracts, and unknown-liveness behavior with fake transports; it does not prove a live SSH relay, WSL, remote-runtime mirror polling, false-live visual state, or real network reconnect timing. -- Perf/backpressure is currently deterministic byte-accounting proof, not live responsiveness proof. The branch asserts daemon `write(false)`/`drain` behavior, cross-session drain priority for flush-immediate output, pure-backpressure cleanup, and main pending-output caps; it does not yet prove renderer parse pressure, event-loop delay, active key latency, or live hidden-output flood behavior. -- Daemon startup reconcile remains a mechanism, not a production-proven startup behavior. Either wire it with dry-run diagnostics and prior-worktree aliases, or keep it as an accepted gap with a named owner. -- Gate evidence is still too easy to hand-enter. The checker now prevents obvious metadata overclaims, but it does not prove the registered command was run in CI, preserve artifacts, or verify that each declared assertion executed. Add machine-checkable artifact/CI-run provenance before promoting gates to blocking. -- Manifest evidence in this worktree is not yet durable. Three referenced test files are untracked (`resource-manager-session-polling.test.ts`, `terminal-lifecycle-diagnostics.test.ts`, `tests/e2e/terminal-live-pty-liveness.spec.ts`) and roughly 39 more files carry uncommitted modifications. Until each slice is committed and pushed to its owning split branch, the manifest references proof that CI cannot run and that no one can reproduce from a ref. -- Fresh `origin/main` renamed `terminal-webgl-paste-recovery.{ts,test.ts}` to `terminal-webgl-atlas-recovery.{ts,test.ts}` and added `pane-webgl-context-recovery.test.ts` and `pane-webgl-renderer.test.ts` in #6949. The `xterm-addon.boundary-containment` gate and its documented command reference the old filename and will fail the checker after rebase; the gate should adopt the renamed and new tests during the rebase. #7133 (merged 2026-07-02) further modified `pane-webgl-renderer.{ts,test.ts}` and added `pane-reveal-repaint.{ts,test.ts}` and `terminal-visibility-resume.{ts,test.ts}`; the containment gate should evaluate those reveal-hardening tests for adoption in the same sweep. This is a confirmed instance of the stale-branch risk, and it shows the fresh-main review must include a mechanical testFiles-vs-main sweep, not only behavioral spot checks. -- This document itself drifted from the manifest it describes (a prior revision said 23 gates, 13 partial/10 none, while the manifest held 24 gates, 15 partial/9 none). Treat the manifest as authoritative and this document's counts as dated snapshots. -- The Resource Manager status segment no longer polls broad `pty:listSessions()` for its closed-popover badge in this branch. Broad session inventory is now scoped to the open Resource Manager and explicit kill/restart refresh paths; the no-hot-listing plan still needs status/diagnostic budgets and broader Electron counters before promotion. -- Boot-time local PTY registry hydration now exposes a debug snapshot and deterministic tests for retry state, SSH skip-before-worktree-enumeration, local registration counters, adapter/session listing counts, recoverable adapter-list failures, fatal hydration failures, router adapter fanout, duration, and large daemon lists. This is still only a startup-hydration counter slice; it does not prove focus, typing, tab switch, workspace switch, render, high-session provider fanout, or real Electron startup budgets. -- Terminal lifecycle anomaly diagnostics now record compact crash breadcrumbs in addition to console warnings. This is useful support evidence, not yet the full pane lifecycle trace buffer requested in the pain-points report. - -## Fresh Main Architecture Findings - -These findings came from focused reviews of current terminal lifecycle, renderer/input/perf, and provider/platform/startup code against freshly fetched `origin/main`. They are not all regressions introduced by the reliability-gate branch. They are the places the plan must cover before claiming broad terminal reliability. - -Current coverage already exists in more places than the first gate stack showed: - -- `terminal-dead-session-reconcile.test.ts`, `resume-sleeping-agent-session.test.ts`, and `pty-connection.test.ts` have substantial deterministic lifecycle coverage. A focused run of those files passed 292 tests. -- Existing E2E/perf tests include terminal typing latency, hidden TUI visual restore, long-table scroll restore, daemon slow-init PTY restore, SSH localhost, and terminal tab-switch SIGWINCH restore. -- Existing scripts include terminal perf report commands such as `test:e2e:terminal-perf`, `test:e2e:terminal-perf:scale`, `test:e2e:terminal-perf:scale:report`, and `test:e2e:terminal-perf:check-report`. - -The problem is that these tests are not yet organized into reliability gates with named invariants, owners, maturity, runtime budgets, flake history, red/green evidence, and promotion rules. Some also need review before promotion because a useful regression test can still be too broad, too slow, or too flaky to be a blocking gate. - -Local validation evidence from this review pass: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ - src/renderer/src/lib/resume-sleeping-agent-session.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts -``` - -Result on 2026-07-01: 3 test files passed, 292 tests passed, duration 6.04s. This asserts the current deterministic lifecycle/unit layer is healthy in this worktree; it does not prove the broader live/platform/provider/perf gaps. - -The provider-session ownership gate now includes the queued/pending resume bridge from fresh `origin/main`: automatic resumes thread `resumeProviderSession` through `pendingStartupByTabId`, claim `automaticAgentResumeClaimsByTabId`, and treat pending startup, time-bounded runtime claims, and live agent statuses as provider-session ownership. The queued/pending claim scan is indexed once per activation so replay protection does not rescan every pending startup for every sleeping record. It also asserts live hook evidence claims only the matching provider session; a wrong-session hook cannot claim a replayed sleeping record; and stale automatic bridge claims cannot suppress recovery forever. - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/lib/resume-sleeping-agent-session.test.ts -``` - -Result on 2026-07-01: 1 test file passed, 34 tests passed, duration 7.86s. This focused command asserts repeat activation or replayed sleeping records do not launch a second resume for the same provider session while the first resume is still pending, queued pending-startup provider-session ids are read once per activation rather than once per replayed record, a live hook for the same provider session owns the replay, and a wrong-session hook does not. It does not yet prove real hook timing through Electron or a full Electron workspace activation loop. - -Structural manifest validation also passed locally: - -```sh -pnpm run check:reliability-gates -``` - -Result on 2026-07-01 after the first expansion: reliability gate manifest check passed for 22 gates. This validated the manifest schema/metadata shape only; it did not prove that the registered commands covered the full terminal runtime. - -After the broader architecture review and fresh-agent review, the manifest was expanded further, including a dedicated WSL restore contract instead of burying WSL under SSH/remote coverage, and later the three convergence/escape-detection gaps registered after the #7133/#7148 saga. As of 2026-07-03, main's manifest holds 27 gates split 9 `partial` (gates whose evidence exists on main itself, including the #7133/#7148/#7173/#7192 regression tests) and 18 `none`; the pending stack raises more gates to `partial` as its slices land. These counts are dated snapshots, and the manifest itself is authoritative. The new entries are intentionally `experimental`; a few now have first executable slices, while others are still commandless gap markers. They are not active protection until each gate gets an implemented command, red/green proof, runtime evidence, and promotion history. - -The manifest checker now enforces the review lessons that were easy to miss manually: - -- declared `testFiles` must exist; -- every declared executable `testFiles` entry must be referenced by at least one gate command, so a manifest cannot borrow credibility from a file the registered command does not run; -- command-backed gates cannot use title selectors such as `-t`, `--grep`, or `--testNamePattern`, because title drift silently changes coverage; -- every gate must declare `protection: none | partial | active`; -- commandless gates must declare `protection: "none"`, while command-backed gates must declare at least `protection: "partial"`; -- `protection: "active"` is reserved for blocking gates with stable flake history and complete red/green evidence; -- `partial` and `active` gates must include at least one passed structured `evidenceRuns` entry whose command exactly matches the gate command; -- `none` gates must have no evidence runs, so planning gaps cannot look like tested coverage; -- `partial` and `active` gates must include `assertionRefs` that point to declared test files and name the invariant assertions inside those files; -- `none` gates must have no assertion refs, so planning gaps cannot borrow credibility from unrelated tests; -- every gate must split risk scope from covered scope with `coveredPlatforms`, `coveredProviders`, and `coverageNotes`; -- `coveredPlatforms` and `coveredProviders` must be inside the declared risk scope, and passed evidence-run platforms must appear in `coveredPlatforms`; -- `soak` and `blocking` gates must have soaking/stable flake evidence and complete red/green evidence. - -How to read the manifest: - -- `platforms` and `providers` describe the affected risk scope for an invariant. They do not mean the current command exercises every listed platform/provider. -- `coveredPlatforms`, `coveredProviders`, and `coverageNotes` describe the scope actually covered by current evidence. Empty `coveredProviders` on a partial gate usually means renderer/state/RPC contract coverage, not a real provider run. -- `protection: "none"` means a registered gap only; `protection: "partial"` means useful executable coverage that is not blocking protection yet; `protection: "active"` means the gate is blocking and has stable evidence. -- `evidenceRuns` are factual run records: date, runner, platform, exact command, result, duration, and summary. A local passed run supports `partial` coverage; it does not imply CI stability or flake history. -- `assertionRefs` explain which assertion groups in the listed test files carry the gate. This is especially important for shared files like `pty-connection.test.ts`, where the whole file passing is not the same as the whole file being relevant. -- `oracle`, `redGreenEvidence`, and `knownGaps` are the authoritative source for what is actually proved today. -- Commandless `experimental` gates are registered gaps, not active protection. - -New or expanded manifest entries: - -- `terminal-platform.live-pty-liveness` -- `terminal-platform.windows-conpty-liveness` -- `terminal-performance.no-hot-list-sessions` -- `terminal-performance.output-backpressure-budget` -- `terminal-performance.input-throughput` -- `terminal-performance.daemon-stream-backpressure` -- `terminal-performance.store-and-git-hot-paths` -- `terminal-provider.daemon-startup-degraded-contract` -- `terminal-provider.ssh-remote-reattach-contract` -- `xterm-addon.boundary-containment` -- `terminal-output.scrollback-replay-fifo` -- `terminal-output.scrollback-restore` -- `terminal-input.ime-and-synthetic-forwarding` -- `terminal-input.windows-conpty-keyboard-reset` -- `terminal-render.windows-cjk-repaint` -- `terminal-shell.windows-resolution-parity` -- `terminal-capability.startup-color-query` -- `terminal-runtime.mobile-stream-budget` -- `terminal-mirror.parser-parity` -- `terminal-observability.restore-convergence-selfcheck` -- `terminal-render.pixel-refresh-repair` - -The visible geometry gate now has its first deterministic renderer/main contract slice: - -```sh -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/renderer/src/components/terminal-pane/pty-connection.test.ts \ - src/main/ipc/pty.test.ts -``` - -Result on 2026-07-02: 4 test files passed, 481 tests passed, duration 5.98s. This focused command asserts delayed hidden split-layout settles are forwarded instead of stopping on a fixed frame budget, unmeasurable frames do not count as settled, visible 0x0 split-right panes get a nonzero recovery size while hidden background 0x0 panes are not forced to desktop size, `pty:getSize` reports applied size when a provider can expose it, and visibility resume reasserts only real PTY/xterm drift without falling back to `listSessions`. It does not yet prove shell-visible `stty`, SSH/remote geometry, Windows ConPTY geometry/readback, or full Electron geometry under real split/tab/app-restart workflows. - -The live local PTY gate now has its first executable Electron slice: - -```sh -pnpm run ensure:electron-runtime && npx playwright test \ - tests/e2e/terminal-live-pty-liveness.spec.ts \ - --config tests/playwright.config.ts --project electron-headless --workers=1 -``` - -Result on 2026-07-02 after adding two workspace hide/restore cycles, exact active-PTY listing, and actual resize convergence: 1 Playwright test passed, test body 8.6s, full command 58.6s including build/setup. This one local macOS run asserts a real Electron local terminal binds a PTY, lists the active PTY id exactly once, accepts keyboard input through focused xterm, renders process output markers, preserves the same PTY id through repeated worktree switch-away/switch-back cycles, accepts additional keyboard input after each restore, applies an actual `pty:getSize` change after viewport resize, reports the same process-visible size, exits the probe, exits the shell, and removes the PTY id from `pty:listSessions`. It does not yet prove tab switch within the same worktree, scrollback after restore, app restart persistence, daemon, SSH, WSL, remote-runtime, Linux CI stability, or Windows ConPTY behavior. - -The IME/native-text gate now has its first deterministic renderer-unit slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts \ - src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts \ - src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts -``` - -Result on 2026-07-02: 3 test files passed, 63 tests passed, duration 887ms. This registers existing native-text, input-source, and paste/runtime forwarding contracts as a reliability gate slice. It does not prove real OS IME automation, Windows ConPTY post-agent keyboard reset, Arabic/RTL, JIS-yen, or the full CJK/Vietnamese/platform matrix. - -The xterm addon containment gate now has its first deterministic renderer-unit slice and product hardening for core addon load failures: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts \ - src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts \ - src/renderer/src/components/terminal-search-safe-find.test.ts \ - src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts \ - src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts -``` - -Result on 2026-07-02: 5 test files passed, 39 tests passed, duration 370ms. This focused command asserts a core addon `loadAddon` throw is pane-scoped and later addons still load, link provider throws degrade to no-link results, search decoration positive-integer crashes return `false` instead of escaping, and WebGL attach/refresh/recovery failures stay contained. It does not yet prove typed PTY input/output survives a live addon failure in Electron, nor WebGL dispose/reset throws across active, hidden, and resumed panes. - -The runtime/mobile stream budget gate now has its first deterministic runtime-RPC slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/runtime/rpc/terminal-subscribe-buffer.test.ts \ - src/main/runtime/rpc/terminal-output-batching.test.ts \ - src/main/runtime/rpc/terminal-multiplex.test.ts -``` - -Result on 2026-07-02: 3 test files passed, 29 tests passed, duration 2.78s. The covered contracts assert mobile initial snapshots downgrade until they fit the 512KB budget, requested binary snapshots downgrade until they fit the 2MB budget, binary live output queued while the initial snapshot is serializing stays within 256KB while preserving the newest tail, large binary output is split into 48KB-or-smaller frames, output bursts are coalesced before emission, aborted subscribes do not register stale listeners, and stale mobile resize re-stream completions are dropped. It does not yet decide JSON subscribe fallback parity/deprecation. - -The replay/scrollback gate now has its first executable slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts -``` - -Result on 2026-07-02: 1 test file passed as part of the focused pty-connection validation, with 255 tests in that file. The relevant replay assertions cover four replay invariants: remote replay payloads that overlap before parsing starts intentionally coalesce to the latest payload, a replay whose xterm parsing has already started is not overwritten by a newer replay, multiple replay notifications accepted after parsing starts drain FIFO, and burst tails are bounded while keeping the newest snapshot. It does not yet prove fresh-main `clearBeforeReplay` metadata, metadata-only replay, or hidden-output/live-output interleaving. - -The no-hot-`listSessions` gate now has its first executable slice and product hardening. The renderer/preload/main path exposes `pty.hasPty(id)` and uses targeted liveness on visibility resume and first input after resume, instead of calling global `pty:listSessions()` from that hot path. The resize-on-resume slice also asserts it uses targeted `getSize` plus `resize` rather than broad session listing. Light tab switches and visible active-state resume now assert they do not call `pty:listSessions`, `pty.hasPty`, or `pty.getSize`; a representative active-PTY case proves the scheduler hint `setActiveRendererPty` is the only allowed PTY API call there. Resource Manager broad session inventory is now scoped to the open popover instead of polling for its closed badge. - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ - src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts \ - src/renderer/src/components/status-bar/resource-manager-session-polling.test.ts \ - src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts -``` - -Result on 2026-07-02: 5 test files passed, 321 tests passed, duration 6.34s. The focused no-hot command asserts visibility resume prefers targeted `hasPty` even when `listSessions` is available, first input after visibility resume calls targeted `hasPty` once, resize re-assertion after visibility resume uses `getSize`/`resize` without `listSessions`, light tab switches and visible active-state resume avoid `listSessions`/`hasPty`/`getSize` fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover, panes close only on authoritative `false`, and unknown liveness keeps panes alive. It does not yet count raw focus, workspace switch, render, high-session typing, or real provider fanout in Electron. - -The store/git hot-path gate now has its first boot-hydration counter slice. This is not broad interactive perf coverage; it is the narrow startup-adjacent part that previously had no measurable budget. - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/memory/hydrate-local-pty-registry.test.ts -``` - -Result on 2026-07-02: 1 test file passed, 8 tests passed, duration 0.342s. The focused command asserts provider-unavailable hydration does not scan repos/worktrees/providers and remains retryable, SSH repos are skipped before worktree enumeration, local daemon sessions are registered with debug counters for repo/worktree/adapter/session/register/skip/duration fields, recoverable adapter-list failures are counted without failing the whole hydration pass, fatal hydration failures record failed phase and error message, router-backed hydration records multi-adapter fanout counts, pre-existing pid rows are not clobbered, and large daemon session lists hydrate without spreading into argument-limit failures. It does not yet count store recomputes, git status requests, provider listings, focus, typing, tab switch, workspace switch, render, or high-session Electron fanout. - -The degraded-daemon provider contract now has its first executable slice and product hardening. Existing-session operations fail closed when ownership is unknown, and restored daemon session ids, including legacy/non-worktree-scoped ids, cannot silently spawn a local fallback PTY under the old id. Fresh degraded-mode PTY creation still intentionally routes through fallback when the caller marks it as new, and process inspection returns benign defaults for unknown ownership instead of leaking fail-closed routing errors into window-close flows. - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/daemon/degraded-daemon-pty-provider.test.ts \ - src/main/daemon/daemon-pty-adapter.test.ts \ - src/main/daemon/daemon-pty-router.test.ts -``` - -Result on 2026-07-02: 3 test files passed, 107 tests passed, duration 2.54s. Focused provider validation asserts discovered daemon sessions route to the daemon, fresh degraded-mode PTYs route to fallback only when marked new, router spawn discovers uncached existing sessions before choosing an adapter, router spawn fails closed instead of falling through to current when legacy ownership cannot be listed or a known session has exited, targeted `hasPty` discovery caches legacy ownership before later operations, real daemon adapter `listProcesses()` discovery seeds targeted `hasPty` liveness, folder and floating terminal workspace ids survive startup reconcile when valid, restored worktree-scoped and legacy/non-scoped ids do not fall back through spawn after ownership is lost or unknown, unknown or exited existing ids fail closed for routing operations, startup reconcile can dry-run orphan detection without killing live daemon sessions, and process inspection returns benign defaults for unknown ownership. It does not yet prove production startup reconcile wiring, prior-worktree aliases, or real daemon process restart behavior. - -The SSH/remote provider contract now has its first executable provider/main/renderer slice. Provider observation failures are treated as unknown instead of destructive evidence: a failed SSH `listProcesses()` call does not clear previously learned ownership, and a rejected SSH `hasPty()` probe returns unknown liveness (`null`) rather than dead. The focused renderer/provider tests also assert that saved SSH sessions reattach through relay `pty.attach`, expired relay attach does not silently fresh-spawn inside the provider, deferred passphrase cancellation does not auto-reconnect, saved leaf/tab session ids are used after connection, transient deferred reattach failure preserves the saved session id without clearing pane/tab bindings or fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without consuming the saved restore id, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/providers/ssh-pty-provider.test.ts \ - src/main/ipc/pty.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts \ - src/renderer/src/store/slices/store-session-cascades.test.ts -``` - -Result on 2026-07-02: 4 test files passed, 546 tests passed, duration 6.48s. Focused validation asserts store wake-hint metadata for disconnected SSH relay ids, provider attach/expired-attach behavior, main-process ownership and targeted-liveness failure semantics including async provider rejection, mocked-renderer deferred SSH attach/transient-failure/expired-relay fallback, and deferred SSH no-result cleanup that clears the pending serializer without consuming the saved restore id. It does not yet prove WSL, remote-runtime mirror polling, a live SSH relay, pending-vs-attached UI status, or real network reconnect timing. - -API-surface validation after adding `pty.hasPty(id)`: - -```sh -pnpm run typecheck:node -pnpm run typecheck:web -``` - -Result on 2026-07-02: both typechecks passed. - -The output-backpressure budget gate now has a deterministic provider/IPC/renderer slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/daemon/daemon-stream-data-batcher.test.ts \ - src/main/ipc/pty.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts -``` - -Result on 2026-07-02: 3 test files passed, 469 tests passed, duration 9.35s. The focused command asserts daemon socket `write(false)`/`drain` ordering, cross-session interactive output priority, pure-backpressure cleanup, bounded daemon queued bytes, main pending renderer caps, active pending-output protection, replay/backlog slices, and ACK-gated in-flight bounds. It does not yet prove live hidden-output floods, renderer parse pressure, event-loop delay, active key latency, or full Electron perf artifacts. - -Combined focused terminal/provider validation: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/daemon/degraded-daemon-pty-provider.test.ts \ - src/main/ipc/pty.test.ts \ - src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ - src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts \ - src/renderer/src/lib/resume-sleeping-agent-session.test.ts -``` - -Result on 2026-07-01: 8 test files passed, 625 tests passed, duration 6.55s. This was focused terminal/provider validation, not structural manifest validation. - -Reliability manifest checker validation is separate: - -```sh -pnpm run check:reliability-gates -``` - -Result on 2026-07-02: reliability gate manifest check passed for 27 gates. This validates the manifest shape and referenced files; it does not prove the registered commands cover the full terminal runtime. - -The highest-risk architecture boundaries are: - -- Terminal/session ownership is spread across tab PTY ids, leaf PTY ids, last-known relay ids, pending reconnect maps, deferred SSH maps, sleeping records, and provider ownership. The plan needs a derived ownership snapshot oracle for tests/logging so attach, reattach, exit, clear, hibernate, and restore can prove all authorities agree. -- Queued or pending provider sessions must count as owned. This branch now restores the pending startup and automatic resume claim bridge and covers same-session/wrong-session hook evidence, but real hook timing and repeat activation through Electron still need coverage. This is the class-level invariant motivated by #6800. -- Stale branch risk is real. The reliability branch previously lacked some protections present on fresh `origin/main`, including queued automatic agent resume claims and exact `hasPty` missing-session reconciliation. This branch has restored those two deterministic slices; the full stack still needs final rebase/retarget verification before landing. -- Main PTY ownership historically had convenient fallback behavior for unknown ownership. This branch now hardens daemon-router and degraded-provider paths so discovered legacy ownership is cached and unknown existing-session operations fail closed, but SSH/remote/diagnostic listing paths still need live coverage. -- Startup daemon reconciliation is present as a mechanism but not wired as production startup behavior. Without it, the daemon can still have a session while the adapter no longer knows it owns that session. -- Degraded daemon routing on fresh `origin/main` can fall back to local for unknown existing-looking sessions. This branch now hardens operation plus restored worktree-scoped and legacy/non-scoped spawn paths to fail closed and registers a focused provider contract, but startup reconcile, prior-worktree aliases, and real daemon restart behavior still need coverage. -- `pty:listSessions` both observes and mutates provider ownership. This branch now asserts failed SSH observation does not clear previously learned ownership and mocked deferred SSH attach/transient-failure/expired-relay fallback behaves correctly, but WSL, remote-runtime mirror polling, live SSH reconnect timing, and false-live tab behavior remain unproved. -- Resource Manager broad session inventory is now limited to popover-open and explicit management refreshes. It still needs a status/diagnostic budget proving open-popover polling is bounded with many providers and does not become a hot-path substitute. -- Boot hydration (`hydrateLocalPtyRegistryAtBoot`) is fire-and-forget, and this branch now records startup phase counters for repo/worktree enumeration, skipped remote repos, adapter/session listing, registrations/skips, duration, and failures. It can still create startup-adjacent git subprocess and daemon-listing churn across many local repos, so do not treat startup reliability as covered until those counters are budgeted in CI/runtime evidence. -- Deferred SSH restore intentionally marks a saved PTY id before actual attach. This branch now covers the mocked renderer contract for passphrase cancellation, attach after connection, transient attach failure preserving retry state, and expired relay fallback; remaining work is false-live visual state, live SSH relay timing, and duplicate-spawn prevention under real reconnect churn. -- Geometry truth is incomplete across SSH/remote paths. Requested size, renderer size, provider-applied size, shell-visible size, and the runtime mirror's parse dimensions must be separated instead of treated as interchangeable. #7192 proved the mirror can silently stay at birth dimensions while every other authority resizes, corrupting every later restore snapshot, and that mirror reflow must stay ordered with queued output writes. -- Production diagnostics are still incomplete. This branch now records compact crash breadcrumbs for explicit terminal lifecycle anomalies, but real user reports still need a bounded pane lifecycle trace for ordinary transitions and a diagnostics-bundle artifact proving those breadcrumbs/traces are included. - -The lifecycle breadcrumb gate now has its first executable slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/terminal-lifecycle-diagnostics.test.ts -``` - -Result on 2026-07-02: 1 test file passed, 2 tests passed, duration 0.593s. The focused command asserts `warnTerminalLifecycleAnomaly` preserves its console warning, records a compact `terminal_lifecycle_anomaly` renderer crash breadcrumb with terminal identity/provider/PTY/reason fields, and dedupes repeated anomalies by lifecycle identity. It does not yet prove a full pane lifecycle trace buffer, diagnostics-bundle export, or forbidden-transition assertions across live Electron/provider flows. - -Specific current-source evidence checked in this pass: - -- Fresh `origin/main` has queued provider-session protections in `src/renderer/src/lib/resume-sleeping-agent-session.ts`: `resumeProviderSession`, `claimAutomaticAgentResume`, and `automaticAgentResumeClaimsByTabId`. -- Fresh `origin/main` has exact liveness reconciliation in `src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts` and the `pty:hasPty` IPC handler in `src/main/ipc/pty.ts`. -- Fresh `origin/main` still uses a single `pendingReplayData` slot in `src/renderer/src/components/terminal-pane/pty-connection.ts` (re-verified after #7133 merged; #7133 changed the same file's `applyMainBufferSnapshot` alt-screen restore path, not replay draining). This branch changes replay draining to coalesce snapshots before xterm parsing starts, queue accepted snapshots FIFO once parsing is in progress, and coalesce burst tails to the newest snapshot once the in-flight queue is full. The broader scrollback gate still needs metadata-only replay, `clearBeforeReplay`, and hidden/live-output interleaving coverage. -- Fresh `origin/main` still has `DaemonPtyAdapter.reconcileOnStartup` with the explicit note that it has no production caller yet. This branch adds a dry-run mode so future startup wiring can audit would-kill sessions before destructive cleanup, but production startup-persistence remains a real gap until wired with prior-worktree aliases or tracked as an accepted gap with a gate. -- Fresh `origin/main` still lets `DegradedDaemonPtyProvider.providerFor()` fall back to local when an existing-looking session id is unmapped and not rediscovered by `hasPty`. This branch changes existing-session operation paths and restored worktree-scoped plus legacy/non-scoped spawn paths to fail closed, adds contract tests, and makes real daemon adapter `listProcesses()` discovery seed targeted `hasPty` liveness. Startup reconcile wiring and live daemon-restart validation are still required before the full provider lifecycle is covered. -- Fresh `origin/main` let the daemon router spawn an unknown existing `sessionId` on the current daemon when routing cache/discovery missed its real owner. This branch now probes/discovers all adapters first, requires `isNewSession` for intentional replacement after an authoritative exit event, and fails closed if legacy ownership cannot be listed or the known id has exited, so a stale existing id cannot silently mint over another daemon's history on current. -- Fresh `origin/main` let canonical daemon-worktree restores treat a non-minted stale/local PTY id as restorable metadata. This branch now rejects non-minted restored ids when the workspace owner is canonical (`repo::path`, `folder:`, or `global-floating-terminal`), clears the stale pane/tab binding, and fresh-spawns instead of calling daemon reattach or attaching that stale id. Shared parsing also recognizes folder and floating minted ids so startup reconcile does not falsely reap valid non-repo terminal sessions. -- Fresh `origin/main` still lets `pty:listSessions` rebuild provider ownership while SSH listing failures become empty lists. This branch adds store/main/provider/renderer contracts proving disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, failed SSH observation does not clear previously learned ownership, `hasPty` failure is unknown, saved SSH sessions attach through relay `pty.attach`, deferred passphrase cancellation does not auto-reconnect, transient deferred reattach failure preserves the saved session id, deferred SSH no-result cleanup clears pending serializer state without consuming retry metadata, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. Live SSH, WSL, remote-runtime mirror polling, false-live visual state, and real reconnect timing remain unproved. -- Fresh `origin/main` has strong Windows/unit coverage around ConPTY packaging, shell resolution, size validation, keyboard reset, complex-script classification, and Windows PTY compatibility, but the real Windows Electron/ConPTY path is still not covered as a reliable PR gate. -- Fresh `origin/main` still exposes global `pty:listSessions`; this branch now also uses targeted `hasPty` for visibility/input liveness, but gates should keep typing, focus, tab switch, visibility resume, resize, and render paths from reintroducing broad daemon/provider listing. -- Fresh `origin/main` has main-to-renderer ACK/backpressure, renderer output-scheduler caps, terminal perf scripts, and a daily/manual terminal perf workflow. This branch adds daemon stream batcher assertions that socket `write(false)` pauses further stream events until `drain`, flush-immediate output from another session drains before unrelated background backlog, global cleanup clears clients that only have backpressured writes, and queued daemon stream bytes stay bounded by preserving priority output plus the newest tail under sustained pressure; it also caps main pending renderer output per PTY and in total, trims background pending output before active pending output under total pressure, and asserts `seq`/`rawLength` metadata stays correct for the surviving tail. Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved. -- Fresh `origin/main` has binary runtime/mobile terminal stream caps, but the legacy JSON subscription path has weaker buffering/backpressure guarantees. Either gate both paths or explicitly deprecate/accept the JSON gap. - -Existing E2E/perf tests that should be reviewed for promotion: - -- `tests/e2e/daemon-slow-init-pty-gate.spec.ts`: strong daemon live-session restore signal; already has a tight user-visible invariant. -- `tests/e2e/daemon-live-session-preservation.spec.ts` and `tests/e2e/daemon-slow-health-check-preservation.spec.ts`: likely useful daemon liveness/preservation gates after oracle and flake review. -- `tests/e2e/terminal-typing-latency.spec.ts`, `tests/e2e/terminal-codex-local-typing-latency.spec.ts`, and `tests/e2e/terminal-history-size-typing-latency.spec.ts`: useful perf signals, but should be tied to stable budgets and artifacts before blocking promotion. -- `tests/e2e/terminal-output-scheduler.spec.ts`, `tests/e2e/artificial-opencode-terminal-load.spec.ts`, and hidden-pressure scenarios: useful for output/backpressure, but should become metric-gates rather than visual-only broad E2Es. -- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`, `tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts`, and `tests/e2e/terminal-tab-switch-visual-restore.spec.ts`: useful rendering recovery coverage; must keep deterministic text/buffer oracles and limit screenshots to diagnostics. -- `tests/e2e/terminal-long-table-scroll-restore.spec.ts` and `tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts`: useful scrollback/rendering regression evidence, but they contain timed waits and should be reviewed before blocking promotion. -- `tests/e2e/ssh-localhost.spec.ts`, `tests/e2e/ssh-docker-relay-perf.spec.ts`, and SSH Codex replay/reconnect specs: useful SSH coverage, but should be separated into deterministic provider contracts plus a small number of environment-dependent live SSH soaks. -- `tests/e2e/terminal-windows-shell-paste-ownership.spec.ts` and Windows env/icon tests: useful Windows path coverage, but not a substitute for a Windows ConPTY live liveness gate. - -Promotion split for existing E2Es: - -- Promote after soak: a smaller derivative of `terminal-typing-latency.spec.ts` as the live local PTY input/output gate. -- Promote after soak: daemon preservation specs, especially slow-init and stale launch identity, as daemon warm-reattach gates. -- Promote targeted: paste ownership tests where the oracle is visible output plus exactly-one PTY write. -- Keep in soak/nightly: artificial OpenCode load, terminal scale perf report, hidden real-PTY pressure, and SSH Docker perf. -- Keep as release evidence: raw emoji/golden rendering, long-table rendering, WebGL recovery, and Codex/OpenCode artifact repros. -- Rewrite before blocking: column desync specs need resize-applied acknowledgement or an in-PTY `SIGWINCH` marker instead of post-resize sleeps. -- Rewrite before blocking: SSH Codex artifact repros and headful dead-terminal repros need deterministic fixtures/events instead of long waits and screenshots as primary proof. - -The first live PTY gate is deliberately small: - -- File: `tests/e2e/terminal-live-pty-liveness.spec.ts`. -- Command: `pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-live-pty-liveness.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1`. -- Invariant: a newly opened local terminal has one active PTY id listed exactly once, accepts real keyboard input through focused xterm, delivers process output visibly, keeps the same PTY id through repeated workspace hide/restore cycles, accepts input after restore, applies an actual size change, and exits without leaving a stale binding. -- Oracle: start a deterministic Node raw-mode probe, wait for `LIVE_PTY_READY_`, require the active PTY id to appear exactly once in `pty:listSessions`, type through `.xterm-helper-textarea`, require ordered per-key markers in serialized terminal content, switch to another worktree and back twice, require the active pane to keep the original PTY id, type again after each restore, resize and require `pty:getSize` to change plus a matching process-visible size marker, then exit the probe and shell and require the old PTY id to disappear from `pty:listSessions`. -- Wait rule: no blind sleeps. Wait only on PTY binding, active worktree changes, ready marker, key markers, resize marker, and list-session absence. -- Budget: target p95 under 75s on Linux including e2e build/setup, hard test timeout 90s. Start blocking on Linux only after soak; run macOS/Windows nightly until each platform has its own history. -- Failure artifacts: PTY id, provider classification, pid if available, size snapshots, key latencies, final `listSessions`, trace, and screenshot. -- Red/green proof: intentionally break xterm focus, PTY write forwarding, resize forwarding, and exit cleanup in separate local runs and confirm the gate fails for each class. -- Local run on 2026-07-02: 1 Playwright test passed in 58.6s total, with an 8.6s test body, on this macOS worktree. This is one green local run for the local live PTY path, not enough flake/runtime history for blocking promotion. - -## Gate Coverage Matrix - -This is the intended shape after rebasing the stack. The first two columns are what we have now; the last two are what must be added or promoted before the plan earns broader reliability claims. - -| Reliability class | Existing evidence | Gap | Next gate or action | -| --- | --- | --- | --- | -| Stale liveness snapshots closing newer PTYs | `terminal-session.snapshot-freshness`; deterministic unit coverage | No full Electron tab-survival/input echo proof | Add `terminal-platform.live-pty-liveness` and keep snapshot gate as lower-layer guard | -| Provider-session replay ownership | `agent-session.provider-ownership`; renderer state tests; queued/pending resume claim proof; bounded queued-claim index proof; same-session and wrong-session hook proof | Needs real Electron repeated activation/hook timing and bounded hook/status scan assertions if those grow | Add Electron activation proof | -| Exact PTY liveness | Fresh main has targeted `hasPty` path | Current branch must not lose it; provider failures must be unknown, not dead | Rebase/restore exact liveness reconciliation and register it as a gate | -| Store/session ownership coherence | Many focused tests touch pieces | No single derived oracle across tab, leaf, relay, reconnect, deferred SSH, and sleeping maps | Add ownership snapshot helper for tests/logging | -| Terminal lifecycle diagnostics | `terminal-observability.lifecycle-breadcrumbs` now records compact anomaly breadcrumbs into crash diagnostics | No full pane lifecycle trace buffer or diagnostics-bundle artifact proof | Add bounded trace buffer and bundle artifact test | -| Xterm addon/search/WebGL containment | `xterm-addon.boundary-containment`; deterministic core addon load, link-provider, search, and WebGL containment tests; #7054 hidden TUI product hardening | Needs live Electron typed input/output survival after addon failure, and WebGL dispose/reset throw coverage across active/hidden/resumed panes | Add focused component/live input echo follow-up; promote hidden TUI E2E after flake review | -| Visible terminal geometry | #7006 renderer/provider-style gate; #7192's merged mirror-resize and reflow-ordering red tests on main | SSH/remote and shell-visible truth not fully covered; mirror-geometry slice unregistered until rebase | Add live PTY size readback and provider-applied-size contract; register #7192's mirror-authority tests | -| Persisted session upgrade | #7007 fixture gate | Needs broader old-version corpus and daemon/SSH cases | Build immutable fixture corpus with startup timing | -| Live local PTY lifecycle | `terminal-platform.live-pty-liveness` now has a focused Electron Playwright slice covering local spawn, single active PTY listing, xterm input, PTY output, repeated workspace hide/restore, actual resize propagation, and cleanup | Needs flake/runtime history, red-green evidence, tab switch, scrollback after restore, app restart persistence, and non-local provider coverage | Promote first slice after soak; add tab-switch/scrollback/restart follow-ups | -| Windows ConPTY lifecycle | Recent fixes on main, some unit/platform logic | No required Windows live gate for ConPTY echo/resize/cursor/CJK/IME | Add `terminal-platform.windows-conpty-liveness` | -| Windows keyboard reset | Unit coverage and recent #6999/#6858 fixes | No live proof that Enter/Backspace/Arrow return to normal after agent/TUI exit | Add `terminal-input.windows-conpty-keyboard-reset` | -| Windows CJK/repaint | Complex-script and compatibility tests plus recent CJK repaint fixes | No Windows visual/pixel oracle for wide-glyph in-place redraws | Add `terminal-render.windows-cjk-repaint` | -| Windows shell parity | Shell resolution unit coverage and packaging fixes | No local-vs-daemon parity gate for PowerShell 5/7, cmd, Git Bash, WSL, startup command delivery | Add `terminal-shell.windows-resolution-parity` | -| Input latency/perf | Existing terminal perf scripts and typing-latency spec | Not registered, not tied to budgets/owners/promotion | Add `terminal-performance.input-throughput` | -| Hot interaction listing | Recent #7002 fix, targeted `hasPty` path, deterministic visibility/input/resize no-listing assertions, light tab/active-state resume no-provider-fanout assertions, and closed Resource Manager broad-listing avoidance | No count gate proving `pty:listSessions` stays out of raw focus/workspace-switch/render/high-session Electron scenarios; open Resource Manager polling still needs an explicit budget | Extend `terminal-performance.no-hot-list-sessions` | -| Daemon stream backpressure | Batching/chunking plus new batcher contract proof for `write(false)`/`drain`, cross-session flush-immediate drain priority, pure-backpressure cleanup, and bounded queued tails | No live hidden-output flood proof or active input latency artifact | Extend `terminal-performance.output-backpressure-budget` | -| Renderer/main ACK and backlog | Main ACK gates, renderer scheduler caps, focused unit coverage, and bounded main pending tails exist | No live metric artifact tying hidden restore, active key latency, queued chars, and dropped backlogs to promotion | Extend `terminal-performance.output-backpressure-budget` | -| Runtime/mobile terminal stream | `terminal-runtime.mobile-stream-budget`; runtime-RPC tests assert mobile initial snapshot <=512KB, requested binary snapshot <=2MB, pending live output <=256KB while snapshot loads, output frames <=48KB, output coalescing, abort cleanup, and stale resize re-stream suppression | Legacy JSON path parity/deprecation is undecided | Decide/gate/deprecate JSON fallback | -| Store/git hot paths around terminal changes | Perf/git-crash skills exist; git status limits/coalescing exist; `terminal-performance.store-and-git-hot-paths` now has a boot-hydration counter slice | No Electron count gate for terminal-adjacent store projection, git status, focus, typing, tab/workspace switch, render, or high-session provider fanout | Extend `terminal-performance.store-and-git-hot-paths` with interactive counters | -| IME and synthetic input | Input forwarding code and scattered coverage | No representative matrix or byte/cell oracle | Add `terminal-input.ime-and-synthetic-forwarding` | -| Daemon degraded/startup provider contract | Mechanisms exist; daemon slow-init E2E helps | Startup reconcile is unwired; degraded fallback needs fail-closed/probe proof | Add daemon degraded and startup reconcile contract gates | -| SSH/remote restore and mirror polling | SSH localhost and remote fixes exist; this branch has store/provider/main/renderer proof for deferred SSH wake metadata, SSH attach, listing failure, deferred attach, passphrase cancellation, transient deferred reattach failure, and expired deferred relay fallback | Remote mirror polling, live SSH relay behavior, false-live visual state, pending-vs-attached UI status, and real reconnect timing remain unproved | Add live SSH soak and remote mirror polling gates | -| WSL restore and launch identity | `terminal-provider.wsl-restore-contract` is now registered as a first-class gap | No WSL provider contract or live Windows WSL smoke yet proves cwd/path identity, startup command delivery, targeted liveness, or restore ownership | Add deterministic WSL provider contract, then one focused Windows WSL smoke | -| Scrollback/replay restore | Recent #7012 fix, long-table restore coverage, this branch's remote replay ordering proof, #7133's merged alt-only-clear tests, and #7173's merged ordered-seq interleaving and session-revival tests on main | Normal-buffer clear semantics and metadata-only replay still need deterministic lower-layer proof; the merged #7133/#7173 tests are unregistered until rebase | Add `terminal-output.scrollback-restore` as a dirty-state exactness contract, seeded with #7133's and #7173's tests | -| Renderer/mirror parser parity | #7148 (merged 2026-07-02) fixed the first known width divergence; its red-green width test exists on main | Parser configs can still drift silently on any axis besides widths; no parity gate command is registered | Add `terminal-mirror.parser-parity` built on the shared `terminal-unicode-provider.ts`/`pane-terminal-options.ts` seams | -| Post-restore convergence detection in production | Anomaly-breadcrumb machinery exists in this branch | Nothing measures renderer-vs-mirror divergence after real restores; #7133-class corruption stays silent until a user notices | Add `terminal-observability.restore-convergence-selfcheck` probe plus telemetry | -| Stale pixels after reveal (render-level) | #7133's reveal hardening and the live repro harness's refresh-repair oracle | Buffer oracles are blind to buffer-clean, pixels-stale variants by definition | Add `terminal-render.pixel-refresh-repair` to the release-blocking terminal-rendering-golden suite | - -## Convergence Oracles And Escape Detection - -The #7133 saga is the design input for this section. One user-visible symptom — stale terminal frames on worktree return — decomposed into five distinct mechanisms: the skipped alt-screen clear (#7133 primary fix), the WebGL render-model fossil (#7133 hardening), the daemon mirror unicode width divergence (#7148), the stale hidden-chunk ordering hole with seq-restart data loss (#7173), and desktop resizes never reaching the runtime mirror (#7192). None of the three was predictable in advance, and seventeen faithful fresh-window repro attempts missed the class because the bugs only exist in dirty, long-lived pane states. Mechanism-enumeration testing cannot catch this family. Layer-convergence assertion can. - -Orca holds four copies of what a terminal shows: the PTY application's intended screen, the main-process headless mirror buffer, the renderer xterm buffer, and the WebGL-rendered pixels. Every bug in this family is two adjacent copies silently disagreeing. The strategy is two layers that map to the program's two goals — catching issues nobody predicted, and catching regressions to issues already fixed. - -Catch novel escapes: - -1. `terminal-observability.restore-convergence-selfcheck`: after every hidden-to-visible reveal settles — whether or not a restore was triggered — compare a bounded row-hash sample of the renderer buffer against the current main-mirror state; on mismatch, record a compact content-free `terminal_lifecycle_anomaly` breadcrumb (machinery already in this branch) and count it in telemetry. Per-reveal scope is load-bearing: #7173's frozen-output variant restores faithfully and then silently drops later hidden output, so a restore-scoped probe would never fire. This turns silent corruption into a measurable signal — #7133 and #7173 would have appeared in anomaly counts weeks before the user reports — and after each fix the anomaly rate is the in-production regression detector. Budget: one bounded comparison per reveal, no polling, no content capture beyond hashed row samples. -2. `terminal-render.pixel-refresh-repair`: the render-level variant is invisible to every buffer oracle by definition (buffer clean, pixels stale). The content-independent oracle: screenshot the revealed pane, force a full model-invalidating repaint, screenshot again; a material diff means something rendered stale, whatever the mechanism. Productize the existing live repro harness with this oracle plus a JS-level render-model-vs-buffer comparison, and land it in the release-blocking `terminal-rendering-golden` suite that `release-cut.yml` already runs against every release tag. Use nightly runs only as the interim phase to stabilize the per-platform diff threshold; golden-set membership is what makes "the next release does not ship this class" directly checkable. -3. Session-corpus replay: record real agent-session byte streams (a Claude run is exactly the #7133 shape — banner, prompt echo, final frame with blank regions) as fixtures and replay them through the hide/skip/restore machinery under the convergence oracles. Real corpora contain escape-sequence conjunctions nobody writes by hand. This is fixture strategy for the gates above, not a separate gate. - -Catch regressions to known fixes: - -4. `terminal-mirror.parser-parity`: feed identical byte corpora to the renderer xterm configuration and the main headless mirror and assert cell-identical buffers, with both parsers constructed from one shared configuration module. #7148's divergence (Unicode 11 + ZWJ in the renderer, default width tables in the mirror) existed since the mirror was born and was catchable by a millisecond-scale unit test; this gate also blocks the next one-sided config drift. -5. The dirty-state restore exactness contract as `terminal-output.scrollback-restore`'s command: apply snapshots onto adversarially dirty panes and assert the resulting buffer exactly equals the snapshot frame. Exactness is load-bearing — presence-of-marker oracles pass on merged frames. Seeded with #7133's merged alt-only-clear tests and #7148's positioned-overwrite width oracle. -6. Manifest registration of every escape: each fixed escape's red-green test becomes gate evidence, and per the Operating Rules the fix PR must also strengthen the relevant convergence oracle, so each escape permanently shrinks the space for the next one. - -Seam placement is part of the design: convergence must be asserted at every adjacent pair of copies, not at one seam. #7192 proved this the hard way — a renderer-vs-mirror check is blind to corruption inside the mirror itself, because the restore is perfectly faithful to a corrupted source. That is why the geometry gate owns the runtime mirror's dimensions and reflow ordering as a first-class size authority, upstream of the seam this section's self-check watches. - -The honest limit stands: this shrinks the escape space rather than zeroing it. The first four mechanisms in the saga are caught by items 1, 2, 4, and 5 without predicting any of them; the fifth (#7192) escaped every renderer-side oracle as originally specced and was closed by adding the mirror to the geometry authority list — evidence that the escape-driven loop (item 6) is not optional. The three new gates and the strengthened `terminal-output.scrollback-restore` entry are registered in the manifest as `protection: "none"` gaps, so the checker, factory reviews, and this plan cannot claim them as coverage before their commands exist. - -## Immediate Fix Candidates - -These are the concrete pieces to prioritize before declaring the plan implemented: - -Three mechanical prerequisites gate everything below: - -- Materialize the split: commit each working-tree slice to its owning branch per the file assignment in the PR Split Plan, reset the diverged child branches (#7004, #7005, #7006, #7008) from the worktree versions of their files, and push. Until then, every evidence run in this plan and in the manifest refers to unreproducible state. -- During the rebase, run the testFiles-vs-fresh-main audit and fix the confirmed #6949 WebGL rename by adopting `terminal-webgl-atlas-recovery.test.ts` and evaluating `pane-webgl-context-recovery.test.ts` and `pane-webgl-renderer.test.ts` for the containment gate. -- Open the aggregated non-blocking `reliability-gates` CI job so promotion evidence starts accruing with machine provenance. - -1. Rebase or retarget the existing reliability PR stack onto fresh `origin/main`, then verify the stack still contains queued automatic resume claims and exact `hasPty` missing-session reconciliation. This branch has restored both deterministic slices, but final stack verification is still required. -2. Extend the provider-session ownership test from the current queued/pending, bounded-index, same-session hook, and wrong-session hook proof to a real Electron repeated-activation test, plus bounded-work assertions for any new hook/status scans. -3. Add or restore exact liveness tests: `hasPty(id) === false` tears down only the matching local PTY, while `true`, `null`, rejection, SSH/remote unknown states, and stale pre-bind responses do not close panes. -4. Add a small derived terminal ownership snapshot helper for tests and diagnostics. It should report whether each live PTY is represented once by the intended tab/leaf and absent from stale maps after attach, reattach, exit, clear, hibernate, and restore. -5. Keep the replay FIFO/burst fix and tests in the stack, rebased over #7133's alt-clear restore preamble in the same file. This branch now asserts that multiple replay notifications arriving during an async drain cannot overwrite one another, cannot reorder accepted replay snapshots, and cannot grow an unbounded replay queue under bursty snapshots; remaining replay work is normal-buffer clear semantics (alt-screen clear landed in #7133), metadata-only replay, and hidden/live-output interleaving. -6. Wire `DaemonPtyAdapter.reconcileOnStartup`, or explicitly create a provider-contract gate and accepted gap if wiring is not safe yet. -7. Keep the degraded daemon fail-closed contract in place and extend it to startup/restart coverage. This branch now prevents unknown existing ids from silently routing operation calls or restored worktree-scoped and legacy/non-scoped spawn calls to fallback; the remaining work is production startup reconcile, prior-worktree aliases, and live daemon-restart validation. -8. Keep the daemon-router targeted-discovery fix in place. This branch now caches ownership discovered by `hasPty()` before later write/resize-style operations and fails closed for operations on unknown existing session ids, preventing legacy-daemon sessions from silently routing to the current daemon. -9. Extend provider listing failure contract tests. This branch now asserts disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, a rejected SSH listing does not clear previously learned ownership, thrown SSH `hasPty` is unknown, provider expired attach does not silently fresh-spawn, deferred SSH passphrase cancellation does not reconnect, deferred attach uses saved session ids, transient deferred reattach failure preserves retry metadata instead of fresh-spawning, and expired deferred relay fallback clears stale pane/tab bindings before one fresh spawn. Remaining work is empty-but-ambiguous provider snapshots, WSL, remote-runtime mirror polling, live SSH relay timing, false-live visual state, pending-vs-attached UI status, and pane-close behavior. -10. Extend the new lifecycle anomaly breadcrumbs into a bounded structured lifecycle trace outside E2E-only mode for reattach, expired sessions, provider-list failure, unknown ownership, fallback routing, replay, and terminal input recovery. -11. Extend the hot-interaction listing count gate. The targeted `hasPty`, resize-on-resume, light tab/active-state resume, and closed Resource Manager polling slices are implemented; next it should prove `pty:listSessions()` calls are zero during raw focus, workspace switch, render, high-session Electron scenarios, and separately budget open status/diagnostic polling. -12. Add a cheaper Resource Manager session-status path or an explicit budget for open-popover `pty:listSessions()` polling. The closed-popover poll is removed, but the visible Resource Manager inventory still fans out to providers and mutates ownership, so it should have a measured ceiling. -13. Budget the new startup hydration counters for `hydrateLocalPtyRegistryAtBoot` in CI/runtime evidence, including repo/worktree enumeration count, git subprocess count proxy, daemon session-list duration, and confirmation that no startup-critical path awaits the scan. -14. Extend the output backpressure proof. The daemon stream batcher now pauses after `write(false)`, prioritizes flush-immediate output from another session ahead of unrelated background backlog on `drain`, clears pure-backpressure queues on global cleanup, bounds queued stream bytes, and resumes on `drain`; main pending renderer output is capped per PTY and in total. Next, hidden-output floods and active key latency need metric artifacts under the perf budget. -15. Add Windows platform gates rather than relying on Linux/macOS live PTY evidence: ConPTY liveness, keyboard reset, CJK repaint, shell resolution parity, and IME/native-text forwarding. -16. Decide whether the legacy JSON runtime terminal subscribe path is supported. If supported, gate its snapshot/live-output byte caps; if not, mark it as an accepted gap with a deprecation path. -17. Implement the dirty-state restore exactness contract as `terminal-output.scrollback-restore`'s first command, seeded with #7133's merged alt-only-clear tests and #7173's ordered-seq interleaving and session-revival tests: apply snapshots onto already-alt-screen panes with stale content in cells the new frame leaves blank, include revived sessions with restarted PTY seq counters in the dirty-state matrix, and assert exact buffer equality. -18. Implement the parser-parity seed during the rebase, now that #7148 has merged: register `headless-emulator-unicode-width.test.ts` plus a shared parser-construction assertion over `terminal-unicode-provider.ts` and `pane-terminal-options.ts` as `terminal-mirror.parser-parity`'s first command. The test file exists on fresh main but not at this branch's stale merge-base, so the command cannot be registered before the rebase. -19. Implement the per-reveal convergence self-check behind the existing anomaly-breadcrumb machinery — comparing the renderer buffer against current mirror state on every reveal, not only after restores, with a strict per-reveal budget and no content capture — and productize the stale-render repro harness into `terminal-render.pixel-refresh-repair` targeting the release-blocking terminal-rendering-golden suite. -20. Register #7192's merged mirror-geometry red tests as the mirror-authority slice of `terminal-geometry.visible-convergence` during the rebase, covering both accepted-resize fan-out to the runtime mirror and reflow-vs-queued-output ordering. - -Next gates to add before claiming broad terminal reliability coverage: - -1. `terminal-platform.live-pty-liveness`: first focused Electron/live-PTY slice is implemented for local Linux/macOS-style terminals. One local macOS run asserts bind, single active PTY listing, focus, keyboard input, PTY echo, repeated workspace hide/restore of the same PTY, post-restore input, actual size propagation, and exit cleanup. Follow up with scrollback-after-restore, tab switch, app restart, and non-local providers before broad live-terminal claims; keep it `experimental` until it has flake/runtime and red-green evidence. -2. `terminal-platform.windows-conpty-liveness`: Windows-specific gate for ConPTY spawn/activation, PowerShell resolution, cursor/rewrite repaint, CJK/IME bytes, and no stuck `stale_bootstrap`. Use lower-level provider contracts where possible and one focused Windows Electron smoke for the real ConPTY path. -3. `terminal-input.windows-conpty-keyboard-reset`: Windows local ConPTY gate proving Enter, Backspace, Arrow, paste, and ordinary shell submission behave normally after an agent or TUI exits. Oracle should include PTY input logs proving stale Kitty/CSI-u mode is not still applied to standard keys. -4. `terminal-render.windows-cjk-repaint`: Windows local ConPTY wide-glyph redraw gate. Oracle should combine xterm buffer/text evidence with screenshot/canvas/pixel evidence and a bounded refresh count. -5. `terminal-shell.windows-resolution-parity`: local and daemon providers must resolve equivalent shell path, args, cwd, env, startup command delivery, and fallback behavior for PowerShell 5/7, cmd, Git Bash, WSL, and missing `pwsh`. -6. `terminal-performance.input-throughput`: non-blocking perf gate for typing latency, hidden-output pressure, renderer CPU, terminal output throughput, resize churn, and store subscriber work. This should be metric-based, not screenshot-based. -7. `terminal-performance.no-hot-list-sessions`: deterministic count gate proving `pty:listSessions()` is not called from typing, focus, tab/workspace switch, visibility resume, resize, render, or per-pane liveness paths. -8. `terminal-performance.daemon-stream-backpressure`: provider/daemon contract gate proving daemon socket writes respect backpressure under output floods and do not starve active input. -9. `terminal-performance.output-backpressure-budget`: main-to-renderer ACK and renderer scheduler budget gate. Suggested ceilings: peak renderer in-flight bytes <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped renderer backlogs 0 in normal perf scenarios, and hidden restore <=1000ms. -10. `terminal-runtime.mobile-stream-budget`: runtime/mobile stream gate proving binary terminal snapshots stay <=512KB mobile or <=2MB requested, live pending output stays <=256KB while snapshot loads, chunks stay <=48KB, and batches stay <=64KB. -11. `terminal-input.ime-and-synthetic-forwarding`: representative IME/input matrix with deterministic byte/cell oracles at the lowest reliable layer, plus platform-specific manual/soak coverage where real IMEs are required. -12. `terminal-provider.remote-daemon-contract`: provider contract for daemon degraded spawn, SSH/remote unknown liveness, mirror polling, reconnect, and path/cwd authority. -13. `terminal-provider.wsl-restore-contract`: Windows WSL provider contract proving host/guest cwd identity, shell launch args, startup command delivery, targeted liveness semantics, and restore ownership, followed by one focused live WSL smoke. -14. `terminal-output.scrollback-restore`: deterministic scrollback/snapshot/replay gate for hidden output, restore, clearing, and no cross-tab or stale-output overlap. Seed it with #7133's merged assertions — the alt-only clear preamble written before alt-screen snapshot data (and no `3J`) in `pty-connection.test.ts`, plus the reveal-repaint ordering tests — then extend to normal-buffer clear semantics, metadata-only replay, and hidden/live-output interleaving. #7133's buffer-merge escape (pre-hide frame bleeding through blank cells) is the motivating regression for this gate. -15. `terminal-capability.startup-color-query`: startup OSC 10/11 replies are answered out of band at startup only, do not leak into shell/renderer streams, and ordinary OSC color queries remain renderer-handled. -16. `terminal-mirror.parser-parity`: renderer xterm and the main headless mirror parse identical byte corpora into cell-identical buffers, with both parsers constructed from one shared configuration module. Seed with #7148's merged width test (`headless-emulator-unicode-width.test.ts`) plus a shared-construction assertion over `terminal-unicode-provider.ts` and `pane-terminal-options.ts`; extend with recorded agent-session corpora. -17. `terminal-observability.restore-convergence-selfcheck`: production per-reveal probe comparing a bounded row-hash sample of the renderer buffer against current main-mirror state — on every reveal, not only after restores, because #7173's frozen-output variant never triggers another restore — recording deduped content-free anomaly breadcrumbs and telemetry counts. This is the in-production escape detector for the whole restore family; corruption upstream of the mirror itself is owned by the geometry gate. -18. `terminal-render.pixel-refresh-repair`: content-independent refresh-repair oracle (screenshot, force a model-invalidating repaint, screenshot, bounded diff) plus a render-model-vs-buffer comparison, productized from the existing stale-render repro harness into the release-blocking `terminal-rendering-golden` suite; nightly runs only until the diff threshold is stable. - -Specific near-term hardening before promotion: - -- #7008 now includes a bounded queued-claim assertion plus same-session and wrong-session hook cases for provider-session ownership checks. It should still be followed by a real Electron repeated-activation proof and any additional bounded-work assertions if hook/status ownership scans grow. -- Each gate PR should keep its residual gaps in the PR description and manifest until a real follow-up gate removes them. Do not let a green lower-layer gate imply live Electron, SSH, WSL, Windows ConPTY, or IME coverage. - -Promotion rule for these additions is unchanged: do not make any of them blocking until they have red/green proof, stable runtime history, zero unexplained flakes in the promotion window, a clear owner, and a demotion rule. A non-blocking gate is useful evidence and documentation; it becomes protection only after promotion criteria are met. - -## Goal - -Prevent the recent terminal, tab, session, startup, provider, and performance regressions from escaping again by turning each high-risk reliability class into a small executable gate with explicit maturity, owner, runtime budget, flake history, and promotion evidence. - -This plan is broad in scope but staged in rollout. The first milestone exercises the operating model end to end; later milestones add live Electron, Windows ConPTY, SSH/remote, daemon, IME, scrollback, and performance gates without turning the suite into noisy sprawl. - -## Operating Rules - -- Add gates only when they protect a named invariant tied to a real issue, PR, or accepted gap. -- Prefer deterministic unit or provider-contract tests over Electron UI tests when the lower layer proves the user-visible invariant. -- Never promote a gate to blocking until it has red/green evidence, stable runtime evidence, and a named demotion rule. -- Keep stress/torture runs non-blocking unless they have deterministic oracles and flake history. -- Include a performance or crash-safety budget whenever the protected change touches terminal throughput, hidden panes, persistence, startup, polling, subprocesses, git/worktree scans, SSH, WSL, or Windows paths. -- Restore, replay, reattach, and snapshot gates must start from adversarially dirty initial states — already-alt-screen panes, stale content occupying cells the new frame leaves blank, scrollback present, wide/emoji glyphs — never only fresh terminals. Fresh-state-only testing is why the #7133 class survived seventeen faithful repro attempts. -- Restore and replay oracles must assert frame exactness or stale-content absence. Marker-presence oracles pass on merged frames and are not acceptable correctness evidence for restore paths. -- Every escaped terminal bug's fix PR must ship both the narrow mechanism regression test and a strengthening of the relevant convergence oracle (parser parity, restore exactness, convergence self-check, or pixel refresh-repair), so each escape permanently shrinks the space for the next one. - -## Milestone 1 - -Milestone 1 creates the reliability-gate loop with the two highest-leverage escaped classes: - -1. `terminal-session.snapshot-freshness` - A stale local/daemon liveness snapshot cannot close a newer PTY binding. - -2. `agent-session.provider-ownership` - Workspace activation, restore, sleep, hibernate, dedupe, clearing, or reconnect code cannot replay or resume a provider session already owned, queued, pending, or live in the workspace. - -Both gates already have targeted tests in the repo. Milestone 1 makes them reviewable factory artifacts by registering their invariant, command, owner, maturity, known gaps, and promotion criteria in `config/reliability-gates.jsonc`. - -## First Commands - -Run the structural manifest check: - -```sh -pnpm run check:reliability-gates -``` - -Run the first two gate tests: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ - src/renderer/src/lib/resume-sleeping-agent-session.test.ts -``` - -Run the existing terminal perf report gate before promoting terminal lifecycle gates to blocking: - -```sh -pnpm run test:e2e:terminal-perf:scale:report -``` - -## Promotion Criteria - -A gate can move from `experimental` to `soak` when: - -- it is registered in the manifest; -- it has a deterministic oracle with no blind sleeps; -- it has a cheap command that can run repeatedly; -- it names the failure artifact reviewers should inspect; -- it has a clear owner and demotion rule. - -A gate can move from `soak` to `blocking` when: - -- it has red/green evidence against the old behavior, a regression fixture, or an intentionally broken variant; -- it has at least 100 consecutive passing soak runs or 14 days of required-platform CI history, whichever is more appropriate for the gate; -- that history comes from the aggregated `reliability-gates` CI job with machine-recorded results (see Evidence Provenance And CI Wiring), not from hand-entered local runs; -- p95 runtime is within the manifest budget; -- there are zero unexplained flakes in the promotion window; -- any required perf/git-crash budget has measured evidence. - -For visual and live release-assurance gates, `blocking` means membership in the release-blocking `terminal-rendering-golden` suite that `release-cut.yml` runs against every release tag — the existing golden rail, not a new checkpoint mechanism, and never PR-blocking. Deterministic unit and contract gates stay on the PR path, where catching a regression is strictly cheaper than catching it at release time. - -The manifest also allows `accepted-gap` for explicitly deferred reliability work with a named owner and `deprecated` for gates superseded by a stronger invariant or removed product surface. Those levels should stay visible in the manifest so factory review can distinguish intentional gaps from missing coverage. - -## Factory Contract - -`brennan-yolo-lite`, `review-code`, `perf`, and `git-crash-perf` should treat the manifest as the source of truth. - -For any PR touching a P0-capable surface, the agent or reviewer should: - -- identify the touched reliability class; -- name the matching manifest gate or accepted gap; -- run the gate command or explain why it does not apply; -- require a new manifest entry when the PR introduces a new reliability class; -- invoke `perf` or `git-crash-perf` when the touched surface matches their risk scope. - -## Evidence Provenance And CI Wiring - -Hand-entered evidence is the weakest link in this plan: the checker validates metadata shape, but nothing yet proves a registered command actually ran against the code it claims to cover. Reliability gates become protection only when they run automatically, against pushed commits, with recorded history. Requirements: - -1. Every `evidenceRuns` entry must record the pushed commit SHA it ran against. A run against uncommitted working-tree state is a development note, not promotion evidence; it must be re-run and re-recorded once the owning PR's branch is pushed. All evidence runs currently in the manifest predate the split and must be refreshed this way. -2. Add one aggregated non-blocking `reliability-gates` CI job that runs `check:reliability-gates` plus every command-backed gate command, and uploads a machine-readable per-gate result artifact (gate id, command, result, duration, commit SHA, runner platform). Run it nightly and on PRs that touch terminal/session/provider/startup paths. Because every gate command except the live Playwright slice is a focused vitest run measured in seconds (a combined run of all 28 unit-layer gate test files completed in 10.2s with 928 tests on 2026-07-02), the job's cost is dominated by setup, not by gate count. -3. Promotion to `active`/blocking requires history from that CI job — the 100-consecutive-run or 14-day criteria in Promotion Criteria — not local runs. Flakes recorded by the job must be triaged before the affected gate can be promoted, and an unexplained flake on an `active` gate is the demotion signal. -4. Checker follow-up: once the CI artifact exists, extend the checker so `active` gates require at least one CI-provenance evidence run, and so evidence-run commit SHAs are verified to be ancestors of the branch under check. -5. Rebase audit: before any merge claim, mechanically verify every declared `testFiles` entry exists on fresh `origin/main` (or on the rebased tree) rather than trusting memory. The #6949 WebGL rename is the standing example of why. - -## Milestone 2 - -After Milestone 1 works, add the next three gates as experimental entries with deterministic harnesses: - -- `terminal-geometry.visible-convergence` -- `xterm-addon.boundary-containment` -- `startup-upgrade.persisted-session-corpus` - -These should not become blocking until the team has red/green, runtime, and flake evidence. The goal is fewer useful gates, not a larger noisy suite. - -## Milestone 3 - -Milestone 3 is where the plan stops being only a lower-layer regression net and starts exercising the real terminal runtime: - -- Register existing terminal perf and E2E tests as experimental manifest gates only after reviewing their oracles, waits, artifacts, and flake history. -- Add the live local PTY gate with a tight oracle: spawn a shell, print a unique token, switch hidden-visible, assert the same PTY/session id, verify visible buffer content, type again, and prove no duplicate tab or blank pane appeared. -- Add Windows ConPTY coverage on Windows CI for echo, resize/readback, cursor visibility, CJK/emoji output, keyboard reset, shell resolution parity, and activation after restore. -- Add daemon degraded/startup reconcile contracts before relying on daemon fallback behavior. -- Add live SSH, WSL, remote-runtime mirror polling, and provider-listing edge contracts before treating remote/SSH restore as broadly covered; the current mocked SSH deferred-reattach and expired-relay tests are useful lower-layer proof, not live remote coverage. -- Add perf gates with explicit budgets for input latency, event-loop delay, hidden-output pressure, daemon stream backpressure, no hot `listSessions`, pending bytes, renderer ACK behavior, store hot paths, and runtime/mobile stream byte caps. -- Add IME/synthetic-input coverage at the lowest deterministic layer, with platform-specific manual or soak coverage only where real IMEs are required. - -Milestone 3 gates should begin as `experimental`. They become `soak` only when the command is stable enough to run repeatedly and the failure artifact tells an engineer exactly what broke. They become `blocking` only after the promotion criteria above are satisfied. diff --git a/docs/reference/reliability-pain-points-2026-06-30.md b/docs/reference/reliability-pain-points-2026-06-30.md deleted file mode 100644 index 8049e3e7191..00000000000 --- a/docs/reference/reliability-pain-points-2026-06-30.md +++ /dev/null @@ -1,712 +0,0 @@ -# Reliability Pain Points and Improvement Plan - -Date: 2026-06-30 - -## Scope - -This note summarizes the recent reliability review across: - -- Recently merged reliability PRs, especially terminal/tab/startup/session work from 2026-06-26 through 2026-06-30. -- GitHub issues labeled `P0` and `P1`. -- Local git history for authorship when issue text or PR bodies named a regressing change. - -The goal is to identify where Orca is hurting, what engineering improvements would reduce repeat incidents, and what can fairly be said about attribution. - -## Issue Snapshot - -At the time of review: - -- Open `P0`: 9 -- Total `P0`: 14 -- Open `P1`: 191 -- Open `P1` split: 90 bugs, 86 enhancements, 15 other - -Last seven days reviewed: 2026-06-23 through 2026-06-30. - -- Merged PRs in the window: 386 -- `P0` issues created in the window: 8 -- `P1` issues created in the window: 83 - -The last-seven-day issue set is the strongest signal for the current pain. It includes the new-tab autoclose `P0`, Windows terminal activation hangs, renderer CPU/input lag, Windows startup visibility failure, Codex config corruption on Windows, a large terminal/IME/rendering cluster, WSL/remote/provider drift, and agent/sidebar/session identity drift. - -Open `P0` issues: - -- [#6877](https://github.com/stablyai/orca/issues/6877): macOS 1.4.106 nested `simcam` dylib is unnotarized and rejected by Gatekeeper. -- [#6874](https://github.com/stablyai/orca/issues/6874): Windows `AppHangB1` when opening or activating a terminal session; runtime stuck in `stale_bootstrap`. -- [#6873](https://github.com/stablyai/orca/issues/6873): likely duplicate or near-duplicate of #6874, though the issue metadata says macOS while the title/body describe Windows. -- [#6795](https://github.com/stablyai/orca/issues/6795): app became very slow after an update; typing can lag by 2-5 seconds. -- [#6773](https://github.com/stablyai/orca/issues/6773): cannot create a new terminal tab or agent; the tab opens and closes immediately. -- [#6655](https://github.com/stablyai/orca/issues/6655): high `Orca Helper (Renderer)` CPU on Mac Mini M4 causing severe UI/input lag. -- [#5787](https://github.com/stablyai/orca/issues/5787): whole Windows window freezes; input dies in every pane; sessions close on click. -- [#5356](https://github.com/stablyai/orca/issues/5356): session loss on first launch after upgrading from 1.4.65 to 1.4.68. -- [#5314](https://github.com/stablyai/orca/issues/5314): Windows close button and Alt+F4 do nothing. - -Closed `P0` issues worth keeping in the reliability history: - -- [#6233](https://github.com/stablyai/orca/issues/6233): Windows window fails to appear while the process runs in the background. -- [#6163](https://github.com/stablyai/orca/issues/6163): Orca-managed Codex `config.toml` duplicate key. -- [#5377](https://github.com/stablyai/orca/issues/5377): native watcher main-process crash on macOS. -- [#5144](https://github.com/stablyai/orca/issues/5144): Windows close-confirmation deadlock. -- [#5109](https://github.com/stablyai/orca/issues/5109): sidebar agent status mismatch. - -## P1 Reliability Clusters - -The `P1` set is too large to treat as a flat list. The high-signal reliability clusters are: - -### Terminal Rendering and Input - -- [#6901](https://github.com/stablyai/orca/issues/6901): garbled text. -- [#6764](https://github.com/stablyai/orca/issues/6764): Windows terminal issues. -- [#6632](https://github.com/stablyai/orca/issues/6632): display artifacts in Claude Code full-screen TUI. -- [#6144](https://github.com/stablyai/orca/issues/6144): laggy Claude Code terminal scroll. -- [#5969](https://github.com/stablyai/orca/issues/5969): remote-over-SSH Codex display artifacts. -- [#5345](https://github.com/stablyai/orca/issues/5345): Windows/WSL scrolling causes duplicated or garbled text and content overlap. - -### IME and International Text - -- [#6905](https://github.com/stablyai/orca/issues/6905): Vietnamese IME input broken in terminal. -- [#6765](https://github.com/stablyai/orca/issues/6765): Linux Sogou Pinyin/fcitx only commits the first CJK character. -- [#6698](https://github.com/stablyai/orca/issues/6698): Vietnamese Telex input loses characters. -- [#5921](https://github.com/stablyai/orca/issues/5921): CJK characters duplicated in Orca terminal on Windows. -- [#5262](https://github.com/stablyai/orca/issues/5262): Arabic text rendering appears reversed or disconnected in terminal input. - -### Paste and Clipboard - -- [#5365](https://github.com/stablyai/orca/issues/5365): terminal freezes for seconds when pasting long text on Windows. -- [#5358](https://github.com/stablyai/orca/issues/5358): Windows paste preview collapses long text and cannot expand it. -- [#5919](https://github.com/stablyai/orca/issues/5919): generic paste reliability issue. -- [#5960](https://github.com/stablyai/orca/issues/5960): bad screen result when pasting links. -- [#6364](https://github.com/stablyai/orca/issues/6364): pasting images into an agent CLI does not work using Remote Host. - -### SSH, Remote Runtime, WSL, and Provider Boundaries - -- [#6106](https://github.com/stablyai/orca/issues/6106): SSH terminal loses pre-TUI shell output after Codex tab restore. -- [#6846](https://github.com/stablyai/orca/issues/6846): remote PTY sessions do not stay alive on disconnect. -- [#6908](https://github.com/stablyai/orca/issues/6908): WSL project added through UI does not discover branches/base refs, while CLI repo add works. -- [#6907](https://github.com/stablyai/orca/issues/6907): Codex launched in a WSL worktree does not appear in agent status/sidebar. -- [#6916](https://github.com/stablyai/orca/issues/6916): OMP launched in a WSL worktree has no live agent status. -- [#6032](https://github.com/stablyai/orca/issues/6032): Remote Orca Servers can lose host-scoped repo and project state. -- [#6688](https://github.com/stablyai/orca/issues/6688): viewing a diff on remote host errors. -- [#6753](https://github.com/stablyai/orca/issues/6753): remote-host GitHub merge errors. - -### Agent, Tab, Session, and Sidebar State Drift - -- [#6910](https://github.com/stablyai/orca/issues/6910): active agent tabs duplicate UI sessions when switching repositories. -- [#6803](https://github.com/stablyai/orca/issues/6803): multiple working agents thrash their order in the sidebar. -- [#6072](https://github.com/stablyai/orca/issues/6072): mobile keeps showing old agent rows after terminals are closed. -- [#5913](https://github.com/stablyai/orca/issues/5913): Pi compact agent row remains after closing its terminal tab. -- [#5718](https://github.com/stablyai/orca/issues/5718): OMP sessions not showing up in Agent Session History or filter options. -- [#5404](https://github.com/stablyai/orca/issues/5404): false "agent running" notifications persist after starting Claude Agents on VSSH/WSL. - -### Mobile Terminal and Tabs - -- [#5421](https://github.com/stablyai/orca/issues/5421): iOS workspace tabs do not open. -- [#6756](https://github.com/stablyai/orca/issues/6756): iOS terminal renders in cursive/italic font and continuously flickers. -- [#5628](https://github.com/stablyai/orca/issues/5628): mobile resizing feature request, closely related to terminal fit/reflow reliability. - -### Browser, Profile, and Embedded Runtime - -- [#6923](https://github.com/stablyai/orca/issues/6923): browser profiles do not isolate storage. -- [#6760](https://github.com/stablyai/orca/issues/6760): floating browser cannot load any page. -- [#6268](https://github.com/stablyai/orca/issues/6268): embedded-browser cookie/session limitation prevents Auth0 login. -- [#6875](https://github.com/stablyai/orca/issues/6875): cannot copy Google Chrome cookies database. -- [#6652](https://github.com/stablyai/orca/issues/6652): browser zoom broken. - -### Startup, Install, and Platform Hangs - -- [#5657](https://github.com/stablyai/orca/issues/5657): macOS startup PATH probe can hang under Endpoint Security agents. -- [#5107](https://github.com/stablyai/orca/issues/5107): Orca opens very slowly on Windows 11 and leaves a residual process. -- [#5989](https://github.com/stablyai/orca/issues/5989): Windows Defender blocks application during installation. - -## Recent Reliability PR Context - -The late-June reliability work clustered around the same few subsystems: - -- New terminal tabs opening and immediately closing: [#6796](https://github.com/stablyai/orca/pull/6796), [#6801](https://github.com/stablyai/orca/pull/6801). -- Stale/frozen/blank terminals: [#6514](https://github.com/stablyai/orca/pull/6514), [#6800](https://github.com/stablyai/orca/pull/6800), [#6833](https://github.com/stablyai/orca/pull/6833), [#6830](https://github.com/stablyai/orca/pull/6830), [#6866](https://github.com/stablyai/orca/pull/6866). -- PTY size and layout desync: [#6644](https://github.com/stablyai/orca/pull/6644), [#6649](https://github.com/stablyai/orca/pull/6649), [#6684](https://github.com/stablyai/orca/pull/6684), [#6725](https://github.com/stablyai/orca/pull/6725), [#6785](https://github.com/stablyai/orca/pull/6785), [#6853](https://github.com/stablyai/orca/pull/6853). -- Renderer/xterm crash hardening: [#6852](https://github.com/stablyai/orca/pull/6852), [#6872](https://github.com/stablyai/orca/pull/6872), [#6855](https://github.com/stablyai/orca/pull/6855), [#6868](https://github.com/stablyai/orca/pull/6868), [#6856](https://github.com/stablyai/orca/pull/6856), [#6857](https://github.com/stablyai/orca/pull/6857). -- Windows shell/spawn/input: [#6537](https://github.com/stablyai/orca/pull/6537), [#6876](https://github.com/stablyai/orca/pull/6876), [#6858](https://github.com/stablyai/orca/pull/6858), [#6890](https://github.com/stablyai/orca/pull/6890). -- Hidden/agent startup behavior: [#6824](https://github.com/stablyai/orca/pull/6824), [#6798](https://github.com/stablyai/orca/pull/6798), [#6836](https://github.com/stablyai/orca/pull/6836). - -## Last-Seven-Day Regression Origin Map - -This section maps recent regressions to the place where the system failed, and to the gate that should catch the same class next time. - -| Issue | Symptom | Regression point | Fix / related PR | Missing gate | -| --- | --- | --- | --- | --- | -| [#6773](https://github.com/stablyai/orca/issues/6773) | New terminal/agent tab opens and closes immediately | Dead-session reconciliation could treat a fresh PTY binding as absent from a stale `listSessions()` snapshot. This likely traces to the local dead-session reconcile path introduced for hidden exited panes in [#6514](https://github.com/stablyai/orca/pull/6514). | [#6796](https://github.com/stablyai/orca/pull/6796), [#6801](https://github.com/stablyai/orca/pull/6801) | Newborn terminal lifecycle test: create shell and agent tabs while liveness reconciliation is in flight; assert the tab cannot be closed by an older snapshot. | -| [#5356](https://github.com/stablyai/orca/issues/5356) | First launch after upgrade loses active floating/agent terminal sessions | Cold-restore relied on state that only versions after the fix could write, so users upgrading from pre-fix versions had no migration source. Issue text explicitly names [#5234](https://github.com/stablyai/orca/pull/5234) and [#5240](https://github.com/stablyai/orca/pull/5240). | Not fully closed in current open `P0` set | Upgrade fixture test: boot current app with persisted state from the last affected production version and assert active terminal/agent sessions are preserved or visibly recoverable. | -| [#5319](https://github.com/stablyai/orca/issues/5319) | Linux/Wayland terminals render but stop accepting keyboard/scroll input | Wayland-aware GPU safeguard was removed in [#1344](https://github.com/stablyai/orca/pull/1344), leaving eager GPU channel setup able to wedge terminal input on affected systems. | [#6557](https://github.com/stablyai/orca/pull/6557) | Platform GPU matrix test: Linux Wayland launch with terminal WebGL/input smoke and assertion that GPU flags preserve input, not only rendering. | -| [#6233](https://github.com/stablyai/orca/issues/6233) | Windows process runs but no window appears | Startup window reveal depended on Electron `ready-to-show`; if that event stalled, there was no fallback reveal or user-visible failure. | [#6462](https://github.com/stablyai/orca/pull/6462) | Startup watchdog test: simulate missing `ready-to-show` on Windows and assert the app either reveals or exits with a clear recovery path. | -| [#6163](https://github.com/stablyai/orca/issues/6163) | Orca-managed Codex `config.toml` fails with duplicate `hooks.state` keys on Windows | Two TOML writers serialized the same Windows path key differently, and Orca deduped textually rather than by decoded key. | [#6318](https://github.com/stablyai/orca/pull/6318), follow-up [#6388](https://github.com/stablyai/orca/pull/6388) bug-scan fix | Cross-writer config round-trip test: seed config with Codex-written literal Windows paths, run Orca hook sync twice, then parse with a TOML parser and assert one decoded key. | -| [#6244](https://github.com/stablyai/orca/issues/6244) | Headless-launched automation terminal showed only a bash prompt until unmount/remount | Hidden/background automation worktrees were not mounted early enough for output/replay to hydrate on first view. | [#6568](https://github.com/stablyai/orca/pull/6568) | Headless automation mount test: launch background automation, wait for output, first-focus the tab, and assert output is already visible. | -| [#6331](https://github.com/stablyai/orca/issues/6331) | WSL workspaces with UNC cwd hit `DaemonProtocolError` | Windows/WSL path handling crossed provider boundaries and daemon spawn assumptions. | [#6536](https://github.com/stablyai/orca/pull/6536) | WSL UNC provider contract: add/open/delete workspace whose root is `\\wsl.localhost\...`; assert daemon and file operations use the right host/path semantics. | -| [#6336](https://github.com/stablyai/orca/issues/6336) | Monorepo subfolder import opened the repo root instead of the selected subfolder | Git-root normalization leaked into the first-terminal cwd/user-facing workspace root behavior. | [#6574](https://github.com/stablyai/orca/pull/6574) | Add-project matrix: repo root, monorepo subfolder, folder workspace, remote runtime; assert file tree root and first terminal cwd separately. | -| [#6147](https://github.com/stablyai/orca/issues/6147) | Chinese full-width punctuation became half-width ASCII in terminal | IME/composition forwarding lacked coverage for macOS full-width punctuation and composition-owned text. | [#6417](https://github.com/stablyai/orca/pull/6417) | IME matrix test: macOS CJK full-width punctuation, Vietnamese Telex, Linux fcitx/Sogou, Windows CJK; assert committed bytes and displayed cells. | -| [#5656](https://github.com/stablyai/orca/issues/5656) / [#5653](https://github.com/stablyai/orca/issues/5653) | Windows Claude Code prompt showed phantom/overwritten characters until resize | Rewrite-style terminal output did not reliably force visible row repaint for CR/CHA/erase updates. | [#6544](https://github.com/stablyai/orca/pull/6544), [#6449](https://github.com/stablyai/orca/pull/6449) | Terminal redraw fixture: feed CR, CHA, backspace, erase-line/screen and assert visible cells after each chunk without resize. | -| [#5161](https://github.com/stablyai/orca/issues/5161) | PowerShell terminal failed to spawn with Windows error code 5 | Windows shell resolver handed ConPTY a bare `pwsh.exe`, which could resolve to an App Execution Alias stub. | [#6537](https://github.com/stablyai/orca/pull/6537), [#6876](https://github.com/stablyai/orca/pull/6876) | Windows shell resolution contract: auto and explicit PowerShell selections must resolve to real executables and skip WindowsApps aliases. | -| [#6270](https://github.com/stablyai/orca/issues/6270) | Restoring agent sessions was not working for SSH/runtime use cases | Resume logic and runtime host restrictions did not cover SSH workspaces. | [#6685](https://github.com/stablyai/orca/pull/6685) | Agent resume provider matrix: local, daemon, SSH, remote runtime, Windows shell; assert resume command is queued on the configured host/provider. | -| [#6852](https://github.com/stablyai/orca/pull/6852) / [#6872](https://github.com/stablyai/orca/pull/6872) | Terminal search could crash terminal surface on narrow viewports | xterm search decorations received invalid dimensions and threw through the terminal surface. | [#6852](https://github.com/stablyai/orca/pull/6852), [#6872](https://github.com/stablyai/orca/pull/6872) | xterm addon safety test: narrow viewport, zero/near-zero columns, wrapped matches, keyboard next/previous; assert addon errors are caught and pane remains usable. | -| [#6855](https://github.com/stablyai/orca/pull/6855) | xterm web-link provider `RangeError` could kill the Windows renderer | Link provider ran synchronously inside xterm with no guard around pathological wrapped lines. | [#6855](https://github.com/stablyai/orca/pull/6855) | Addon boundary policy: all xterm addon callbacks are guarded and failure disables only that addon for that pane. | -| [#6853](https://github.com/stablyai/orca/pull/6853) | Split-right PTY stayed at `0x0` and rendered a white screen | Deferred spawn measured an unlaid-out pane; post-spawn reconcile could terminate before forwarding a usable size. | [#6853](https://github.com/stablyai/orca/pull/6853) | Split spawn invariant: visible panes must never keep PTY size `0x0`; fallback size must be forwarded if measurement is unavailable. | - -The repeated pattern is not one person repeatedly making the same mistake. It is that Orca does not yet have enough executable contracts around lifecycle, provider boundaries, terminal sizing, xterm safety, platform shell behavior, and upgrade state. - -## PR Process Evidence - -Reading the PR descriptions for the confirmed or likely regression chains shows that the failure was usually not "no process." The failure was that the process accepted plausible local validation without requiring the invariant that would have protected the escaped user path. - -### #6514: Dead-Session Reconcile Introduced a Newborn-Tab Hazard - -[PR #6514](https://github.com/stablyai/orca/pull/6514) was a Brennan-authored reliability fix for frozen terminal panes after a backgrounded agent exits. The PR body shows a substantial process: design review, completeness verification, headline behavior status, perf audit, code-review loop, Electron validation, and targeted unit tests. The stated non-goal explicitly excluded SSH/remote reconciliation, and the implementation revalidated pane/PTY identity at apply time. - -What still got through: - -- The PR protected "stale dead pane should close" but did not name the dual invariant "a fresh or newly bound pane cannot be closed by a liveness snapshot requested before that binding existed." -- The tests covered live/dead/remote/idempotency paths, but not the TOCTOU case where `listSessions()` is requested, a new PTY binds before the response returns, and the stale response omits the newborn id. -- Reviewers accepted "healthy panes are untouched" as a semantic claim, but the executable gate did not prove that claim under delayed session enumeration. - -Skill implication: - -- `brennan-yolo-lite`, `auto-design-review-fix`, `review-code`, and `brennan-test-changes` all need to force lifecycle changes to state the inverse safety property, not only the intended cleanup behavior. -- For terminal lifecycle changes, the review checklist should ask "what newer state can this async observation accidentally destroy?" - -### #5234 and #5240: Session-Restore Fixes Missed Old-Version Upgrade Fixtures - -[PR #5234](https://github.com/stablyai/orca/pull/5234) and [PR #5240](https://github.com/stablyai/orca/pull/5240) were Jinwoo-authored fixes for slow-startup daemon PTY restoration and quit-time agent session persistence. Their PR descriptions show AI review, negative controls, targeted tests, and high-quality e2e coverage against the newly modeled behavior. - -What still got through: - -- The tests proved the new behavior once the new data and startup gates existed. -- They did not appear to boot the current app against serialized persisted state from the affected production versions before the fix. -- The escaped class is an upgrade/migration class: a fix can be correct for state written after the fix while still losing users who upgrade from state written before it. - -Skill implication: - -- Startup, restore, daemon, and persistence PRs need an "old state corpus" gate: seed data from the last affected production version, not only state produced by current code. -- Negative controls should include "old production fixture fails before fix and is preserved after fix" when the bug is upgrade-related. - -### #1344: GPU Rendering Parity Missed Linux Wayland Input Failure - -[PR #1344](https://github.com/stablyai/orca/pull/1344) replaced global GPU opt-ins with VS Code-style GPU startup flags and terminal GPU acceleration settings. The PR description cites reference behavior and local tests, but it does not show the later, stricter Brennan PR process markers used in the June reliability fixes. - -What still got through: - -- The change removed or bypassed the Wayland-aware GPU safeguard later identified by [PR #6557](https://github.com/stablyai/orca/pull/6557). -- Validation covered configuration and pane lifecycle tests, but not a Linux Wayland terminal input smoke where rendering appears live while keyboard/scroll input is wedged. -- One TypeScript command was reported as failing on existing project-include issues, which made the PR less clean as a release-risk artifact. - -Skill implication: - -- Cross-platform rendering/startup changes need an explicit platform matrix tied to the failure mode: not just "renders," but "renders and accepts input." -- Reference implementations are useful context, but factory review must still require Orca-specific platform hazards to be named and tested or accepted. - -### Regression Lessons From Fresh Review - -Fresh reviewer passes on the recent regression chains pointed to these reusable misses. These are class-level gates, not new blame findings. - -- [#6514](https://github.com/stablyai/orca/pull/6514) -> [#6773](https://github.com/stablyai/orca/issues/6773), fixed by [#6796](https://github.com/stablyai/orca/pull/6796) and [#6801](https://github.com/stablyai/orca/pull/6801): the original PR correctly proved the forward cleanup case, where a hidden local/daemon PTY exits and should reconcile on resume while live listed panes survive. The missed invariant was the inverse lifecycle property: a destructive liveness snapshot can only close state that existed before the snapshot was requested. The escaped path was a macOS local new shell or agent tab where `listSessions()` was requested, a new PTY bound before the response returned, the stale response omitted the newborn id, and the renderer routed the healthy binding through exit teardown. The next gate is a Terminal Snapshot Freshness and Ownership Gate: a fake daemon holds `listSessions()` at `t0`, binds a newborn PTY at `t1`, resolves the stale `t0` snapshot without that PTY, and asserts the tab/pane survives, still owns the same PTY, accepts input, and renders output. A later post-bind snapshot may close a genuinely dead PTY, but only when provider, pane, PTY id, and binding epoch still match. -- [#6800](https://github.com/stablyai/orca/pull/6800), with [#5240](https://github.com/stablyai/orca/pull/5240), [#6411](https://github.com/stablyai/orca/pull/6411), [#6514](https://github.com/stablyai/orca/pull/6514), and [#6833](https://github.com/stablyai/orca/pull/6833) as related context: the original fix correctly proved that active worktree-sleep records should not be claimed merely because old tab/layout state exists; visible connecting panes with restorable identity can own wake, stale hidden panes fresh-resume, and completed hibernation evidence stays passive. The missed invariant was provider-session ownership, not "pane connects now." For each provider-session claim key, exactly one durable owner must exist: a live hook/status for that same provider session, a queued/sent resume command for that same provider session, or an explicit retained record because spawn/connect failed. Layout match, PTY wake hints, replayed terminal bytes, and cold-restored screen snapshots are display evidence, not ownership evidence. The next gate is an Agent Provider Session Ownership Matrix covering active focused tabs, inactive tabs, visible non-focused splits, live/quit/worktree-sleep origins, replay-only/cold-restore-only/wrong-session/no-hook/delayed-hook cases, and repeated workspace activation. -- [#5234](https://github.com/stablyai/orca/pull/5234) / [#5240](https://github.com/stablyai/orca/pull/5240) -> [#5356](https://github.com/stablyai/orca/issues/5356): the original PRs correctly proved slow-daemon restoration and newly written quit-origin provider-session persistence. The missed invariant was first-open-after-upgrade compatibility: startup/restore fixes must preserve or explicitly recover user sessions from the last affected production persisted-state schema, not only from state written by the fixed build. The escaped path was first launch after a 1.4.65 -> 1.4.68 upgrade, including Windows 10 active floating shell and Claude panes, where the old version had no `sleepingAgentSessionsByPaneKey` payload to consume. The next gate is a Persisted Session Upgrade/Restore Contract: boot current Orca against immutable user-data fixtures from the last affected version, including active and inactive tabs, visible and hidden splits, old tab-level PTY wake hints, missing `sleepingAgentSessionsByPaneKey`, live/quit/worktree-sleep/legacy origins, repeat activation, and a second restart after current code writes upgraded state. The oracle rejects blank replacement panes, duplicate resume tabs, silent session loss, and "works only after current code writes new state." -- [#1344](https://github.com/stablyai/orca/pull/1344) -> [#5319](https://github.com/stablyai/orca/issues/5319) / [#6557](https://github.com/stablyai/orca/pull/6557): the original PR correctly attempted to replace broad GPU opt-ins with VS Code-style GPU channel flags and prove terminal GPU/WebGL fallback at unit level. The missed invariant was terminal platform liveness, not visual readiness. A terminal is healthy only if it renders output, receives focus, delivers keyboard input to the correct PTY, echoes process output back to the xterm buffer, scrolls, and survives GPU/renderer startup on the target platform. The next gate is a Terminal Platform Liveness Gate across Linux Wayland GPU paths, headless Weston plus at least one scheduled/manual headful Wayland run, control Linux/macOS/Windows paths when renderer policy is shared, active and inactive tabs, visible and hidden panes, first mount, tab switch, repeated workspace activation, local PTY, and SSH/remote PTY. Screenshots alone never pass. -- [#6644](https://github.com/stablyai/orca/pull/6644) / [#6649](https://github.com/stablyai/orca/pull/6649) / [#6725](https://github.com/stablyai/orca/pull/6725) / [#6785](https://github.com/stablyai/orca/pull/6785) / [#6794](https://github.com/stablyai/orca/pull/6794) / [#6853](https://github.com/stablyai/orca/pull/6853) / [#6939](https://github.com/stablyai/orca/pull/6939): the fixes correctly proved pieces of the PTY geometry class: first-mount column desync, single-frame insufficiency, split-mount convergence, requested-size versus applied-size drift, mobile-owned PTY exceptions, visible split `0x0` fallback, and visible geometry reassertion after stable fit. The missed invariant was cross-layer terminal geometry authority. A visible desktop-owned terminal is not valid until xterm `cols/rows`, fit/proposed dimensions, applied PTY winsize, shell-visible `stty`/`stdout.columns`, and echo-wrap behavior converge after layout settles. Hidden/inactive panes may defer resize, and mobile/remote providers may intentionally diverge, but every transition into visible desktop ownership must converge. The next gate is a terminal geometry matrix with fault injection for first `0x0` fit, delayed layout settle, dropped/delayed resize acknowledgement, requested-size-versus-applied-size drift, SSH-style unknown applied size, hidden-to-visible activation, split create/collapse, restore, repeated workspace activation, and no redundant hidden SIGWINCH. -- [#6852](https://github.com/stablyai/orca/pull/6852) / [#6872](https://github.com/stablyai/orca/pull/6872) / [#6855](https://github.com/stablyai/orca/pull/6855), with [#6868](https://github.com/stablyai/orca/pull/6868), [#6856](https://github.com/stablyai/orca/pull/6856), and [#6857](https://github.com/stablyai/orca/pull/6857) as nearby WebGL/renderer-risk hardening: the fixes correctly moved toward safe wrappers for search dimensions, keyboard search navigation, link providers, WebGL addon patches, and renderer/GPU crash fallback. The missed invariant was xterm addon boundary containment. Optional addons are untrusted boundary code: search decorations, link providers, WebGL attach/reset/dispose, renderer fallback, hover callbacks, and stale async callbacks must not throw across the pane, React, or window boundary. The next gate is an xterm-addon-boundary harness that injects throwing addon behavior under active tabs, inactive tabs, visible and hidden splits, hidden-to-visible resume, floating tab switch, repeated workspace activation, and pane destroy/recreate; the oracle is pane-scoped addon disable/degrade with one breadcrumb, no React/window crash, and surviving focus, input, PTY echo, and rendering. - -Concrete gate shapes from the fresh passes: - -- **Snapshot freshness harness**: defer and reorder `listSessions()` / `listTerminals()` responses. Request a snapshot at `t0`, bind a new PTY at `t1`, resolve the `t0` response without the new PTY, and assert the pane survives with PTY id, tab ownership, and input/output proof. Then request a fresh `t2 > t1` snapshot for a genuinely killed PTY and assert teardown works. Run active/inactive tabs, visible/hidden splits, first-input probes, restore/reattach, repeat activation, and SSH/remote unknown-liveness variants. -- **Provider-session ownership matrix**: model provider-session ownership separately from terminal render output. For live, quit, active worktree-sleep, and completed worktree-sleep origins, prove each provider session is exactly one of owned, resuming, or intentionally retained. Cover active focused tabs, inactive hidden tabs, visible non-focused splits, delayed/missing/wrong hooks, duplicate legacy records, fresh-spawn failure, and repeat activation. Displayed replay must never count as ownership. -- **Old-version restore corpus**: keep immutable user-data fixtures from the last affected production versions, especially pre-#5232 session state. Boot current Orca against those fixtures and assert every persisted PTY/agent record is preserved, resumed once, warm-reattached once, or visibly marked unrecoverable. Include second restart after current Orca writes upgraded state. -- **Wayland platform liveness**: extend the Linux Wayland verifier so it proves terminal render, focus, keyboard input, PTY echo, scrollback movement, and GPU crash/stall absence across active/inactive tabs, visible/hidden splits, first mount, tab switch, restore, repeated activation, local PTY, and SSH/remote runtime. Screenshot-only evidence is not enough. -- **Terminal geometry convergence**: add an `assertTerminalGeometryConverged` oracle that compares xterm cols/rows, fit/proposed dimensions, applied PTY size when available, shell-visible `stty size` / `$COLUMNS`, and deterministic echo/wrap behavior. Inject dropped resize, delayed resize until visible, stale applied size, null SSH-style readback, initial `0x0`, and layout settling after more frames than old fixed budgets. -- **xterm addon boundary harness**: inject throwing search decorations, link providers, WebGL attach/reset/dispose, and stale async callbacks under active tab, inactive tab, visible split, hidden split, hidden-to-visible resume, floating tab switch, repeat activation, and pane destroy/recreate. Assert no `window.onerror` or React boundary hit, one structured breadcrumb, addon-scoped degradation, focus/input survival, PTY/output rendering, and WebGL fallback to DOM when needed. - -### Corrective PRs Show the Desired Direction - -The later fixes, especially [#6801](https://github.com/stablyai/orca/pull/6801), show the right shape: root-cause description, happens-before proof, explicit `auto-design-review-fix`, completeness verification, code-review loop, `brennan-test-changes`, and targeted tests for the stale snapshot/newborn binding race. [#6852](https://github.com/stablyai/orca/pull/6852), [#6853](https://github.com/stablyai/orca/pull/6853), and [#6855](https://github.com/stablyai/orca/pull/6855) also show good local invariant tests around xterm addon throws and PTY `0x0` sizing. - -The process improvement is to make that shape mandatory before the regression escapes, and to promote only the deterministic, red/green parts into blocking CI. - -## Core Pain Points - -### 1. Terminal Lifecycle Is Inferred Instead of Owned - -Many paths infer terminal state from a combination of pane visibility, provider session membership, stored pane layout, active leaf ids, sleeping-agent records, and xterm mount state. That makes it easy for a stale observation to tear down a fresh pane, or for a dead pane to look live. - -Common symptoms: - -- New tab opens and immediately closes. -- Hidden terminal exits but the visible pane later looks frozen and swallows input. -- Sleeping or hibernated agents get stranded in stale panes. -- Mobile and desktop disagree about which terminal/session still exists. - -### 2. PTY Size Authority Is Split Across Too Many Places - -`FitAddon`, `ResizeObserver`, deferred `requestAnimationFrame`, mobile-fit locks, hidden-pane guards, PTY locks, split layout equalization, and provider resize forwarding all compete to decide the active terminal size. - -Common symptoms: - -- PTY born at `0x0`. -- White split pane. -- First-mount column desync. -- TUI output wraps at the wrong width until manual resize. -- Mobile-size terminals fight desktop-size restoration. - -### 3. xterm Addons Are Unsafe Boundary Code - -The terminal surface trusts addon behavior too much. Recent failures came from search decorations, web-link providers, WebGL texture atlas resets, CJK/wide glyph rendering, and synchronized-output buffering. - -Any addon that can throw synchronously or produce invalid dimensions can take down the workbench unless Orca wraps it defensively. - -### 4. Provider Semantics Drift - -Local PTY, daemon PTY, SSH relay, remote runtime, WSL, Windows ConPTY, Linux Wayland, and mobile sessions do not share the same truth source for liveness, size, replay, cwd, shell, or reconnect behavior. - -Common symptoms: - -- Local-only liveness checks are accidentally applied to SSH/remote panes. -- Remote paths miss fixes made only in the renderer. -- WSL works through CLI add but not UI add. -- Daemon can be protocol-healthy while PTY spawn is degraded. - -### 5. Agent Identity Is Coupled to Terminal UI Details - -Agent status, sidebar rows, compact rows, mobile session tabs, sleeping-agent records, terminal helper identity, and pane reuse all share responsibility for "what agent is this?" - -Common symptoms: - -- Duplicate agent tabs. -- Stale sidebar rows after terminal close. -- Wrong agent icon/title. -- False running/completed notifications. -- Worktree switching changes agent ordering or ownership. - -Class-level invariant: - -- Agent/provider session replay must be ownership-safe. When code touches agent launch, restore, sleep, hibernate, dedupe, clearing, or workspace activation, it must prove Orca does not replay or resume a provider session id that is already owned, queued, pending, or live in that workspace. This applies across active and inactive tabs, visible and hidden panes, repeat activation of the same workspace, and session records whose origin is live, quit, or worktree sleep. -- Motivating example: the #6800 escape showed how code can conflate "this pane will connect right now" with "this pane/session owns this provider session." A valid inactive or hidden existing session can then look unowned, causing workspace activation to replay `agent resume ` instead of recognizing the session is already represented. The gate should protect the whole class, not only the #6800 path. -- Display evidence is not ownership evidence. Replayed terminal bytes, cold-restored screen snapshots, tab layout matches, and "will connect soon" state must never count as proof that a provider session id is already claimed. - -### 6. Upgrade and Startup Are Under-Tested as First-Class Flows - -Several failures only appear after an app update, daemon survival across versions, old persisted state, stale helper paths, or a first launch after migration. - -Common symptoms: - -- First launch after upgrade loses sessions. -- Daemon remains reachable but cannot spawn PTYs. -- App process runs but no window appears. -- Release packaging/notarization works at top level but fails for a nested binary. - -### 7. Reliability Fixes Can Regress Performance or Crash Safety - -Several reliability fixes add watchers, reconciliation loops, terminal replay, git scans, or richer diagnostics. Those are high-risk performance surfaces if they wake hidden panes, serialize large state, multiply subprocesses, or make cleanup scans wait for final results before showing progress. - -Common symptoms: - -- Terminal input becomes correct but slower under foreground TUI redraw or background PTY output. -- Hidden panes keep waking the renderer for data/title/spinner frames. -- Store subscribers or persistence writes serialize large terminal/worktree maps on common interactions. -- Git/worktree cleanup opens a modal that spins, duplicates rows, hangs on a subprocess, or crashes on Windows/WSL/SSH paths. -- Safety checks run unbounded git subprocesses or trust stale cleanup evidence before destructive removal. - -## Proposed Improvements - -### A. Build a Terminal Lifecycle State Machine - -Create a single source of truth for terminal lifecycle: - -- `newborn` -- `connecting` -- `live` -- `hidden-live` -- `exited-observed` -- `exited-unobserved` -- `hibernated` -- `restoring` -- `degraded` -- `closed` - -All teardown, restore, reconnect, hibernation, and liveness decisions should transition through this machine. Avoid having individual React effects independently infer lifecycle from partial state. - -### B. Make Liveness Reconciliation Epoch-Based - -Every pane/PTY binding should carry a monotonic renderer-local epoch or generation. Any async liveness snapshot must include the epoch it was requested against. - -Rules: - -- A snapshot requested before a bind cannot close that bind. -- A provider membership result can only prove death when the current binding still exactly matches the request context: pane id, provider id, connection id, PTY id, and epoch. -- Remote/SSH/WSL providers must supply provider-scoped liveness, or the reconciler must treat them as unknown. - -This generalizes the timestamp guard added for newborn terminal tabs and makes it harder to reintroduce the class. - -### C. Define a PTY Size Ownership Protocol - -Document and enforce one protocol for size authority: - -- Initial spawn size source. -- Hidden-pane spawn behavior. -- Mobile-fit lock behavior. -- Split layout settle behavior. -- Fallback size behavior. -- Provider resize acknowledgement. -- Applied PTY size reporting. -- Dropped, delayed, or skipped resize recovery. -- Hand-off from reconcile loop to live `ResizeObserver`. - -Add invariant tests that a visible terminal never remains `0x0`, and that xterm `cols/rows` converge with PTY-reported `cols/rows` under split, restore, reload, and mobile-fit scenarios. - -Class-level invariant: - -- A visible terminal pane must have one authoritative size path from layout measurement to xterm fit to provider resize to applied PTY size. `0x0` and unknown layout measurements are non-authoritative for visible panes. If a resize is skipped because of a mobile lock, PTY lock, hidden state, split layout settle, first mount, provider delay, or dropped acknowledgement, Orca must keep reconciling until the applied PTY size and xterm size converge or the pane enters an explicit degraded state. - -Required deterministic hooks: - -- Force first measurement to `0x0` during split-right spawn. -- Delay split layout settlement across frames. -- Hold and release mobile-fit or PTY locks while reconcile is active. -- Drop or delay one provider resize acknowledgement. -- Report applied PTY size separately from requested size. -- Switch hidden-to-visible or worktree-to-worktree while a resize is pending. - -Required oracle: - -- After the fault, assert visible xterm `cols/rows`, provider-reported applied PTY `cols/rows`, and process-observed `stdout.columns/rows` converge. -- Assert a width-sensitive TUI or long-line fixture renders without one-column wrapping. -- Assert typed input reaches the process and echoed output appears after convergence. -- Assert no test is promoted to blocking if it relies on blind `waitForTimeout()` instead of size/lifecycle events or controlled fault release. - -### D. Add Provider Contract Tests - -Start with a deterministic fake/fault-injection provider, then run the same lifecycle contract against real providers. - -The fake provider should be able to: - -- Delay and reorder `listSessions()` responses. -- Return `unknown` separately from empty/live/dead. -- Delay attach/replay and resize acknowledgements. -- Kill processes while panes are hidden. -- Drop or duplicate exit/replay/resize events. -- Simulate stale cwd/helper paths. - -After the fake provider is stable, run the contract against: - -- local PTY -- daemon PTY -- degraded daemon wrapper -- SSH relay -- remote runtime -- WSL path/shell combinations -- mobile session replay - -Required scenarios: - -- spawn -- hidden spawn -- visible restore -- hidden exit -- reconnect -- resize before/after mount -- cwd deleted -- helper binary moved after upgrade -- process dies while renderer is hidden - -### E. Create a Terminal Torture Harness - -Add a nightly and pre-release reliability suite that exercises: - -- rapid tab create/close -- agent create/close -- split right/down -- reload with split layout -- hidden-to-visible switching -- huge streaming TUI output -- CJK, Vietnamese, Arabic, and emoji input/output -- long paste, link paste, image paste -- browser/terminal focus handoffs -- hibernation wake -- daemon restart/degraded fallback -- mobile connect/disconnect during active terminal traffic - -This should record screenshots, terminal snapshots, PTY sizes, lifecycle transitions, and renderer CPU. - -Each scenario must also declare its oracle before it can be promoted beyond `experimental`: the observable pass condition, the failure artifact, and whether it is a discovery/stress run or a merge-blocking gate. Stress scenarios without deterministic oracles should stay nightly/pre-release only. - -### F. Wrap xterm Addons Behind Safe Adapters - -Search, links, WebGL, decorations, and input-protocol helpers should be treated as untrusted code: - -- Catch synchronous addon throws. -- Validate all rows/cols/decoration dimensions before calling xterm APIs. -- Disable or degrade an addon for a pane after repeated failures. -- Emit a structured breadcrumb with pane id, provider, terminal size, addon name, and sanitized error. - -Class-level invariant: - -- xterm addon failures must be contained to the addon and pane. A search, link, WebGL, decoration, input-protocol, or keyboard-navigation error must never unmount the terminal surface, crash the renderer, break focus, or stop typed input/output for the PTY. Invalid dimensions, wrapped-line ranges, zero columns, texture atlas resets, and synchronous callback throws should either be validated away before the addon call or caught and downgraded after the call. - -Required deterministic hooks: - -- Force zero or near-zero terminal columns. -- Force wrapped search and link ranges across line boundaries. -- Throw from search, link, decoration, and WebGL callback boundaries. -- Simulate WebGL texture/atlas reset or addon initialization failure. -- Invoke next/previous search navigation while the search addon reports an invalid match. - -Required oracle: - -- The terminal pane remains mounted and focused. -- The failing addon is disabled or downgraded only for the affected pane. -- A structured breadcrumb names the addon and sanitized failure. -- Typed input reaches the process and echoed output appears after the addon failure. - -### G. Add Upgrade Fixtures - -Keep serialized workspaces and daemon/session state from known old versions: - -- pre-#5232 session restore data -- stale daemon cwd -- stale node-pty helper path -- old hibernated agent records -- old mobile session snapshots -- old Windows shell settings - -CI should boot the current app against these fixtures and assert no session loss, no blank first pane, and no startup deadlock. - -Fixture rules: - -- Name the exact production version and build source. -- Store the pre-migration state immutably and copy it to a temp profile before boot. -- Document the expected pre-migration shape and post-migration behavior. -- Include stale daemon/helper/path state when that is the real user upgrade path. -- Treat silent blank replacement as a failure. If exact restoration is impossible, the accepted outcome must be an explicit recoverable/degraded state or user-facing migration notice, not silent session loss. - -### H. Add Platform Startup, Input, and Release Artifact Contracts - -Some failures look visually healthy while the product is broken. Platform contracts must prove the user action, not only the frame. - -Required contracts: - -- Terminal rendering/input: on Linux Wayland, Windows ConPTY, and macOS, assert the terminal renders, accepts typed input, echoes process output, scrolls, and preserves focus after GPU/renderer startup paths touched by the PR. -- IME/input: when terminal input handling changes, assert representative CJK, Vietnamese, Arabic, paste, and modifier paths at the lowest deterministic layer available. -- Startup/window lifecycle: packaged app reveals, close button and Alt+F4 work on Windows, startup watchdogs recover when `ready-to-show` stalls, and PATH/shell probes time out with a recoverable state. -- Release artifact: packaged builds include required nested binaries and pass signing/notarization checks, not only top-level app validation. -- Daemon degradation: daemon reachable-but-unable-to-spawn and stale helper/cwd states produce a visible degraded path instead of blank or frozen terminals. - -### I. Add an Agent Identity Contract - -Provider and terminal tests are not enough for agent/session identity drift. Add a contract for: - -- Pane reuse and tab close. -- Worktree switch and sidebar ordering. -- Sleep/wake and hibernation records. -- Provider session id capture and consumption. -- Mobile rows and desktop rows converging after terminal close. -- False running/completed notifications. -- Agent history/filter visibility for restored sessions. -- Provider session replay ownership: workspace activation, launch, restore, sleep/hibernate, dedupe, clearing, and reconnect code must not issue a second resume/launch for a provider session id already owned by an active tab, inactive tab, visible pane, hidden pane, queued pane, pending connection, or live session in the same workspace. -- Session record origins: the same invariant must be proved for live sessions, quit-restored sessions, and worktree-sleep records, including repeat activation of an already-active workspace. - -Required agent identity scenarios: - -- Activate a workspace with an existing visible live agent pane; assert no duplicate `resume ` is queued. -- Activate a workspace with an existing hidden/inactive live agent pane; assert the provider session is recognized as represented even before the pane visibly reconnects. -- Re-activate the same workspace repeatedly while agent restore/session discovery is pending; assert the pending ownership claim dedupes all replay attempts. -- Restore a quit-origin session and a worktree-sleep-origin session; assert each is claimed once, survives hidden-to-visible transitions, and does not produce duplicate sidebar/mobile rows. -- Clear or close stale panes while a same-session replacement is queued or pending; assert cleanup cannot erase the newer ownership claim or trigger a second replay. - -### J. Introduce Reliability Gates for Terminal/Session PRs - -For PRs touching terminal, agent status, sessions, startup, provider routing, or hibernation, require: - -- Provider matrix explicitly listed. -- Hidden-pane behavior listed. -- SSH/remote behavior listed. -- Windows/WSL behavior listed when shell, cwd, path, ConPTY, or process launch is touched. -- A regression test that fails against the old behavior. -- A manual or automated stress command if the change touches timing. -- PTY size authority proof when the PR touches terminal spawn, split layout, hidden/visible transitions, mobile fit, resize forwarding, PTY locks, provider resize acknowledgement, or xterm fit. The proof must cover `0x0` quarantine, requested versus applied PTY size, xterm/process size convergence, and a user-visible input/output oracle. -- Agent/provider replay ownership proof when the PR touches agent launch, restore, sleep/hibernate, dedupe, clearing, workspace activation, pane reuse, provider session id capture, sidebar/mobile agent rows, or session history. The proof must cover active versus inactive tabs, visible versus hidden panes, repeat workspace activation, and live, quit, and worktree-sleep session record origins. -- xterm addon containment proof when the PR touches search, links, WebGL, decorations, keyboard terminal shortcuts, synchronized output, or xterm addon registration. The proof must show addon throws and invalid geometry cannot crash the terminal surface, unmount the pane, or break typed input/output. -- The terminal/session checklist should explicitly ask: "Can this change make an already represented provider session look unowned, or replay/resume it a second time?" -- The terminal/session checklist should explicitly ask: "Can this change make a visible pane trust `0x0`, stale requested size, or renderer-only size instead of the size the PTY actually applied?" -- The terminal/session checklist should explicitly ask: "Can an xterm addon callback or invalid addon geometry throw through the terminal surface instead of being contained?" - -### K. Make Reliability Tests Predictable and Useful - -The goal is not "more tests." The goal is a small set of executable contracts that protect named invariants from real failures. - -Required shape for every new reliability gate: - -- Name the invariant it protects. -- Name the real issue, PR, or failure mode that motivated it. -- Prove red/green: the gate must fail against the old behavior, a regression fixture, or a small intentionally broken variant before it becomes a trusted blocker. -- Attach a promotion record: invariant id, motivating issue/PR, old-fail evidence, new-pass evidence, exact command, failure output or CI link, deterministic wait condition, owner, runtime budget, and demotion rule. -- Pick the cheapest deterministic layer: pure state-machine or provider-contract tests first, integration tests second, Electron/UI tests only for the small number of golden paths that require the real app shell. -- Avoid sleeps as proof. Wait for observable state, protocol events, lifecycle transitions, PTY size convergence, replay completion, or process exit. -- Record why the test is allowed to block merges: expected runtime, flake history, covered invariant, and owner. - -Test maturity levels: - -- `experimental`: local or nightly only; useful for shaking out the harness, not allowed to block merges. -- `soak`: runs repeatedly in CI but non-blocking; collects flake rate, runtime, and whether failures are actionable. -- `blocking`: promoted only after red/green proof, stable runtime, and low flake rate. - -Flake policy: - -- Blocking promotion should require numeric evidence, for example 100 consecutive soak runs or 14 days across required CI platforms, p95 runtime within the gate budget, and zero unexplained flakes. Tune the numbers later, but do not leave "stable" undefined. -- A blocking reliability test that flakes above the agreed threshold should be demoted or fixed quickly; rerun-only culture hides weak gates. -- CI should quarantine or demote a blocking test automatically when it exceeds the threshold over the configured window, open or update an owner issue, and require a fresh soak window before re-promotion. -- A flaky test should be treated as a product or harness bug with an owner, not as background noise. -- If a scenario cannot be made deterministic yet, keep it as soak/manual evidence and do not let it become required CI until the signal is trustworthy. - -Deletion policy: - -- Delete or demote tests that do not map to an invariant, duplicate a stronger gate, assert implementation details instead of behavior, only fail from timing/environment noise, or require blind sleeps. -- Prefer one high-signal contract test over several UI tests that all exercise the same happy path. -- Track regression coverage by invariant, not by raw test count. -- Maintain a reliability-gates manifest in the repo. Each gate should list invariant id, motivating issue/PR, layer, owner, maturity, runtime budget, flake history link, promotion record, and replacement gate if deprecated. Tests without current metadata should fail review or be demoted. - -How to know the program is working: - -- Every recent P0/P1 reliability class maps to an invariant and an executable gate or explicitly accepted gap. -- New gates carry red/green evidence. -- Blocking gate flake rate and runtime stay within budget. -- Future incidents identify either a missing invariant, a failed gate, or an explicit accepted gap instead of requiring archaeology. -- Accepted gaps have an owner, reason, affected platforms/providers, issue link, and expiry date. Expired gaps should block future high-risk PRs until renewed or closed. -- The dashboard tracks touched high-risk PRs, gates required, gates implemented, accepted gaps by age/owner, promotions, demotions, and escaped regressions by invariant. - -### L. Make Performance and Git Crash Safety First-Class Gates - -Correctness gates must include a performance budget when the fix touches terminal throughput, hidden panes, store subscribers, persistence, startup, polling, git/worktree scans, cleanup modals, subprocesses, SSH/WSL providers, or Windows git paths. - -Required performance contract: - -- Name the user-visible performance symptom that could regress: typing latency, scroll/resize jank, terminal throughput, startup delay, modal loading, CPU, memory, subprocess churn, or crash/reload. -- Name the resource being protected: renderer frame time, event-loop delay, PTY IPC count, xterm writes, serialized bytes, subprocess count, file descriptors, WebGL contexts, webviews, or retained buffers. -- Add a before/after measurement or deterministic count/leak test. Do not rely on "felt faster." -- Keep foreground input echo and control responses latency-sensitive while batching background or high-volume redraw streams. -- Prove hidden snapshot-capable panes do not wake the renderer per PTY chunk/title frame and restore from main-owned buffers when shown. -- Preserve `orca terminal read` pagination, cursor metadata, bounded preview behavior, partial-line handling, truncation flags, and total counts when changing terminal buffering. -- Prove subscriber, persistence, and publication paths avoid unchanged large slices and record serialized byte size when persisted payloads are touched. -- Keep stress/torture runs non-blocking until they have numeric runtime and flake evidence. - -Required git crash/perf contract: - -- Bound every git subprocess in scan/cleanup paths with a timeout or abort signal, and kill the process tree where applicable. -- Use bounded concurrency and in-flight dedupe; avoid one subprocess per repo x worktree x polling tick. -- Stream progress incrementally to the renderer and remove IPC listeners in `finally`. -- Verify close/reopen/refresh during active or just-settled scans clears loading state, preserves rows, and does not duplicate progress. -- Recheck cleanup candidates immediately before destructive delete, including dirty files, unpushed commits, unknown upstream/base, folder repos, main worktrees, pinned workspaces, running terminals, disconnected SSH, and WSL paths. -- Keep local, WSL, and SSH git providers at parity for timeouts, aborts, errors, and path handling. -- On Windows-risky cleanup changes, validate a real Electron flow when feasible: progress appears before final completion, close/reopen recovers, a clean old worktree delete does not crash, and no orphan Electron/git process remains. - -Promotion rule: - -- A reliability fix cannot become a blocking gate if it makes the product slower or crashier. The promotion record must include the perf command or measurement, budget, result, platform/provider scope, and any accepted perf gap. - -### M. Make the Factory Enforce Those Gates - -The factory should turn the reliability gate into default behavior for planning, review, and merge-confidence testing. This should be treated as part of the prevention plan, not as a substitute for the actual test harnesses and CI gates above. - -Factory changes to carry forward: - -- `brennan-yolo-lite` should require a Reliability regression gate in the design doc whenever terminal, tab, session, provider, startup, upgrade, persistence, packaging, shell, path, or release behavior is touched. -- The gate should force the agent to classify touched P0-capable surfaces: terminal lifecycle, tab creation/binding, dead-session reconciliation, hidden-to-visible resume, PTY identity/routing, PTY geometry including `0x0` quarantine, xterm addon boundaries, IME/paste/redraw behavior, local/daemon/SSH/remote/WSL/mobile provider contracts, attach/replay/resize/exit ordering, startup/upgrade persisted state, daemon survival, renderer/GPU crash containment, packaging/notarization, shell/PATH probing, terminal throughput, hidden-pane output, store subscriber hot paths, persistence payload size, git/worktree scan subprocess churn, cleanup modal progress, and Windows/WSL/SSH git crash risk. -- Completeness verification should block when a touched P0-capable surface has no implemented or explicitly accepted reliability gate. -- `review-code` should treat missing reliability tests or validation gates as findings for these surfaces, with the same review weight as lifecycle, provider, or cross-platform regressions. -- `review-code` should invoke the `perf` skill for slow UI, terminal throughput, high CPU, memory/resource leaks, polling, subprocess churn, startup latency, render churn, or persistence writes. -- `review-code` should invoke the `git-crash-perf` skill for worktree cleanup, git scans/status/listing, destructive removal, cleanup modal progress, SSH/WSL git providers, subprocess timeouts, or Windows git crash risk. -- `brennan-test-changes` should run or explicitly justify the relevant terminal torture, provider contract, startup/upgrade, persisted-state, release, cross-platform, perf, or git crash/perf matrix subset. - -Current implementation status: - -- Internal factory skill update PR: https://github.com/stablyai/orca-internal/pull/491 -- Scope of that PR: add the reliability gate to `brennan-yolo-lite` and expand `review-code` to inspect the recent terminal/session/provider/startup/release failure classes. -- Follow-up needed: once the concrete repo-side harnesses exist, update the skills again to name the exact commands and required CI jobs instead of only naming the expected reliability evidence. - -Additional factory follow-up: - -- `auto-design-review-fix`: treat missing reliability gates for P0-capable surfaces as design findings; require inverse invariants for cleanup/reconcile/init code. -- `review-code-fix-loop`: require the final loop report to name the touched reliability classes, invariant, gate, and whether both reviewers addressed them. A clean review that never discusses the required reliability class should not count as clean. -- `brennan-test-changes`: add merge-confidence sub-gates for stale snapshot/newborn binding races, hidden-to-visible resume, attach/replay/exit ordering, old-version persisted state, provider/platform matrices, and render-live/input-live checks. -- `pr-review`: classify P0-capable surfaces before review/fix and require the same invariant/test/platform matrix before approval. -- `yolo-lite` and `electron`: require terminal/runtime validation to prove input, scroll, focus, process output, and relevant platform behavior, not only screenshots or visible frames. -- `playwright-reliability-tests`: add a focused skill for writing and reviewing Playwright tests that are evidence-based, low-flake, and tied to named invariants, so browser/Electron tests do not become more noisy checklist coverage. -- `perf`: require measurement-backed review for terminal throughput, hidden pane output, store subscribers, persistence, startup, polling, subprocess churn, resource leaks, and memory growth. -- `git-crash-perf`: require bounded subprocesses, progress streaming, close/reopen recovery, fresh delete preflight, provider parity, and Windows Electron validation for git cleanup/worktree changes. - -### N. Add Pane Lifecycle Telemetry and Diagnostics - -Each terminal pane should be able to emit a compact lifecycle trace: - -- pane id, tab id, worktree id -- provider and connection id -- pty id and binding epoch -- visibility transitions -- spawn/connect timestamps -- resize measurements and forwarded sizes -- liveness snapshot request/response times -- teardown reason -- hibernation/restore reason - -This will make future reports much easier to attribute without guessing. It should also be test-consumable: CI should be able to fail on forbidden transitions, stale close attempts, stuck `0x0`, missing input echo, and startup watchdog expiry. - -## Attribution Methodology - -"Who caused it?" is not reliably answerable from labels alone. This review separates: - -- **Confirmed**: the issue body or fix PR explicitly names the introducing PR/change. -- **Likely**: the fix PR identifies the failing subsystem and local git history shows the regressing mechanism was introduced by a specific PR. -- **Area ownership**: the same people are repeatedly fixing or modifying the subsystem, but there is not enough evidence to say they caused a given issue. -- **Systemic**: no single introducer is evident; the problem is a missing invariant, provider contract, or test harness. - -## Attribution Findings - -### Confirmed Regression Chains - -- [#5356](https://github.com/stablyai/orca/issues/5356), session loss on first launch after upgrading from 1.4.65 to 1.4.68, explicitly names PRs [#5234](https://github.com/stablyai/orca/pull/5234) and [#5240](https://github.com/stablyai/orca/pull/5240). Local git history shows both PRs were authored by Jinwoo Hong. Count: Jinwoo 1 confirmed open `P0` regression chain. -- [#5319](https://github.com/stablyai/orca/issues/5319), Linux/Wayland terminal input freeze, is closed, but the fixing PR [#6557](https://github.com/stablyai/orca/pull/6557) says the Wayland-aware GPU path had been removed in [#1344](https://github.com/stablyai/orca/pull/1344). Local git history shows #1344 was authored by Neil. Count: Neil 1 confirmed closed `P1` regression chain. - -### Likely Regression Chains - -- [#6773](https://github.com/stablyai/orca/issues/6773), new terminal/agent tabs opening and immediately closing, was fixed by [#6796](https://github.com/stablyai/orca/pull/6796) and [#6801](https://github.com/stablyai/orca/pull/6801). Those PRs identify the dead-session reconciler as the cause. Local git history shows [#6514](https://github.com/stablyai/orca/pull/6514), which introduced the focused dead-session reconcile module for hidden exited panes, was authored by Brennan Benson. Count: Brennan 1 likely open `P0` regression chain. - -### Area Ownership Without Clear Causation - -The recent terminal reliability churn is concentrated among Brennan, Neil, and Jinwoo because they authored many of the fixes and nearby changes. That does not mean each issue was caused by them. It does mean the riskiest ownership surfaces are: - -- Terminal lifecycle/reconcile/session persistence: Brennan, Neil, Jinwoo. -- PTY size/layout/reflow: Neil, Jinjing, Jinwoo, Brennan. -- Daemon/degraded spawn/focus/remote lifecycle: Neil, Jinwoo. -- Windows terminal/shell/ConPTY/GPU recovery: Brennan, Neil, Jinwoo. -- Agent status/sidebar/session identity: Brennan, Neil, Jinwoo, Jinjing. - -### Current Conservative Count - -For currently open `P0`/`P1` issues with a clear or likely PR-caused chain: - -- Jinwoo: 1 confirmed open `P0` regression chain. -- Brennan: 1 likely open `P0` regression chain. -- Neil: 0 confirmed open `P0`/`P1` chains from the evidence pulled, plus 1 confirmed closed `P1` chain. - -Most open `P0`/`P1` issues do not identify an introducing PR. Treating those as individual blame would be misleading. The stronger conclusion is that Orca has systemic reliability gaps around terminal/session lifecycle, provider contracts, PTY sizing, and upgrade/startup flows. - -## Recommended Next Steps - -Implementation plan: [`docs/reference/reliability-gates-implementation-plan.md`](./reliability-gates-implementation-plan.md). - -1. Deduplicate and update `P0` issues #6873/#6874 and #6773 with the suspected fix status. -2. Add narrow red/green gates for known escapes first: newborn liveness race, PTY `0x0`, xterm addon throw containment, old-version upgrade fixture, and Linux Wayland render-live/input-live smoke. -3. Define test maturity levels, promotion records, numeric flake/demotion policy, and a reliability-gates manifest before making new reliability gates blocking. -4. Build the fake/fault-injection provider contract kit, then run the same contract against local and daemon PTY before expanding to SSH, WSL, remote runtime, and mobile. -5. Add upgrade/startup/release fixtures for pre-#5232 session data, stale daemon/helper paths, packaged app reveal/close, nested notarization, PATH probe timeout, and Windows terminal activation. -6. Add the terminal lifecycle state machine design doc and implement it incrementally behind the known escaped-regression gates. -7. Formalize the PTY size protocol and make `0x0` a quarantined/non-authoritative state for visible panes. -8. Add the agent identity contract for pane reuse, worktree switch, sidebar/mobile rows, provider session ids, stale notifications, and ownership-safe provider session replay across active/inactive tabs, visible/hidden panes, repeated activation, and live/quit/worktree-sleep records. -9. Add performance budgets and measurements to reliability gates that touch terminal throughput, hidden panes, store subscribers, persistence, startup, polling, subprocess churn, git/worktree scans, cleanup modals, SSH/WSL git providers, or Windows git crash risk. -10. Gate terminal/session PRs behind the reliability checklist above, including perf and git crash/perf checks when those surfaces are touched. -11. Keep the factory skill update as the enforcement layer, add the Playwright reliability-testing skill, and make `perf` plus `git-crash-perf` mandatory companion skills for relevant PRs; then revise companion skills once exact repo commands and CI job names exist. -12. Add pane lifecycle traces so future issues can be attributed from evidence instead of archaeology. diff --git a/docs/reference/ssh-gssapi-kerberos.md b/docs/reference/ssh-gssapi-kerberos.md deleted file mode 100644 index e7594151667..00000000000 --- a/docs/reference/ssh-gssapi-kerberos.md +++ /dev/null @@ -1,39 +0,0 @@ -# Kerberos / GSSAPI SSH Authentication - -Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library -has no GSSAPI userauth support, and adding it would mean forking ssh2's -protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal / -Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead, -hosts that need Kerberos ride the existing **system OpenSSH transport** — the -same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts — -which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon), -and credential delegation to the platform's own `ssh` binary on macOS, Linux, -and Windows (Win32-OpenSSH uses SSPI). - -## Transport selection - -Two tiers, deliberately asymmetric because RHEL-family distros ship -`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes -`ssh -G` report GSSAPI enabled for *every* host: - -1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an - explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or - set on the target directly) tries the system-ssh probe first. If that fails - (e.g. no ticket), the connect falls through to the normal ssh2 key/agent - path where passphrase/password prompts remain available — OpenSSH semantics - allow other auth methods alongside GSSAPI. -2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the - `ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate` - in `ssh-connection-utils.ts`), the connection retries over system ssh - *before* prompting the user for credentials. Kerberos-only hosts on - distro-default configs connect this way; hosts where keys work never leave - the ssh2 path. - -Manual (non-ssh-config) targets flagged for GSSAPI get an explicit -`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets -inherit the option from their own `Host` block since the system binary re-reads -ssh_config. - -Both tiers work headless (`orca serve`): the system-ssh probe needs no -credential callbacks, and GSSAPI itself is non-interactive once a ticket -exists. diff --git a/docs/reference/ssh-typing-latency-under-relay-load.md b/docs/reference/ssh-typing-latency-under-relay-load.md deleted file mode 100644 index f7ae8e0b3a7..00000000000 --- a/docs/reference/ssh-typing-latency-under-relay-load.md +++ /dev/null @@ -1,115 +0,0 @@ -# SSH Typing Latency Under Relay Load - -Date: 2026-07-06 - -## Symptom - -Typing in an SSH-host terminal can become extremely slow (hundreds of ms to -seconds per echoed keystroke) while other SSH work is active — file previews, -source-control refresh, search. A quiet SSH shell stays snappy, which made the -reports look unreproducible. - -This is distinct from the local warm-switch lag documented in -`terminal-switch-typing-lag-investigation.md` (daemon `listSessions()` -snapshot cost — already fixed; `TerminalHost.listSessions()` is now -metadata-only via `getAppliedSize()`). - -## Root Cause - -The relay and the Electron client share ONE ordered SSH channel for all -JSON-RPC traffic: PTY input/output, file streams, git responses, search, port -scans. Two mechanisms turned bulk traffic into typing latency: - -1. **Relay-side head-of-line blocking (primary).** The `fs.readFileStream` - pump (`src/relay/fs-handler-file-read.ts`) wrote every 256KB chunk (~340KB - framed) into the relay's stdout as fast as local disk reads completed, - ignoring the `write() === false` backpressure signal. A 10MB preview - enqueued ~13.6MB into the pipe at once; a `pty.data` echo emitted - mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds - of echo delay per open file. The reproduced measurement: 4,195,592 bytes - (the entire remaining 3MB test file, framed) queued ahead of one echo - frame. - -2. **Client-side O(n²) frame buffering (secondary).** `FrameDecoder.feed()` - in both `src/main/ssh/relay-protocol.ts` and `src/relay/protocol.ts` did - `Buffer.concat([buffered, chunk])` per data event, re-copying the whole - backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame, - ~80MB per 10MB file — on the Electron main thread, delaying `pty:write` - IPC dispatch and echo delivery to the renderer. - -## Fix - -- **Bulk lane with sink backpressure** (`RelayDispatcher.notifyBulk`): - `fs.streamChunk` frames are serialized per client and each send waits for - the sink's `drain` when `write()` returns `false`. Interactive frames - (`pty.data`) still use plain `notify()` and are admitted immediately — - because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps - ahead of every not-yet-admitted chunk. Sinks (`relay.ts` stdout + Unix - socket clients) surface `write()`'s boolean and a one-shot - `waitWriteDrain`; every death path (EPIPE, stdin end, detach, dispose) - flushes parked waiters so a pump can never hang on a dead pipe. - -- **Credit-based flow control** (`fs.streamAck`): the client requests - streams with `flowControl: 'ack'` and acks each processed chunk; the relay - caps unacked chunks at `STREAM_ACK_WINDOW_CHUNKS = 4` (~1MB raw). This - bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP - buffers) and paces the relay's base64/JSON encode loop so incoming - keystroke frames get event-loop turns. A parked pump wakes on ack, cancel, - release, client detach, and a 1s staleness recheck. - -- **Cross-version compatibility**: old client + new relay → no - `flowControl` param → legacy unpaced pump (still drain-bounded, which is - transparent). New client + old relay → `fs.streamAck` is an unknown - notification and is silently ignored. `STREAM_CHUNK_SIZE` (256KB) is - intentionally unchanged — both sides bake it into chunk offset math, so - changing it would corrupt cross-version streams. - -- **FrameDecoder rewrite** (both mirrored protocol files): chunk-list - buffering; each frame is assembled exactly once (one copy) instead of - re-concatenating the backlog per feed. - -## What Stays Fast / Unchanged - -- Relay PTY output batching (interactive echo fast path in - `src/relay/pty-handler.ts`) is untouched. -- No `listSessions()` / provider inventory was added anywhere near typing, - focus, switch, resume, resize, or render paths. -- Remote file streaming correctness: per-chunk length checks, chunk-count and - byte-count invariants, cancel paths, and the 12MB round-trip integration - test all pass unchanged. - -## Regression Coverage - -- `src/relay/fs-stream-pty-echo-backpressure.integration.test.ts` — real - mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts - deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed - chunks when congested (pre-fix: whole file), ack window caps in-flight - chunks, legacy no-ack clients still get complete streams. -- `src/relay/fs-handler-stream.test.ts` — pump parks at the ack window, - resumes per ack, and releases its file handle when cancelled while parked. -- `src/relay/dispatcher.test.ts` — notifyBulk semantics: drain gating, - per-client targeting, dispose releases parked senders, interactive - notify() not gated behind a stalled bulk lane. -- `src/main/ssh/relay-protocol.test.ts` — decoder byte-at-a-time boundary - straddling, oversized-frame resync across odd chunk sizes, and a - no-`Buffer.concat`-during-feed guard that locks out the O(n²) shape. -- `tests/e2e/ssh-docker-relay-perf.spec.ts` — new "busy relay" scenario: - types while two 8MB remote file-read loops and a git.status loop run; - budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix - HOL is only tens of ms — the in-process byte-bound test is the - authoritative red/green; the e2e guards the end-to-end wiring.) - -## Residual Gaps - -- **Large single-frame responses**: `git.*` responses (stdout capped at - `MAX_GIT_BUFFER` = 10MB → ~13MB framed) and other request/response results - are one atomic frame on the wire; a huge diff can still delay an echo by - its own transfer time. Fixing this requires response chunking (a protocol - change); bounded by the 16MB `MAX_MESSAGE_SIZE`. -- **Outbound direction**: a large `fs.writeFile` request frame (client → - relay) can queue ahead of keystroke `pty.data` frames on `channel.stdin`. - Same shape, opposite direction, rarer trigger (saving a large remote file - while typing). -- `pty.ackData` flow control for PTY output remains unenforced - (`src/relay/pty-handler.ts` — "not yet enforced"); PTY output floods are - already paced by the relay's 8ms batch/16KB slice scheduler. diff --git a/docs/reference/telemetry-availability.md b/docs/reference/telemetry-availability.md deleted file mode 100644 index e6d4924d699..00000000000 --- a/docs/reference/telemetry-availability.md +++ /dev/null @@ -1,393 +0,0 @@ -# Telemetry Availability - -Append-only reference for dashboard authors. Use this file to answer: "From what date is this event or property trustworthy enough to chart?" - -This file records operational availability. Event contracts still live in `src/shared/telemetry-events.ts`, and dashboard/query examples live with the dashboard work that introduced them. - -## How To Use - -- Prefer `dashboard_ready_at_utc` as the lower bound for saved PostHog retention, funnel, and cohort tiles. -- If `dashboard_ready_at_utc` is `TBD`, do not save production cohort or retention tiles. QA/exploratory charts may proceed only after checking first-seen evidence, and their descriptions must say the boundary is not final. -- Use the latest required first-valid timestamp when a dashboard depends on multiple signals. An earlier timestamp may prove one event exists while a join key or depth field is still absent, which silently biases cohorts. -- Do not save a cohort chart from one isolated first-seen row. -- Mention rollout boundaries in saved insight descriptions when querying across pre-rollout data. -- Do not add these timestamps to telemetry payloads. They are repo-side interpretation metadata. - -## Boundary Fields - -| Field | Meaning | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `code_merged_at_utc` | When the implementation reached `main`. Useful for source history only. | -| `first_released_at_utc` | First release commit/build that contains the code. This is the earliest possible user exposure. | -| `first_seen_at_utc` | First observed PostHog row for the event/property. This proves at least one event flowed. | -| `dashboard_ready_at_utc` | Earliest lower bound for saved dashboards after all required signals have first-valid rows and pass any validation window. | - -First-valid means the released app version has been observed, the required event/property fields are populated, joins used by the dashboard are available, and any validation window listed in the rollout entry has passed. - -D1+/D3+/D7+ retention means the user fired `app_opened` at least once after 24/72/168 hours from cohort start, matching the existing dashboards' mature-denominator logic. Do not read D3/D7 as exactly-on-day returns, and do not include users whose cohort start is too recent to satisfy the relevant window. - -## Cross-Cutting Rules - -- `app_opened` is the return marker. It fires once per app session after telemetry consent/gating resolves. -- `nth_repo_added = 0` means the user had no repos at emit time. It is not a missing-value sentinel. -- Missing `nth_repo_added` means the classifier failed soft or the event predates the property. Do not bucket it with `0`. -- Main injects `nth_repo_added` only into schemas that declare the field, and injects onboarding `cohort` only into schemas that declare `cohort`. -- `onboarding_started { cohort: 'fresh_install' }` is the fresh-install anchor. `upgrade_backfill` rows should not be mixed into new-user onboarding funnels. -- Classify a user's fresh-install onboarding cohort from the first `onboarding_started { cohort: 'fresh_install' }` and carry that forward. Existing-user classification can flip after live completion is persisted, so later event-level `cohort` values are not a replacement for the start anchor. -- Numeric onboarding step values are rollout-specific. Prefer `value_kind` when the relevant event/property is available. -- Current `onboarding_step_skipped` means an optional preference/setup step was skipped toward required project setup. It does not mean the user abandoned repo setup. -- `source = 'unknown'` is not a real product surface. It means the caller omitted a source or the value failed schema validation. -- `workspace_created` means create-worktree IPC succeeded. It is not a general "usable workspace exists" or "workspace revealed" marker. -- `add_repo_setup_step_action` is a historical post-add choice-screen event. Current Add Project flows auto-open the default checkout or reveal the project row, so absence of this event after the default-checkout handoff rollout is expected and should not be read as setup abandonment. -- `add_repo_existing_workspaces_detected` is a detection signal for migration opportunity, not proof that a user chose an existing workspace. Current Add Project flows may emit it before automatically opening the default checkout. -- `add_repo_default_checkout_handoff.result = 'revealed_project'` means Orca could not confidently open the default checkout and instead revealed the newly added project row. It is the direct fallback metric for the 2026-06-03 Add Project handoff rollout. -- `agent_started` means PTY spawn succeeded with agent telemetry attached. It is not first-repo activation and does not prove the user sent a prompt. -- `agent_prompt_sent` means a live agent hook observed an explicit non-empty user prompt. It excludes hydrated/replayed status, agent auto-start, bare shells, draft prefill, and hookless sessions; missing rows mean no hook-confirmed interaction was observed, not proof the user never typed. -- Workspace-outcome joins are native Electron coverage unless the query explicitly proves remote/web instrumentation. Remote runtime and web paths can bypass native repo/worktree telemetry, so do not interpret missing workspace outcome rows as product drop-off for SSH, remote, or web users. - -## Rollouts - -### 2026-05-08 - Repo Cohort Property - -Scope: `nth_repo_added` on repo/activation/retention events. Current schemas declare it on `app_opened`, `repo_added`, `add_repo_setup_step_action`, `add_repo_existing_workspaces_detected`, `add_repo_default_checkout_handoff`, `workspace_created`, `workspace_create_failed`, `setup_script_prompt_shown`, `setup_script_prompt_action`, `agent_started`, `agent_prompt_sent`, and `agent_error`. The original rollout covered `app_opened`, `repo_added`, `add_repo_setup_step_action`, `workspace_created`, `workspace_create_failed`, `agent_started`, and `agent_error`; later events have their own first-seen timestamps below. - -| Field | Value | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PR | `#1591` | -| Merge commit | `002e2acc38cad262d694049cc86c0962ffd381ce` | -| `code_merged_at_utc` | `2026-05-08T17:50:44Z` | -| First release | `v1.3.42-rc.1` | -| First release commit | `b6dab85aaa0e9b600ec93846824ba815542a1e1e` | -| `first_released_at_utc` | `2026-05-08T17:56:35Z` | -| Earliest `first_seen_at_utc` | `2026-05-08T18:40:00.354Z` on `app_opened` | -| `dashboard_ready_at_utc` | Event-dependent. Use `2026-05-08T18:40:00.354Z` for general `app_opened` repo-count cuts; use `2026-05-08T20:01:06.364Z` or later for first-repo activation cuts requiring `repo_added`. | - -PostHog evidence checked at `2026-05-23T23:34:32Z`: - -| Event | First seen with `nth_repo_added` | -| --------------------------------------- | ------------------------------------------ | -| `app_opened` | `2026-05-08T18:40:00.354Z` (`1.3.42-rc.1`) | -| `workspace_created` | `2026-05-08T19:15:06.913Z` | -| `agent_started` | `2026-05-08T19:15:07.130Z` | -| `agent_prompt_sent` | `TBD` | -| `repo_added` | `2026-05-08T20:01:06.364Z` (`1.3.42`) | -| `add_repo_setup_step_action` | `2026-05-08T20:01:20.897Z` | -| `workspace_create_failed` | `2026-05-08T23:04:32.315Z` | -| `agent_error` | `2026-05-13T14:13:16.096Z` | -| `add_repo_existing_workspaces_detected` | `2026-05-20T05:33:20.160Z` | -| `add_repo_default_checkout_handoff` | `TBD` | -| `setup_script_prompt_shown` | `2026-05-21T04:09:04.231Z` | -| `setup_script_prompt_action` | `2026-05-21T04:10:31.616Z` | - -Dashboard caveats: - -- Earlier rows do not have `nth_repo_added`; show them as pre-rollout or exclude them from repo-count cohorts. -- A dashboard that depends on a specific event must use that event's first-seen timestamp, not the earliest `app_opened` timestamp. -- `repo_added nth_repo_added = 1` is the first-repo activation marker. -- `add_repo_setup_step_action` is only representative of the old post-add choice-screen funnel. After the 2026-06-03 default-checkout handoff change below, Add Project no longer shows that choice screen during the normal Git add/clone/create/onboarding paths. - -### 2026-05-09 - Onboarding Cohort Injection - -Scope: `cohort` on onboarding events. Current schemas declare it on `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_tour_outcome`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_task_sources_snapshot`, `onboarding_windows_terminal_snapshot`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, onboarding import/setup events, `onboarding_feature_setup_toggled`, `onboarding_feature_setup_run`, `onboarding_feature_setup_terminal_opened`, and `onboarding_feature_setup_terminal_interacted`. See `src/shared/telemetry-events.ts` for the exact current roster. - -The original `#1608` rollout covered `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, and onboarding import events. Later onboarding events joined the roster by declaring `cohort` in their schemas. - -| Field | Value | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PR | `#1608` | -| Merge commit | `318e2b4c2c501b50280dc9e5b11ed34bd899bdd9` | -| `code_merged_at_utc` | `2026-05-09T02:57:18Z` | -| First release | `v1.3.44` | -| First release commit | `e602decf1d35dc54c84eea516fc2b9afdc065cce` | -| `first_released_at_utc` | `2026-05-09T08:05:39Z` | -| Earliest `first_seen_at_utc` | `2026-05-09T09:54:03.038Z` on `onboarding_started { cohort: 'fresh_install' }` (`1.3.45`) | -| `dashboard_ready_at_utc` | `2026-05-09T09:54:03.038Z` for fresh-install onboarding-start cohorts; use later event-specific first-seen timestamps where a tile requires another event. | - -Later onboarding cohort events: - -| Event | PR | First release | `first_seen_at_utc` | -| ---------------------------------------------- | ---------------------------------------------------- | --------------------------------------- | ----------------------------------------- | -| `onboarding_feature_setup_run` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.248Z` (`1.4.2-rc.4`) | -| `onboarding_feature_setup_terminal_opened` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.261Z` (`1.4.2-rc.4`) | -| `onboarding_feature_setup_terminal_interacted` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T11:47:28.022Z` (`1.4.2-rc.6`) | -| `onboarding_feature_setup_toggled` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-17T03:03:54.535Z` (`1.4.3`) | -| `onboarding_task_sources_snapshot` | `#2275` (`ececd27fd8203a7acb3e430fa016fb4653aaa93c`) | `v1.4.7-rc.1` at `2026-05-19T00:47:11Z` | `2026-05-19T04:18:23.872Z` (`1.4.7`) | - -Dashboard caveats: - -- Use `onboarding_started { cohort: 'fresh_install' }` as the new-user denominator. -- Keep the single observed `upgrade_backfill` rows out of fresh-install funnels. -- `onboarding_step_completed` had semantic `value_kind` before the tour instrumentation, but `onboarding_step_viewed` and `onboarding_step_skipped` only became semantic-step-safe with the tour telemetry rollout below. -- Later onboarding events joined the `cohort` roster by declaring `cohort` in their schema. Use event-specific first-seen timestamps for tiles that depend on those later events. -- `onboarding_task_sources_snapshot` fires only when the integrations step exits through Continue or Skip to project setup. Viewing integrations and leaving does not emit the snapshot. -- Old numeric step analysis must be scoped to the flow version being analyzed. - -### 2026-05-14 - Feature Wall Base Telemetry - -Scope: first feature-wall tour event family before the inline onboarding tour. This added base open/close and tile activity telemetry. Historical `feature_wall_closed` rows from this rollout have `dwell_ms`; the source/depth fields needed for tour cohort retention only arrive in the later tour telemetry rollout. - -| Field | Value | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | -| PR | `#1772` | -| Merge commit | `fdf7d9e97a46f27b3667d9bae6c8aca5d11345d8` | -| `code_merged_at_utc` | `2026-05-14T22:20:50Z` | -| First release | `v1.4.2-rc.1` | -| First release commit | `c66e9801fe5f159cac23564ec7c028957973692a` | -| `first_released_at_utc` | `2026-05-14T22:23:46Z` | -| `first_seen_at_utc` | `2026-05-15T06:15:30.263Z` on `feature_wall_opened { source: 'popup' }` (`1.4.2-rc.4`) | -| `dashboard_ready_at_utc` | Event-dependent. Useful for base feature-wall usage after first-seen rows, not for inline-tour cohort retention. | - -PostHog evidence checked at `2026-05-23T23:34:32Z`: - -| Event/property | `first_seen_at_utc` | -| ------------------------------------------------- | ----------------------------------------- | -| `feature_wall_opened { source: 'popup' }` | `2026-05-15T06:15:30.263Z` (`1.4.2-rc.4`) | -| `feature_wall_tile_focused` | `2026-05-15T06:15:31.073Z` (`1.4.2-rc.4`) | -| `feature_wall_closed` with legacy `dwell_ms` only | `2026-05-15T06:18:22.677Z` | -| `feature_wall_tile_clicked` | `2026-05-17T04:35:04.142Z` (`1.4.3`) | -| `feature_wall_opened { source: 'help_menu' }` | `2026-05-17T07:02:23.278Z` (`1.4.3`) | - -Dashboard caveats: - -- Do not infer inline onboarding tour cohorts from this rollout. -- Historical `feature_wall_closed` null `source`/depth fields are pre-rollout absence, not product behavior. - -### 2026-05-23 - Inline Tour Surface - -Scope: optional "Explore Orca" tour during onboarding and the Help menu entry point. This shipped the product surface, not the full retention cohort instrumentation. It also added `source = 'onboarding'` to the feature-wall open source enum and added feature-wall group/feature/docs click events with source. - -| Field | Value | -| -------------------------- | ---------------------------------------------------------------------------- | -| PR | `#2652` | -| Merge commit | `669ade23133b9905fff1b6ed22755ee2911ea517` | -| `code_merged_at_utc` | `2026-05-23T19:21:14Z` | -| First release | `v1.4.23-rc.0` | -| First release commit | `1f1171e0dee5d0fbf969de56845882bf8deb9037` | -| `first_released_at_utc` | `2026-05-23T20:14:38Z` | -| First app version observed | `1.4.23-rc.0` at `2026-05-23T20:54:12.343Z` | -| `dashboard_ready_at_utc` | `TBD`; do not use this surface-only rollout for tour cohort retention tiles. | - -Dashboard caveats: - -- `feature_wall_opened { source: 'onboarding' }` is a release-availability signal for the inline surface, not the canonical tour cohort assignment. -- Use this boundary only for exploratory checks that do not require the new outcome/depth fields. -- Tour cohorts are observational and self-selected. Do not use causal "lift" or "impact" language unless a separate experiment supports it. - -### 2026-05-23 - Onboarding Tour Retention Telemetry - -Scope: low-cardinality telemetry that makes inline-tour cohort retention dashboards possible. - -Current status: the inline first-run onboarding tour was later removed from active onboarding after PR #2734 moved these education/setup moments to contextual feature tours and the Getting started with Orca guide. The historical schemas still accept seven-step `tour`/`agent_setup` onboarding rows for compatibility, but current active onboarding should not emit new `onboarding_step_* { value_kind: 'tour' }` rows. - -Added/changed signals: - -- `onboarding_tour_outcome` with `outcome`, intro/tour duration fields, optional tour depth fields, `advanced_via`, and injected onboarding `cohort`. -- `onboarding_step_viewed.value_kind` and `onboarding_step_skipped.value_kind`, including `value_kind = 'tour'`. -- `feature_wall_closed` optional `source`, `exit_action`, `furthest_step`, `last_group_id`, and bounded visited/completed depth counts. -- `feature_wall_opened { source: 'onboarding' }` through the inline tour surface. - -| Field | Value | -| ------------------------ | ------------------------------------------ | -| PR | `#2713` | -| Branch commit | `9115143927dce38547545a07e58b85d17796fd7a` | -| Merge commit | `b9c55bb07127f799528761ec31d7e2b7f8598c07` | -| `code_merged_at_utc` | `2026-05-23T21:15:11Z` | -| First release | `v1.4.23-rc.1` | -| First release commit | `c74765922e770c6540496735a5a2be838457256a` | -| `first_released_at_utc` | `2026-05-23T21:16:38Z` | -| `first_seen_at_utc` | `TBD` | -| `dashboard_ready_at_utc` | `TBD` | - -Required readiness signals: - -| Signal | `first_seen_at_utc` | -| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `onboarding_tour_outcome` with valid `outcome` and joinable fresh-install start | `TBD` | -| `onboarding_step_viewed { value_kind: 'tour' }` | Historical only; no new active-onboarding rows after the PR #2734 education handoff. | -| `feature_wall_closed` with non-null `source`, `exit_action`, and expected depth fields | `TBD` | - -QA/opportunistic signals: - -| Signal | `first_seen_at_utc` | -| ------------------------------------------------ | ------------------------------------------------------------------------- | -| `onboarding_step_skipped { value_kind: 'tour' }` | Historical only; do not gate dashboard readiness on natural skip traffic. | -| `feature_wall_opened { source: 'onboarding' }` | `TBD`; surface availability only. | - -PostHog evidence checked at `2026-05-23T23:34:32Z`: - -- `1.4.23-rc.0` had app traffic from `2026-05-23T20:54:12.343Z` through `2026-05-23T22:59:47.599Z`. -- No `1.4.23-rc.1` or `1.4.23-rc.2` app traffic was observed in the checked query window. -- No rows were observed for `onboarding_tour_outcome`, `onboarding_step_viewed { value_kind: 'tour' }`, `onboarding_step_skipped { value_kind: 'tour' }`, `feature_wall_opened { source: 'onboarding' }`, or `feature_wall_closed` with the new source/depth fields. -- No rows were observed for `feature_wall_group_selected`, `feature_wall_feature_selected`, or `feature_wall_docs_clicked` in the checked query window. - -Dashboard readiness rule: - -Set this rollout's `dashboard_ready_at_utc` to the latest first-valid timestamp across the required readiness signals, then use that as the lower bound. If a query uses a local variable, name it `tour_telemetry_ready_at_utc` and assign it to the same value. After first-seen rows exist, keep saved cohort tiles in QA/exploratory mode until there is at least a 24-hour validation window where reached-tour users reconcile to exactly one primary inline cohort and closed tour sessions include depth fields. - -Invalid before this rollout's `dashboard_ready_at_utc`: - -- Tour cohort onboarding completion/progress. -- Corrected onboarding outcome split by tour cohort. -- Tour depth before abandonment. -- Skipped-tour later Help recovery. -- D1/D3/D7 retention by tour cohort, until cohorts are also old enough for each maturity window. -- Numeric-step max-progress charts unless segmented by rollout or app version. - -Primary cohort interpretation: - -- Historical denominator for inline completed/partial/skipped rates: `onboarding_step_viewed { value_kind: 'tour' }` after this rollout's `dashboard_ready_at_utc`. Do not use this as a current active-onboarding readiness signal after the PR #2734 education handoff. -- `onboarding_tour_outcome.outcome = 'completed_inline'` means the user completed the inline tour during fresh onboarding. -- `onboarding_tour_outcome.outcome = 'started_partial'` means the user started the inline tour but the fresh onboarding session resolved without inline completion. -- `onboarding_tour_outcome.outcome = 'skipped_intro'` means the user reached the tour intro and skipped without starting the inline tour. -- Users who started onboarding but never reached the tour intro are `pre_tour_abandoned`, not `unresolved_inline_outcome`. -- Users who reached the tour intro but have no outcome event are `unresolved_inline_outcome`; monitor this as a telemetry QA gap. -- Later Help menu tour usage is an overlay on the user's inline cohort. It does not replace the primary inline outcome. -- `completed_inline` wins over earlier partial state if the user returns and completes during the fresh onboarding session. -- `started_partial` resolves only after the user started the inline tour and the fresh onboarding session resolves without inline completion. -- `skipped_intro` intentionally has no `tour_dwell_ms` or depth fields. - -Feature-wall close interpretation: - -- `feature_wall_closed.dwell_ms` is session-close dwell time. -- `feature_wall_closed.source` is the entry point (`onboarding`, `help_menu`, `popup`, or `unknown`). -- `exit_action = 'onboarding_continue'` means the inline onboarding tour ended through Continue to project setup. -- `exit_action = 'done'` means a non-onboarding tour session ended through Done. -- `exit_action = 'dismissed'` is the default close/unmount path. -- Depth fields are per explicit tour session. Persisted completion state can affect UI progress but must not be interpreted as current-session depth. -- Missing/null `feature_wall_closed.source` or depth fields mean the row predates the expanded schema, came from an older app version, or failed field coverage. Do not bucket null with `source = 'unknown'`; exclude it from source/depth cohort tiles or show it as a telemetry coverage gap. - -### 2026-06-02 - Active Onboarding Step Removal - -Scope: active first-run onboarding no longer emits the `agent_setup` or `tour` semantic steps. The removed "Set up Orca for agents" and "Explore Orca" education/setup moments are covered by PR #2734 through contextual feature tours and the Getting started with Orca guide. - -This is a product-flow and telemetry-interpretation boundary, not a new event rollout. Historical onboarding schemas still accept seven-step `agent_setup` and `tour` rows so old data remains queryable, but dashboard authors should not expect new active-onboarding rows for those semantic steps after this rollout. - -| Field | Value | -| ------------------------ | ------------------------------------------------------------------------------- | -| PR | `#4445` | -| Merge commit | `TBD` | -| `code_merged_at_utc` | `TBD` | -| First release | `TBD` | -| First release commit | `TBD` | -| `first_released_at_utc` | `TBD` | -| `first_seen_at_utc` | N/A | -| `dashboard_ready_at_utc` | Event-dependent; use this as a cutoff only after the PR is merged and released. | - -Dashboard caveats: - -- Treat `onboarding_step_* { value_kind: 'agent_setup' }`, `onboarding_step_* { value_kind: 'tour' }`, and `onboarding_tour_outcome` as historical first-run onboarding signals after this rollout. -- Do not use absence of new `agent_setup` or `tour` onboarding rows as a drop-off signal; those steps no longer exist in active onboarding. -- Segment numeric onboarding step analysis across this boundary. The active final step changed from seven-step onboarding to the five-step active flow. -- Continue using `contextual_tour_shown` and `contextual_tour_outcome` from PR #2734 for current feature-education exposure and outcome analysis. - -### 2026-06-03 - Final Code Onboarding Step Removal - -Scope: active first-run onboarding no longer emits the final code/project picker step. The notifications step is now the final step, and completing it opens the Add Project modal. - -This is a product-flow and telemetry-interpretation boundary, not a new event rollout. Historical onboarding schemas still accept five-step rows so old data remains queryable, but dashboard authors should not expect new active-onboarding rows for the removed final code/project picker step after this rollout. - -| Field | Value | -| ------------------------ | ------------------------------------------------------------------------------- | -| PR | `#4524` | -| Merge commit | `TBD` | -| `code_merged_at_utc` | `TBD` | -| First release | `TBD` | -| First release commit | `TBD` | -| `first_released_at_utc` | `TBD` | -| `first_seen_at_utc` | N/A | -| `dashboard_ready_at_utc` | Event-dependent; use this as a cutoff only after the PR is merged and released. | - -Dashboard caveats: - -- Treat `onboarding_step_*` rows for the removed final code/project picker step as historical first-run onboarding signals after this rollout. -- Segment numeric onboarding step analysis across this boundary. At this boundary, the active final step changed from the five-step active flow to `ONBOARDING_FINAL_STEP = 4`; later onboarding step rollouts may supersede that final-step value. -- Do not use absence of new final code/project picker rows as a drop-off signal; that step no longer exists in active onboarding. - -### 2026-06-03 - Add Project Default Checkout Handoff - -Scope: Add Project handoff telemetry and interpretation change. The Git add/clone/create/onboarding flows no longer show the post-add setup-choice screen; they close the add modal and open the project/default checkout when available. If no default checkout is available, they reveal the project row instead. - -`repo_added` remains the add/import marker. `add_repo_existing_workspaces_detected` remains a low-cardinality detection event for pre-existing non-main workspaces on local folder, runtime server path, and SSH server path adds. `add_repo_default_checkout_handoff` is the direct rollout-health event for the automatic handoff decision. - -| Field | Value | -| ------------------------ | ------------------------------------------------------------------------------------- | -| PR | `#4530` | -| Merge commit | `TBD` | -| `code_merged_at_utc` | `TBD` | -| First release | `TBD` | -| First release commit | `TBD` | -| `first_released_at_utc` | `TBD` | -| `first_seen_at_utc` | `TBD` on `add_repo_default_checkout_handoff` | -| `dashboard_ready_at_utc` | Use the first release boundary once known for charts that compare setup-choice usage. | - -Dashboard caveats: - -- Treat `add_repo_setup_step_action` rows after this rollout as historical compatibility or stale modal cleanup only, not the normal Add Project funnel. -- Do not build current-flow conversion funnels that require `repo_added -> add_repo_setup_step_action`; the normal next step is an automatic workspace reveal/open, not a tracked user choice. -- Use `add_repo_existing_workspaces_detected` to estimate how often added projects had non-main existing workspaces, but do not infer the user selected "use existing worktrees" because that choice no longer exists in the normal flow. -- Use `add_repo_default_checkout_handoff` for the current handoff outcome. `result = 'opened_default_checkout'` is the expected path; `result = 'revealed_project'` is the graceful fallback. Break down fallback rows by `source` and `reason`. - -### 2026-06-10 - Repo Added Git-vs-Folder Signal - -Scope: `repo_added.is_git_repo` replaces the retired `onboarding_completed.is_git_repo` split for git-vs-folder analysis. Project selection moved out of onboarding in the 1.4.46 flow, so `onboarding_completed` now fires before any repo is chosen. After that boundary, the old `onboarding_completed.is_git_repo` value is not a valid git-vs-folder signal. - -`repo_added.is_git_repo` is sourced from git detection at the add point. It is optional so SSH/remote paths that genuinely cannot determine git-ness can omit the property instead of defaulting to `false`. - -| Field | Value | -| ------------------------ | ------------------------------------------------------------------------------------------- | -| PR | `#5121` | -| Merge commit | `TBD` | -| `code_merged_at_utc` | `TBD` | -| First release | `TBD` | -| First release commit | `TBD` | -| `first_released_at_utc` | `TBD` | -| `first_seen_at_utc` | `TBD` on `repo_added.is_git_repo` | -| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and field coverage has been checked in PostHog. | - -PostHog evidence checked at `2026-06-10T19:00:00Z`: - -- Dashboard tile "Fresh-install onboarding completion over time" (`JlIt5J1N`, insight id `9076383`, project `406068`) showed the git-repo share collapse to about 4% while plain-folder completions spiked to about 88% on 2026-06-05. -- Raw `onboarding_completed.is_git_repo` counts by `app_version` showed a version cliff: versions through `1.4.45` were about 80% true, while `1.4.46`, `1.4.47`, and `1.4.48` had zero true rows in the sampled data. - -Dashboard caveats: - -- Treat `onboarding_completed.is_git_repo` as historical only after app version `1.4.45`. -- Do not stitch historical `onboarding_completed.is_git_repo` and new `repo_added.is_git_repo` series without an explicit version boundary and label change; they are emitted at different funnel moments. -- Repoint dashboard tile `JlIt5J1N` to use `repo_added.is_git_repo` once the new field is observed in release telemetry. -- Omitted `repo_added.is_git_repo` means unknown/degraded detection, not plain folder. Only explicit `false` means plain folder. - -### 2026-06-16 - Windows Terminal Preferences Onboarding Step - -Scope: Windows first-run onboarding adds a terminal preferences step before notifications. The step lets users choose the default Windows terminal shell and right-click paste/menu behavior before their first project handoff. - -`onboarding_step_*` rows can now emit `value_kind = 'windows_terminal'` at step `4`. Notifications move to step `5`, so `ONBOARDING_FINAL_STEP = 5` for current active onboarding. Non-Windows clients skip the Windows terminal step but still persist through the skipped step so resumed onboarding lands on notifications. `onboarding_windows_terminal_snapshot` records the low-cardinality selected shell bucket, right-click behavior, exit action, duration, and advance method when the visible Windows terminal step exits. - -| Field | Value | -| ------------------------ | -------------------------------------------------------------------------------------- | -| PR | `#5488` | -| Merge commit | `68abadba8198c627fb642c41e54937c04ccddfe8` | -| `code_merged_at_utc` | `2026-06-16T21:07:26Z` | -| First release | `TBD` | -| First release commit | `TBD` | -| `first_released_at_utc` | `TBD` | -| `first_seen_at_utc` | `TBD` on `onboarding_step_viewed { value_kind: 'windows_terminal' }` and `onboarding_windows_terminal_snapshot` | -| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and Windows/non-Windows split plus snapshot coverage are verified. | - -Dashboard caveats: - -- Segment numeric onboarding step analysis across this boundary. Step `4` is Windows terminal preferences in the current flow, but was notifications in the previous active flow. -- Use `value_kind` rather than numeric `step` when comparing notifications or Windows terminal setup across releases. -- Non-Windows users can have persisted `lastCompletedStep` values that include the skipped Windows step; do not treat that as evidence they viewed the Windows terminal page. -- `onboarding_windows_terminal_snapshot.default_shell = 'other'` means Orca could not bucket the persisted setting. It is not a raw shell path and should be monitored as telemetry quality, not a product choice. - -## Updating This File - -When adding or changing telemetry that dashboard authors will depend on: - -1. Add or update one rollout entry in this file. -2. Record PR, merge commit, first release, release commit, `first_released_at_utc`, `first_seen_at_utc`, and `dashboard_ready_at_utc`. -3. Include the app version/build on first-seen rows when it is available. -4. Add `PostHog evidence checked at ...` with the UTC query time. -5. If dashboard readiness depends on multiple signals, add a required readiness signal table and set `dashboard_ready_at_utc` only after all of them are first-valid. -6. Add dashboard caveats for pre-rollout rows, missing/null fields, and maturity windows. -7. Keep product-specific facts here, not in agent instructions or telemetry payloads. diff --git a/docs/reference/terminal-cold-park-reveal-cost.md b/docs/reference/terminal-cold-park-reveal-cost.md deleted file mode 100644 index e4eecd7fb91..00000000000 --- a/docs/reference/terminal-cold-park-reveal-cost.md +++ /dev/null @@ -1,132 +0,0 @@ -# Cold-park reveal cost — measured - -Companion to [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md). -Quantifies the reveal latency a user pays when returning to a terminal whose -hidden view was cold-parked, so the keep-warm policy is tuned from data rather -than guessed. Motivated by field reports that "terminal mounting is jumpy — -switching to a worktree after a while takes ~1s". - -## How to reproduce - -```bash -pnpm bench:cold-park-reveal -- --cycles=12 # empty shell -pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=5000 -pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=20000 -``` - -The bench (`tools/benchmarks/terminal-cold-park-reveal-bench.mjs`) drives the -real dev app over CDP. Each cycle times a **cold** reveal (tab confirmed parked -via `window.__terminalParkingDebug.parkedTabIds()` before revealing) and a -**warm** reveal (revealed inside the hot-retain window, confirmed not parked), -so the cold−warm delta is the isolated cost of parking. It shrinks the 30s -hysteresis with `ORCA_E2E_TERMINAL_PARKING_DELAY_MS` and roots userData under a -short `~/.ocpb` path — the default `/var/folders` tmp path overruns the macOS -104-char Unix-socket limit, the daemon fails to bind, and terminals fall back to -non-snapshot PTYs that never park. - -Phases per reveal (ms from the switch-back click): `activationMs` (store active -worktree flips), `ptyBindMs` (revealed pane has a bound pty — xterm remounted -and attached), `paintSettleMs` (two RAFs after ptyBind). - -## Results (macOS, dev app, medians / p95) - -| Scrollback | warm paintSettle | cold paintSettle | **cold-park penalty** | -| ---------- | ---------------- | ---------------- | --------------------- | -| empty shell | 104 ms | 264 / 298 ms | **+160 ms** | -| 5 000 lines | 131 ms | 318 / 423 ms | **+187 ms** | -| 20 000 lines | 102 ms | 266 / 286 ms | **+164 ms** | - -All cold samples confirmed parked (`parkedConfirmed = N/N`); all warm samples -confirmed not parked. - -## Finding - -**The cold-park reveal penalty is ~160–190 ms and effectively flat across buffer -size.** Growing scrollback 0 → 20 000 lines did not grow the penalty (the 20k -run is within noise of empty). So the cost is **fixed remount overhead** — React -subtree teardown/rebuild, fresh xterm construction, WebGL attach, fit, and the -reattach reset — **not** daemon snapshot replay, which scales fine. - -Implications for tuning: - -- The lever is **how often a remount happens**, not how much is replayed. - Widening the keep-warm working set (worktree hot-retain limit is 4, tabs 12) - and never parking the last-active tab per worktree remove remounts entirely - for the common switch-away-and-back, which is worth more than shaving the - ~170 ms remount itself. -- Pre-warming a predicted reveal (sidebar hover, jump-palette selection) hides - the fixed cost behind the navigation instead of eliminating it. -- This synthetic terminal is a plain shell. A live agent TUI in the alternate - screen pays additional reattach-reset + mode-rehydrate work on reveal (see the - Grok-scrollback reattach path), so real-world cold reveals of a busy agent - pane can exceed these numbers — the ~170 ms here is a floor for the mount - overhead, not a ceiling for the full experience. - -## Is parking worth having? (benefit side) - -`tools/benchmarks/terminal-cold-park-resource-bench.mjs` -(`pnpm bench:cold-park-resource`) A/Bs the `terminalHiddenViewParking` setting -in one session: mount N worktree terminals, background all but one, then read -resources with parking off vs on. Measured at 8 backgrounded worktrees -(5 000-line scrollback each): - -| metric | parking off | parking on | released | -| ------ | ----------- | ---------- | -------- | -| mounted pane managers | 9 | 1 | **8** | -| DOM nodes | 2 941 | 1 389 | **1 552** | -| attached WebGL contexts | 1 | 1 | **0** | - -So parking's unique win is **releasing every hidden terminal's mounted view** — -8 xterm instances + their DOM subtrees (~1 550 nodes) here. At xterm's default -5 000-row scrollback, each terminal buffer alone is ≥ ~4–5 MB of typed-array -cell data (12 bytes/cell × 80 cols × 5 000 rows), so the renderer-memory floor -is on the order of **several MB per hidden terminal** — tens of MB across a -many-worktree session, before DOM and addon overhead. - -Two honest caveats on the numbers: - -- The **WebGL-context budget is NOT parking's win.** Even with parking off, only - one context was attached across 9 mounted terminals — the separate - WebGL-suspend-on-hide path (`suspendRendering`) already releases hidden panes' - contexts. So the #6874 context-exhaustion protection stands with or without - parking; parking's benefit is renderer memory + DOM, not contexts. -- The measured JS-heap delta was small (~1.7 MB) because detached xterm - typed-array buffers GC lazily and the first measurement ran before they were - reclaimed. The bench was upgraded to force multi-pass GC and read whole-app - process memory (`window.api.memory.getSnapshot`), but a machine under disk + - memory pressure could not complete the upgraded run (esbuild dev-service - crashes). Re-run the upgraded bench on a healthy machine to get the settled - whole-app memory figure; the pane-manager / DOM release above is the robust, - reproducible part. - -### Verdict - -Parking earns its keep for the **many-worktree power user** (the reporter's -profile): it is the only mechanism that frees hidden terminals' view memory and -DOM, which is the dominant renderer cost at scale. It does **not** provide the -GPU-context protection (that is suspend-on-hide's job). The reveal price is a -flat ~170 ms remount. Net: keep it, and tune the keep-warm caps so the common -few-worktree rotation never pays the remount. - -### Tuning applied - -Because the reveal cost is a fixed remount, the levers are all about -**frequency**, not replay size. Three changes to -`terminal-hidden-view-parking.ts`: - -- **Worktree hot-retain limit 4 → 8.** Eight covers the ordinary working set; - at the ~4–5 MB renderer floor per hidden worktree that is ~16–20 MB worst - case to eliminate remounts for the common rotation. Beyond 8, parking still - reclaims for the heavy tail it was built for. -- **Hot-retain TTL 5 min → 15 min** (worktree and tab). The cap, not the clock, - is the primary evictor now; 5 min was aggressive enough that a meeting-length - absence parked the whole warm set. The tab count cap stays 12 (already - generous). -- **Last-active exemption.** The single most-recently-hidden worktree/tab is - never parked, regardless of TTL or cap, so returning to the view you just - left is always instant. Implemented in `selectIdsBeyondHotRetain`; it holds - one warm slot and counts against the cap. - -Not done (deliberately): pre-warm on predicted reveal — it hides the cost -behind navigation rather than removing it and adds hover/prediction complexity; -and the 30 s cold-park delay is unchanged (it is the quick-flip guard). diff --git a/docs/reference/terminal-hidden-view-parking.md b/docs/reference/terminal-hidden-view-parking.md deleted file mode 100644 index 55583ab5a79..00000000000 --- a/docs/reference/terminal-hidden-view-parking.md +++ /dev/null @@ -1,119 +0,0 @@ -# Terminal Hidden View Parking - -Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch -`terminalHiddenViewParking` (default on). See -[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the -invariants this design extends and the full phase list. - -## Problem - -Hidden terminal panes keep a full renderer xterm instance alive (buffer, -scrollback, DOM, addons). At many-worktree scale this is the dominant renderer -memory cost, and it forces every hidden byte through renderer-side write/skip -decisions. The main-process model (daemon + runtime headless emulators) already -ingests every byte and can serve restorable snapshots, so the renderer view for -a long-hidden pane is redundant state. - -A previous attempt shipped and was reverted the same day. The post-mortem -finding: parking unmounted the pane component, which also tore down the -renderer's PTY byte parsers — and those parsers are the only source of bell -notifications, title-transition agent-complete notifications, and tab titles. -A parked worktree whose agent finished would never notify. This design keeps -those side effects alive while parked. - -## Design - -### Park policy (renderer) - -A pure policy module decides which hidden terminal tabs may park: - -- Cold-park hysteresis: a tab must be hidden for 30s before parking. -- Hot-retain working set: recently visible worktrees/tabs are retained - (15 minutes; up to 8 worktrees / 12 tabs) so quick tab switches never pay a - re-hydrate. The single most-recently-hidden (last-active) worktree/tab is - exempt from both the retain TTL and the count cap, so switching back to the - view you just left is always instant regardless of how long you were away. - See `terminal-cold-park-reveal-cost.md` for the data behind these caps. -- Eligibility excludes: visible panes, hidden-measuring startup probes, - activity-portal panes, tabs with pending startup commands or pending - activation spawns, floating-panel tabs, and any tab whose PTY is not - snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded). -- Kill switch: `settings.terminalHiddenViewParking === false` disables parking - entirely. - -### Park mechanics - -Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer -renders null for parked tabs). This is the same teardown that tab-group moves -already exercise: transports detach but the PTY session, daemon model, and tab -state all survive. The xterm instance, its buffers, DOM, and WebGL/addon -resources are released. - -### Parked watcher (the piece the reverted attempt lacked) - -While a tab is parked, a pane-less watcher -(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its -consumption mode is decided once at watcher start: - -- **Main side-effect authority on (default):** the watcher is purely - fact-driven — it registers exactly one `pty:sideEffect` fact consumer and - parses no bytes. Titles, agent working/idle/exited transitions, BEL - attention, and PR links arrive as main-tracker facts and drive the same - policy callbacks a mounted pane uses. With the hidden-delivery gate also on, - the watcher marks the PTY hidden so main stops renderer byte delivery - entirely; the DECSET 2031 color-scheme subscribe arrives as main's - `2031-subscribe` fact and the watcher replies out-of-band via - `transport.sendInput`. -- **Kill switch off:** the watcher subscribes to raw bytes through the - dispatcher sidecar mechanism (the same mechanism background agent launches - use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2 - titles (all-titles ordering, live-path normalization), the title-transition - agent tracker (completion notification, prompt-cache timer), the OSC-aware - stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031 - byte responder (`parked-terminal-mode2031-responder.ts`, whose - `subscribeToPtyData` registration doubles as the delivery-interest signal). - -The two modes drive one shared policy-callback block, so flipping the kill -switch never changes notification semantics. Main's synthetic -agent-title/permission frames feed the main tracker directly and arrive as -facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off -mode. - -Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside -hidden-dropped chunks are answered by main's model responder -([`terminal-query-authority.md`](./terminal-query-authority.md)); in -kill-switch-off byte mode only the 2031 reply is answered and Command Code -output is not scraped, matching the pre-gate status quo. - -### Reveal - -Revealing a parked tab remounts the pane subtree and rides the existing -reattach path: fresh xterm via `openTerminal` (unicode provider activation -before any write), daemon model snapshot > relay replay > cold restore -precedence, replay-guarded so snapshot-embedded queries never answer, then -`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is -disposed before the pane handlers re-register. - -## Invariants - -1. PTY reads never stop; parking only changes renderer-side view lifetime. -2. Bell, agent-completion, title, and PR-link side effects keep working while - parked (watcher parity tests). -3. Reveal shows model-correct output (visual gates: hidden TUI restore, long - table, rendering golden) and accepts input immediately. -4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers - or strand parked state. -5. Memory: parked tabs hold no xterm buffers; renderer memory scales with - visible panes. - -## Relation to later phases (all shipped) - -Side-effect authority in main (Phase 3) replaced the watcher's byte parsing -with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4) -stops hidden byte delivery in main, moving the parked 2031 reply from the -byte sidecar to the `2031-subscribe` fact; the model query responder -(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser -mode survives only behind the kill switches. Parking still excludes -remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher -would return as a byte parser only if remote-runtime tabs — whose bytes never -transit local main — ever became parkable. diff --git a/docs/reference/terminal-model-view-contract.md b/docs/reference/terminal-model-view-contract.md deleted file mode 100644 index 8c30724c94c..00000000000 --- a/docs/reference/terminal-model-view-contract.md +++ /dev/null @@ -1,234 +0,0 @@ -# Terminal Model/View Contract - -## Goal - -Terminal output should have one authoritative model path and many disposable -views. A renderer xterm is the fast interactive view, but it must not be the -only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists. - -This contract defines the boundary the shipped terminal stack implements — and -that future terminal work must preserve — without changing the query-response -behavior that real shells and TUIs depend on. See [Architecture -Status](#architecture-status) for the shipped phases. - -## Terms - -- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY, - or remote runtime PTY. -- **Raw span:** A half-open post-shell-ready provider sequence range carried - beside its classified output. Its length can differ from the visible string. -- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this - is mostly the headless emulator plus retained read transcript state. -- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop - subscriber, or CLI read page consuming model state and live output. -- **Snapshot:** A bounded model serialization that can restore a view without - replaying an unbounded byte log. -- **Transcript:** The retained output contract for `orca terminal read`; it is - line/cursor oriented and distinct from a screen snapshot. - -## Non-Negotiable Invariants - -1. PTY reads do not stop to protect renderer performance. Backpressure may bound - delivery to views, but terminal state, notifications, titles, and agent - status keep advancing from the PTY stream. -2. Active visible terminal input/output stays on the lowest-latency path. Bulk - hidden or background output must not delay keystroke-sized foreground redraws. -3. Hidden views do not own unbounded output memory. Main's hidden-delivery - gate drops renderer-bound bytes for hidden-marked PTYs after model - ingestion and emits an out-of-band restore marker - (`pty:modelRestoreNeeded`) so the view restores from the model on reveal. - With the gate's kill switches off, hidden bytes ride a bounded renderer - queue whose overflow latches the same model restore. -4. Returning to a hidden or slept terminal must show model-correct output. A - stale or replaced view may be cleared and replayed from a snapshot, but it - must not show a warning fallback when model recovery is available. -5. Snapshots and live bytes have ordering metadata. A view restore must not - duplicate bytes already included in the snapshot or drop bytes that arrived - after it. Main buffer snapshots report the pending-delivery start sequence - (`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a - restore without misreading foreign sequence domains as duplicates. -6. Terminal query authority is singular and structural: the owner of a live - query answers it. A bounded fresh-session source ingress may consume startup - OSC 10/11 queries before any model or view and then hands authority off in - source order. After that handoff, visible renderer and remote views keep - xterm authority; chunks dropped by the hidden-delivery gate are answered - exactly once by the main model responder. Replayed, seeded, or snapshot - bytes are answered by no one. The daemon emulator never answers. See - [`terminal-query-authority.md`](./terminal-query-authority.md). -7. The transcript contract stays separate from screen restore. `orca terminal -read` must preserve bounded previews, cursor pagination, partial-line rules, - truncation flags, and total counts even if view snapshots change shape. -8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy - the same model/view contract or explicitly report that model recovery is - unavailable. -9. Classification precedes every authoritative consumer. Local providers, - daemon sessions, and SSH relays emit ordered raw spans after shell-ready - preprocessing and before model, persistence, replay, or fanout. Empty - transformed spans advance raw sequence and flow-control accounting without - writing bytes into an emulator or view. Transformed spans are never sliced; - overlap recovery requests a fresh authoritative snapshot. - -## Current Owners - -| Responsibility | Current owner | -| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Fresh-session startup OSC classification and raw-span emission | `src/shared/pty-startup-ingress.ts`, installed by `LocalPtyProvider`, daemon `Session`, and relay `PtyHandler` | -| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` | -| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` | -| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` | -| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` | -| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` | -| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` | -| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` | -| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` | -| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` | -| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` | - -## Snapshot Contract - -A model snapshot must include: - -- terminal dimensions used to produce the snapshot; -- enough ANSI state to rehydrate xterm before snapshot content; -- bounded screen and scrollback content; -- title and cwd metadata when known; -- source metadata that distinguishes headless/model snapshots from renderer - fallback snapshots; -- monotonic ordering metadata for live-output reconciliation when available. - -A snapshot must not: - -- include unbounded transcript history; -- answer terminal queries while replaying into the model; -- overwrite newer live view output with older model output; -- hide that recovery was unavailable for a PTY surface. -- retain a held ingress prefix: shell-ready preprocessing and source ingress - drain in order before the snapshot sequence is captured. - -## View Contract - -A renderer or remote view may: - -- write active visible output immediately; -- budget visible inactive output; -- stop receiving hidden output entirely while main's hidden-delivery gate owns - the bytes (model restore on reveal); -- request fresh snapshots for restore, mobile subscription, or explicit remote - snapshot recovery. - -A view must: - -- keep live-output buffers bounded while a snapshot is in flight; -- apply generation or sequence checks before replaying a snapshot; -- refresh/repaint after replay when xterm/WebGL needs an explicit paint; -- keep side effects such as title, bell, cwd, and agent status flowing from the - PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte - delivery is budgeted, gated, or parked. - -## Transcript Contract - -The retained read transcript is not a screen dump. It must preserve: - -- uncursored bounded latest preview behavior; -- cursor reads over completed retained lines; -- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`; -- partial-line duplication rules; -- `truncated`, `limited`, and total count metadata; -- bounded memory for long partial lines and large output bursts. - -Snapshot optimizations must be tested against this transcript contract instead -of assuming xterm scrollback serialization can replace it. - -## Required Contract Tests - -Before moving more runtime behavior behind the model/view boundary, add or -extend tests that prove: - -- headless snapshots rehydrate rich alternate-screen TUI state; -- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive - queries (the `session.test.ts` pins are permanent); -- the main runtime responder answers queries only from live chunks the - hidden-delivery gate dropped — never delivered, replayed, seeded, or - remote-subscribed chunks; -- hidden renderer overflow restores from model state without duplicate live - output; -- sleep/wake and worktree revisit restore from model-correct state; -- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs; -- remote runtime multiplex output remains ACK bounded and can request recovery - snapshots; -- source ingress consumes a capable fresh-spawn query exactly once, keeps - reattach/legacy/WSL paths passive, and removes matched native-Windows cooked - projections before model, persistence, relay replay, and fanout; -- empty transformed spans retain their raw length through daemon/relay batching, - main-to-renderer ACK accounting, and remote/mobile multiplex frames; -- mobile subscribers receive bounded snapshots without unbounded pending live - output; -- retained terminal reads remain pageable and bounded after large output. - -Current coverage is spread across: - -- `src/main/daemon/headless-emulator.test.ts` -- `src/main/daemon/session.test.ts` -- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers, - `pendingDeliveryStartSeq`) -- `src/main/ipc/pty-hidden-delivery-gate.test.ts` -- `src/main/runtime/mobile-subscribe-integration.test.ts` -- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts` -- `src/main/runtime/rpc/terminal-multiplex.test.ts` -- `src/main/runtime/orca-runtime.test.ts` -- `src/main/runtime/terminal-query-responder.test.ts` -- `src/shared/terminal-output-side-effects.test.ts` -- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts` -- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts` -- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts` -- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts` -- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts` -- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts` -- `tests/e2e/terminal-hidden-view-parking.spec.ts` -- `tests/e2e/terminal-parked-memory.spec.ts` -- `tests/e2e/terminal-sleep-wake-restore.spec.ts` -- `tests/e2e/terminal-output-scheduler.spec.ts` -- `tests/e2e/artificial-opencode-terminal-load.spec.ts` - -## Architecture Status - -All six phases of the terminal model/view architecture are shipped; the kill -switches noted in parentheses default on: - -1. **Hidden view parking** — "Park hidden terminal views behind a byte - watcher": hidden terminal tabs unmount their xterm after a cold-park - hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects - alive while parked (`terminalHiddenViewParking`). See - [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md). -2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory": - renderer heap and live-terminal counts gate parking in the perf suite - (`tests/e2e/terminal-parked-memory.spec.ts`). -3. **Side-effect authority in main** — "Track terminal titles in main with - all-titles ordering", "Move terminal side-effect authority to a main facts - channel", "Complete terminal side-effect facts coverage", "Finish terminal - side-effect authority migration": every local/daemon/SSH PTY byte is - side-effect-parsed once in main and delivered as `pty:sideEffect` facts - (`terminalMainSideEffectAuthority`). See - [`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md). -4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views": - main drops renderer-bound bytes for hidden-marked PTYs after model - ingestion; delivery-interest registrations exempt sidecar byte consumers, - out-of-band restore markers latch model restore, and - `pendingDeliveryStartSeq` reconciles live output racing a restore - (`terminalHiddenDeliveryGate`). -5. **Model query authority** — "Answer hidden terminal queries from the - model", "Bridge renderer view attributes to the model responder", "Align - query authority contract and spawn-time ownership": hidden-dropped queries - are answered by the runtime emulator plus renderer-pushed view attributes, - and hidden-at-spawn PTYs are marked before byte one - (`terminalModelQueryAuthority`). See - [`terminal-query-authority.md`](./terminal-query-authority.md). -6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the - renderer's per-chunk hidden-skip eligibility grammar and the 10s codex - startup query window are deleted; the kill-switch-off fallback is the - bounded background queue with overflow-latched model restore. - -Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a -local repaint quirk. Renderer fallback paths retire only when their kill -switches do, and only after the equivalent model path has platform and TUI -golden coverage. diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md deleted file mode 100644 index 02b4871393e..00000000000 --- a/docs/reference/terminal-query-authority.md +++ /dev/null @@ -1,372 +0,0 @@ -# Terminal Query Authority - -Status: Shipped — Phase 5 of the terminal model/view architecture, kill -switch `terminalModelQueryAuthority` (default on). Builds on -[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this -phase **amends invariant 6**), -[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md) -(Phase 3), and the Phase-4 hidden-delivery gate -(`src/main/ipc/pty-hidden-delivery-gate.ts`). - -## Problem - -Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion -(`src/main/ipc/pty.ts:1426,1515`, `src/main/ssh/ssh-relay-session.ts:931`). -Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks -waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang -TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4 -hidden skip latch had the same hole (only mode 2031 and the 10s codex startup -window answered), so this is not a regression — it is the long-standing gap -this phase closes. - -Contract invariant 6 ("the model must never answer queries") was written -against a real bug: the daemon emulator replying ahead of the renderer with -default-xterm values (the OSC-11 default-black-background race, -`headless-emulator.ts:86-97`, pinned by `session.test.ts:163-190`). The danger -was never "the model answers" — it was **two answerers for the same bytes**, -one of them with wrong values. Phase 5 keeps the singularity and fixes the -values. - -## Decision: the delivery decision is the reply decision - -For a capable fresh agent spawn there is one earlier, bounded authority: the -PTY source owner's startup ingress transaction. After shell-ready marker -preprocessing and before model, persistence, or replay, it may consume exact -OSC 10/11 queries and write canonical 16-bit, ST-terminated replies from -validated renderer-pushed colors. It records each reply candidate before the -provider write so synchronous output callbacks remain ordered. Consumed query -spans still advance the raw provider sequence but emit an empty transformed -span, so no renderer or model sees and answers them again. - -This startup authority opens only when fresh creation atomically installs a -versioned intent before buffered subprocess output can be released. Local -reattach, daemon `createOrAttach` reattach, relay `pty.attach`, unsupported -daemon/relay versions, WSL, and legacy sessions never arm it. It closes after -both slots are answered, its deadline expires, the process ends, or main sends -an ordered close after a consuming view or hidden-runtime owner is established. -Closing query interception preserves already-written echo candidates until -they match, expire, or drain. Transport attachment by itself is not a view -handoff. - -Main answers a query **iff main dropped the chunk that carried it**. The same -per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that -decides renderer delivery decides reply ownership, evaluated once, -synchronously, at ingestion: - -- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via - `Terminal.onData` → `transport.sendInput`, unchanged. -- Hidden-marked, no delivery interest → chunk dropped → main answers from the - runtime headless emulator, via the provider input path (`provider.write`, - same path as `pty:write`; daemon shell-ready write gating and the SSH relay - write apply unchanged). -- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both - sides). - -The source ingress is the only exception before this delivery decision exists. -Queries emitted after its ordered close follow the three rules above. - -This is structurally exactly-one-responder: a chunk is delivered or dropped, -never both, and each side only answers bytes it actually parsed live. The -mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist -from Phase 4 and are reused, not duplicated. - -Rejected alternatives: - -- **Fact-based renderer replies per query class** (the mode-2031 pattern - generalized): needs a main-side detection grammar per query, a fact round - trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the - emulator is the only state for a hidden pane. The 2031 fact stays because it - is subscription registration, not a state query. -- **Emulator always answers**: re-creates the OSC-11 double-reply race for - visible panes. Never. - -## Mechanism: forwarded emulator onData, not a new grammar - -`HeadlessEmulator` has `onData` wiring behind a per-write capture flag. -For static and model-state queries, xterm core **is** the query grammar: the -runtime emulator runs the same xterm version with equivalent options as the -renderer pane, so main's reply set equals the visible renderer's by -construction — verified empirically against the bundled headless build: -DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including -DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS -(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching -visible behavior today. The headless build has **no theme service**: OSC -4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option -set, so the view-attribute class is answered by responder-registered parser -handlers instead (below) — never by core defaults. - -Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and -attached to the emulator `writeChain` link (the mark can flip between -ingestion and an async write; the decision must not be re-read at reply time): - -1. gate enabled (`terminalMainSideEffectAuthority` and - `terminalHiddenDeliveryGate` both on) AND new kill switch - `terminalModelQueryAuthority !== false`; -2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData` - — same module state, same tick as the drop sites); -3. the write is live PTY data — never `seedHeadlessTerminal`, - `maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay - (main-side replay guard, mirror of the renderer's `replay-guard.ts`); -4. no remote view subscriber is attached to the PTY (runtime terminal-RPC - subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop - xterm receiving the multiplexed stream answers with view authority, exactly - like a visible local pane. Legacy JSON `terminal.subscribe` streams **do** - register as view subscribers and suppress, even when the consumer is a - read-only watcher — deliberately conservative, because the stream may feed - an older live xterm view and a withheld reply (the pre-Phase-5 status quo) - is strictly safer than a double reply. Consumers that never register a - stream (CLI `terminal.read`, automation observers) do not suppress — they - also do not answer; that bounded no-reply case matches today's behavior. - -Mobile streams preserve that singularity across snapshot startup and multiple -views. The mobile WebView suppresses `onData` during snapshot replay, then -enables replies at an explicit live-output boundary. Clearing the WebView drops -that replay queue and resumes live reply authority immediately. If a live query -arrived while the initial snapshot was being built and its output sequence is -covered by that snapshot, runtime RPC re-emits only the bounded query sequence -after the snapshot; ordinary covered output stays deduplicated. `terminal.send` -accepts the resulting `inputKind: query-reply` only from the earliest active -phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest -is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not -change reply ownership; passive desktop-mode watchers are excluded. Peer phones -are rejected, the next subscriber is promoted on unsubscribe, and desktop -parser and capability handlers stay silent until desktop retakes the driver. -Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile -client never forwards replies to a host that lacks it, because such hosts -strip `inputKind` and would treat the bytes as floor-taking shell input. -Residual: a desktop→mobile driver handoff has a bounded double-reply window -until the async driver-change event reaches the desktop renderer's cached -driver map (`isPtyLocked`). - -Everything the emulator emits outside a forwarding window is discarded, which -also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes -triggered by option mutations). - -## Reply classes - -| Class | Queries | Answer source | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | -| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | -| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | -| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | -| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | -| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | - -### View-attribute bridge - -Renderer→main push, `pty:terminalViewAttributes` — one global snapshot, -not per-PTY: the composed terminal `ITheme` (from -`applyTerminalAppearance`, `terminal-appearance.ts`), -`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme -mode (`resolveTerminalColorSchemeMode` — the same source as the existing -hidden 2031 reply). Pushed on renderer startup and on every theme/settings -apply. - -Main consumes it two ways: - -- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options - inside the replay guard; xterm core then answers DECRQSS DECSCUSR and - DECRQM 12 with renderer-true values (verified working headless). -- Palette and color-scheme replies come from responder-registered parser - handlers on the emulator (`registerOscHandler` 4/10/11/12, - `registerCsiHandler` for DSR ?996n), because the headless core cannot - answer them. The OSC handlers see SET payloads too, so runtime OSC - 4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are - tracked per PTY and layered over the pushed base palette — matching what - the renderer's theme service reports for a visible pane. - -Staleness rules: replies use the last push; a theme flip is stale for at most -one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push). -**Before the first push main answers no view-attribute query** — a fabricated -default would resurrect the default-black OSC-11 bug; silence is the -documented hidden status quo. - -### Kitty keyboard flags - -`vtExtensions.kittyKeyboard: true` is enabled in `HeadlessEmulator`, matching -`buildDefaultTerminalOptions` (`pane-terminal-options.ts:50`). Risk is low: -for the write-only daemon use, keyboard state never alters serialization; the -change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring -them, and lets the responder answer `CSI ? u` with the flags the hidden app -actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes` -for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty -flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty -reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`) -stays authoritative. Slice 3 wires the re-seed consumer: the daemon -warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn -result into `seedHeadlessTerminal`, which applies them to the fresh runtime -emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding -window), so hidden `CSI ? u` reports the flags the hidden app actually -pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer -`?0u`; protocol-conformant programs re-push. - -### ConPTY DA1 variant - -The provider kind is known main-side: mirror `isLocalNativeWindowsPty` -(`windows-pty-compatibility.ts:59`) from the spawn record (local/daemon -provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the -emulator parser (the main-side twin of -`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by -the forwarding predicate. The override is installed at emulator creation and -retrofitted when the spawn mark lands (daemon stream data can create the -emulator before the awaited spawn response marks the PTY). ConPTY blocking on -a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is -closed by the slice-3 `initiallyHidden` spawn flag (races section). - -## Suppression: when main never replies - -- A startup query consumed by the capable source owner; it never enters the - main runtime model. -- Visible or unmarked PTY (chunk was delivered). -- Renderer delivery interest registered (chunk was delivered to a sidecar). -- Remote-runtime (`remote:`) PTYs — never markable - (`isHiddenDeliveryGateManagedPty`), bytes never transit local main. -- Remote view subscriber attached (mobile/web/remote desktop owns replies). -- Seed/hydration/snapshot writes into the emulator, and option pushes. -- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an - independent off switch for the responder alone. -- The **daemon** emulator: never, under any setting. The responder lives in - main's runtime only; `session.test.ts:163-190` stays pinned verbatim. - -## Transition races - -Worst cases, per direction: - -- **visible→hidden**: chunks delivered between the visibility flip and the - mark landing in main are hidden-skipped by the renderer write path without - query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden - skip behavior, bounded by one renderer→main IPC hop. After the mark lands, - main answers everything it drops. -- **hidden→visible**: unmark consumes the drop latch and emits the restore - marker; the snapshot replay is replay-guarded, so queries main already - answered are never re-answered from the snapshot; post-unmark live chunks - are answered by xterm once (restore-queued live chunks reply late, not - twice). -- **Split queries across the drop/deliver boundary**: neither parser saw the - whole sequence → no reply; the restore marker resets renderer cross-chunk - state and replay hygiene resets the parser. At-most-once holds. - -Safe-side rule per class: duplicates are structurally impossible (one decision -point per chunk); where the race costs anything it costs a missing reply. -That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or -tolerate silence, as they did for every hidden pane before this phase). The -one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible -pane answers it from the renderer xterm. A PTY spawned hidden previously had -no answerer until the renderer's hidden mark landed in main (one IPC hop -after spawn). Slice 3 closes that window with the `initiallyHidden` -spawn-record flag: the renderer declares hidden-at-spawn on `pty:spawn` -(never for remote-runtime transports), and main marks the PTY hidden before -the first byte — pre-spawn for -daemon-host sessions whose id is minted up front, immediately after -`provider.spawn` resolves otherwise — so the gate and responder own queries -from byte one. The pane's first visibility sync then re-marks or unmarks -through the existing Phase-4 machinery (unmark emits the restore marker for -any spawn-window drops). - -## Invariants - -1. Exactly one party may answer any query. Before normal ownership is known, a - capable fresh-session source ingress may consume OSC 10/11. After its - ordered close, the chunk's delivery decision chooses: delivered → the - consuming live view's xterm; dropped → main's model responder; - replayed/seeded → no one. Each decision is captured once at ingestion. -2. Main answers only from live PTY bytes parsed by the runtime emulator — - never from snapshot, seed, hydration, or option-push writes. -3. View-attribute answers are renderer-true or absent: no reply is ever - fabricated from emulator defaults (the OSC-11 lesson). -4. The daemon emulator stays write-only; daemon subprocess query writes stay - zero (`session.test.ts` pins are permanent). -5. Reply parity is structural for static and model-state classes: same xterm - core, equivalent options, no hand-rolled grammar — the only overrides are - the documented ConPTY DA1 variant and the view-attribute parser handlers - the headless core cannot serve. -6. Remote views keep view authority; main yields whenever a remote view - subscriber is attached. When multiple desktop/mobile views coexist, the - terminal driver and server-side mobile election keep one reply writer. - -**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is -replaced by: - -> 6. Terminal query authority is singular and structural: the party that -> writes a chunk into a live terminal answers its queries. Visible renderer -> and remote views keep xterm authority. Chunks dropped by the -> hidden-delivery gate are answered exactly once by the main model -> responder, from runtime-emulator state plus renderer-pushed view -> attributes. Replayed, seeded, or snapshot bytes are answered by no one. -> The daemon emulator never answers. - -The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11, -or theme-sensitive queries" splits into: daemon emulator never answers -(unchanged pins) / runtime responder answers only hidden-dropped chunks. The -side-effect authority matrix row "DECSET 2031 reply — query authority stays -with the view (contract invariant 6)" gains a pointer here; its reply path is -otherwise untouched in this phase. - -## Test strategy - -- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs - interest-suppressed; each reply class; seed/hydrate silence; remote- - subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip - between ingestion and async emulator write (captured decision wins). -- Parity harness: shared query byte fixtures through a renderer-configured - xterm (onData capture) and through the responder; assert byte-identical - replies for static + model-state classes, and for view-attribute classes - after an attribute push. -- `session.test.ts:163-190`: assertions stay; the comment is updated to name - the main responder (not "the renderer") as the hidden answerer. -- E2E: hidden `claude /theme` reports the configured theme; hidden TUI - blocked on CPR/DA unblocks while gated; reveal shows no stray reply - fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and - `terminal-hidden-view-parking.spec.ts` stay green. - -## Cut-offs (shipped as three stacked slices) - -1. **Responder core.** Emulator onData wiring + per-write capture + main - replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`); - static + model-state classes; ConPTY DA1 override; remote-subscriber - suppression; `terminalModelQueryAuthority` switch; unit + parity tests. - Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock. -2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor - option application under the guard, responder OSC/DSR parser handlers with - per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e. -3. **Contract alignment.** Invariant-6 amendment in the contract doc, test - bullet split, `session.test.ts` comment, side-effect matrix pointer, and - the Phase 6 prerequisites below recorded as accepted. - -## Phase 6 (delete skip grammar + startup window): prerequisites from this design - -Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s -codex startup renderer-query window are deleted. Kill-switch-off hidden panes -fall back to the pre-grammar path — hidden bytes ride the bounded background -scheduler queue; overflow latches the model-snapshot restore — and never run -a per-chunk content scan. - -Accepted and shipped in slice 3 (except where noted): - -- **Mark-before-first-byte** (shipped): panes spawned without a visible view - are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn` - (spawn-record flag, not a renderer round trip) so startup queries — - including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6 - removed the codex exclusion with the window: codex spawns are main-owned - from byte zero too, the responder answering their startup probes. -- **Attributes before spawn** (shipped): the renderer pushes composed view - attributes once at app start (right after settings load, before terminal - reconnect/spawn), so spawn-time view-attribute queries no longer fall into - the silent-until-push rule. Per-pane appearance applies keep re-publishing - through the same deduped publisher. -- **Daemon shell-ready write gating** (verified): responder replies through - `ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never - dropped, and the queue flushes at the shell-ready marker or the 15s - `SHELL_READY_TIMEOUT_MS` bound (`session.ts`). The codex window was removed - with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches - as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains - worth adding. -- With the skip grammar deleted, every chunk is either written to a live - xterm or dropped — the delivered-but-skipped no-reply gap disappears and - the only remaining loss window is the mark IPC race. -- **2031 consolidation** (optional follow-up): move the subscription registry - into the responder (the headless core cannot serve 997 pushes any more than - it can ?996n) and push 997 flips from the attribute cache, retiring the - `2031-subscribe` fact reply, the parked responder, and the parked-tab - theme-flip gap. diff --git a/docs/reference/terminal-session-lifecycle.md b/docs/reference/terminal-session-lifecycle.md deleted file mode 100644 index 0b11fd15557..00000000000 --- a/docs/reference/terminal-session-lifecycle.md +++ /dev/null @@ -1,580 +0,0 @@ -# Terminal Session Ownership and Teardown - -**Status:** Incident fix and pane-authority transfer implemented -**Date:** 2026-07-13 -**Incident:** [`../../pty-exhaustion-agent-session-leak.md`](../../pty-exhaustion-agent-session-leak.md) -**Related contracts:** [`terminal-model-view-contract.md`](./terminal-model-view-contract.md), -[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) - -## Summary - -Terminal process lifetime must be owned by explicit product intent, never by -whether a React view happens to be mounted. - -Orca currently preserves a PTY when a terminal view detaches, which is correct -for view parking, tab moves, renderer reload, and warm reattach. It also relies -on `TerminalPane` unmount to destroy a PTY after a tab is closed. Hidden-view -parking invalidated that implicit invariant: a parked tab has no mounted pane, -so closing it removes renderer state and observers without terminating the PTY. -Agent resume records survive the same close and later interpret the missing pane -as failed recovery, launching the deliberately closed session again. - -This design establishes one authoritative lifecycle contract: - -- **Close tab or pane:** permanently retire the surface, terminate every PTY it - owns, and revoke every resume record for its panes. -- **Detach or park:** remove only the view connection; preserve the PTY and its - durable identity. -- **Sleep:** intentionally checkpoint resumable agents, terminate the PTYs, and - preserve only the checkpoint needed for wake. -- **Quit or reload:** follow the configured persistence policy; warm detach is - allowed, but it is not a tab close. - -The immediate implementation makes the existing terminal-tab state close the -authoritative compatibility boundary for explicit retirement while adding an -explicit natural-exit reason for callers that are only reconciling an already -dead PTY. A follow-up separates the command and reducer APIs once all direct -store callers have migrated. - -## Implementation status - -The incident patch ships the retirement planner, provider-aware close routing, -unified-only retirement, parked watcher/candidate disposal, resume-authority -revocation, explicit natural-exit handling, late-binding rejection, and direct -background-launch ownership checks. It also adds exact pane retirement and a -persisted physical-to-owner pane-key alias for detach, plus a live Electron -parked-close gate. Focused tests cover local, SSH, split, parked, shared, -paired-host, ordinary runtime, pane-transfer, and late-spawn paths. - -`orca terminal close --tab` extends that same ownership boundary to automation. -With an attached renderer, main sends a request/reply close, the renderer runs -the canonical retirement path, persists a fresh host-partitioned session, and -forces the main store to disk before acknowledging success. Without a renderer, -the runtime applies the equivalent immutable projection to the persisted -aggregate tab, removes every split binding and resume record, safely selects a -surviving browser/editor/terminal surface, stops exact live or durable SSH PTYs, -and flushes before returning. The ordinary `terminal close` pane/session -contract is unchanged. - -This durability boundary does not use replay fences or tombstones. Renderer -session writes are ordered through the same IPC sender, and a pending debounced -write rebuilds from the latest store state, so the forced fresh snapshot is not -followed by a captured pre-close topology patch. Headless closes update the -single main-owned session projection directly. - -Origin-aware defensive expiry remains a follow-up. Immediate deletion on -explicit close fixes the incident without applying a wall-clock policy that -could invalidate intentional long-lived `worktree-sleep` checkpoints. - -## Goals - -1. Closing a terminal tab submits retirement for all exclusively owned local, - daemon, WSL, SSH, or runtime PTYs, including every split pane, whether its - views are mounted or parked. -2. Closing a terminal pane terminates only that pane's PTY; detaching a pane to - another tab preserves its PTY, hook identity, and resume authority under the - new owning pane. -3. Closing a tab permanently removes its agent resume authority. App restart, - worktree activation, mobile wake, and periodic capture must not resurrect it. -4. Parking, tab-group moves, renderer reload, and warm reattach keep their - existing process-preservation behavior. -5. Remote-runtime and web-session surfaces are terminated by their owning host, - not by sending a host-scoped ID to the local PTY provider. -6. Teardown is idempotent and safe under duplicate close requests, late PTY - exits, late agent-hook events, in-flight spawns, and provider disconnects. -7. A future bookkeeping regression cannot grow without bound: unowned defensive - resume records have an origin-aware expiry, and provider-owned detached - sessions gain a bounded orphan policy in the daemon-lease phase. - -## Non-goals - -- Changing terminal output, snapshot, replay, query-response, or hidden-delivery - behavior. -- Killing processes that deliberately daemonize away from a plain user - terminal's process group (nohup-style survivors remain user intent there). - For **agent sessions**, close/kill additionally terminates the snapshotted - descendant tree — including detached-pgid children the PTY's SIGHUP cannot - reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with - same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL). - Completed process tables are never reused as signal targets, and identity - checks use C-locale timestamps from the source scan. Later requests start a - fresh same-turn-coalesced successor inside their own deadline instead of - waiting behind older scans. A session is marked as terminating before capture - and keeps request ownership through natural exit, so reattach, duplicate kill, - and graceful-to-immediate upgrade cannot race the snapshot; descendant signals - still require the exact root session/handle to be live. Windows and SSH-hosted - PTYs keep the previous foreground-tree contract for now. -- Changing agent-provider resume commands or permission flags. -- Making a UI close wait for a remote process to exit before the tab disappears. -- Replacing worktree sleep with tab close. Sleep remains resumable by design. -- Claiming that a disconnected SSH relay process is already dead when main can - only tombstone its app-scoped ID. Durable relay-side kill-on-reconnect is a - follow-up. - -## Terminology - -- **Surface:** a terminal tab or a pane within a split terminal tab. -- **View:** a renderer xterm or pane-less parked watcher observing a surface. -- **Session:** the provider-owned PTY identified by a PTY ID. -- **Resume authority:** a `sleepingAgentSessionsByPaneKey` record that permits - Orca to launch an agent-provider resume command. -- **Retire:** permanently close a surface and revoke its process and resume - ownership. -- **Detach:** disconnect a view while preserving the session for reattachment. - -## Open-source prior art - -The lifecycle distinction is established in mature terminal implementations: - -- VS Code's terminal instance disposal calls process-manager disposal, and the - process manager marks the process `KilledByUser` before shutdown. Its separate - `detachFromProcess` path deliberately clears the client reference without - shutting down the process. It also shuts down a process whose asynchronous - creation completes after the manager was disposed. See - [`terminalInstance.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L1287-L1325) - and - [`terminalProcessManager.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts#L207-L239). -- tmux exposes the same contract as separate commands: `detach-client` removes - a client while leaving the session intact, whereas `kill-session` destroys - the session and its windows. See - [`tmux.1`](https://github.com/tmux/tmux/blob/42b3ea0d7411acb4cd0357a3c2829d986b455918/tmux.1#L1177-L1218). - -Orca needs the same semantic split, extended across local, WSL, SSH, persistent -daemon, and remote-runtime ownership. The important precedent is the explicit -intent boundary, not a particular framework or process API. - -## Non-negotiable invariants - -1. View lifetime and process lifetime are independent. Mount and unmount may - attach or detach observers; they do not decide whether a session should live. -2. Every live PTY has an owning surface or an explicit bounded detached state. -3. A user close is terminal: once accepted, no status event, session snapshot, - or runtime replay may recreate the closed surface or resume its agent. -4. A tab's retirement candidates are the union of its live index, tab row, - persisted split layout, last-known relay ID, deferred SSH ID, and pending - reconnect ID. A candidate is killable only when no other live surface claims - it. -5. Provider routing follows the execution owner: - - local/daemon/WSL and app-scoped SSH IDs use `pty.kill`; - - `remote:` runtime mirror IDs are never passed to the local PTY provider; - - paired web tabs are closed through the host session RPC. -6. Teardown requests are idempotent. Repeated kill, exit-after-kill, and - close-after-exit must converge on the same empty state. -7. Resume-record validity is origin-aware. Intentional `worktree-sleep` records - are not expired by agent-status freshness; unowned defensive `live` and - `quit` records use a documented decision-time horizon. -8. Natural process exit and explicit user retirement are distinct intents. - Reconciliation after an exit must not silently adopt destructive user-close - semantics. - -## Lifecycle matrix - -| Intent | View | PTY | Resume record | Persistent tab state | -| --------------------- | ------------------------------------------ | ---------------------------------- | --------------------------------------------------- | -------------------- | -| Switch tabs/worktrees | hidden or parked | keep | keep/update | keep | -| Move pane/tab group | detach, then remount | keep | keep | move | -| Renderer reload | detach | keep when warm persistence applies | keep | keep | -| App quit | detach or terminate per persistence policy | policy-dependent | capture live agents | keep | -| Sleep worktree | unmount | terminate | capture intentional sleep checkpoint | keep identifiers | -| Close split pane | destroy | terminate that pane | delete that pane record | remove pane | -| Close terminal tab | destroy or already absent | terminate all tab PTYs | delete all tab records | remove tab | -| Remove worktree | destroy | terminate all worktree PTYs | delete all worktree records | remove worktree | -| PTY exits naturally | disconnect | already dead | clear or retain completed evidence per agent policy | reconcile surface | - -## Current failure - -The current close path deletes `ptyIdsByTabId[tabId]` and the tab layout, then -relies on a mounted `TerminalPane` cleanup to call `transport.destroy()`. A -parked tab has already run the detach branch and has no mounted cleanup left. -`disposeParkedTabWatchers` only unregisters observers. The provider session is -therefore alive after Orca discards its last renderer-side owner. - -The same state close drops live agent status but not -`sleepingAgentSessionsByPaneKey`. Worktree activation intentionally fresh-resumes -records that no preserved pane can own. A deliberately closed pane therefore -looks like a failed restore. The existing age check compares `capturedAt` with -`updatedAt`; that detects a status that was stale at capture time but never -expires a record as wall time advances. - -## Proposed architecture - -### 1. Authoritative tab retirement plan - -Before deleting any tab-scoped state, build a `TerminalTabRetirementPlan` from -one store snapshot: - -```ts -type TerminalTabRetirementPlan = { - tabId: string - worktreeId: string | null - ptyIds: string[] - localOrSshPtyIds: string[] - runtimeTerminals: Array<{ environmentId: string; handle: string }> - sharedPtyIds: string[] - paneKeys: string[] -} -``` - -`ptyIds` is the deduplicated union of: - -- `ptyIdsByTabId[tabId]`; -- the tab row's legacy `ptyId`; -- every value in `terminalLayoutsByTabId[tabId].ptyIdsByLeafId`; -- `lastKnownRelayPtyIdByTabId[tabId]`; -- `deferredSshSessionIdsByTabId[tabId]`; -- `pendingReconnectPtyIdByTabId[tabId]`. - -This snapshot must be built before any of those maps are pruned. PTY IDs are -classified structurally with `parseRemoteRuntimePtyId`, not by assuming a host -or platform from the current active workspace. Before shutdown, the planner -subtracts IDs referenced by another tab row, live index, split layout, or relay -and reconnect map. A partially completed pane move must not let closing the -source tab kill the target tab's session. - -The immediate compatibility boundary is `closeTab`, because several production -paths still call the store action directly. Its options carry an explicit -`reason: 'user' | 'pty-exit' | 'cleanup'`; `user` and `cleanup` retire, while -`pty-exit` reconciles an already-dead session without killing siblings or -revoking crash-recovery policy by accident. Making only the rendered tab-bar -action destructive would leave background launch cleanup, floating terminal, -onboarding, runtime notifications, and future direct callers vulnerable. - -### 2. Teardown ordering - -A local tab retirement executes in this order: - -1. Snapshot the retirement plan and its execution-owner classifications. -2. Mark the tab recently closed so late agent-hook events cannot reintroduce - status or resume authority. -3. Dispose the whole tab's parked registry entry and captured candidates, and - silence teardown side-effect handlers while preserving deterministic exit - observation. -4. Submit `pty.kill` for exclusive local or SSH IDs and `terminal.close` for - exclusive ordinary runtime handles. A paired host-session close remains - owned by `session.tabs.close` and is not duplicated locally. -5. Atomically remove the tab, layout/binding maps, agent status, and resume - records from renderer state. -6. Let subsequent React unmount cleanup run idempotently. It is no longer the - process-lifetime authority. - -Steps 3 and 4 are issued before the state loses its IDs. The UI state mutation -remains synchronous and does not await provider exit. Completion is observed -with `Promise.allSettled` so rejections cannot become unhandled promises. The -current Phase 1 fallback for a rejected retirement is provider `listSessions` -plus Resource Manager orphan cleanup; durable retry inventory belongs to the -main-owned retirement phase. - -Remote-runtime mirrors are different: their `remote:` IDs are excluded from -local `pty.kill`. Ordinary runtime terminals retire through `terminal.close`. -Paired host-session tabs retire through `session.tabs.close`, which owns the -entire host tab graph. The local mirror may be pruned optimistically only after -the host close intent is recorded, as it is today. - -### 3. Resume-authority revocation - -Tab retirement removes every sleeping record whose: - -- map key begins with `${tabId}:`; or -- record has `tabId === tabId`. - -The explicit `record.tabId` check covers migrated or legacy keys whose key no -longer encodes the current tab identity. Sibling tabs and other worktrees retain -their records by reference when unchanged. - -The existing recently-closed tab registry remains the short-lived race guard -for hook events already in flight. It must prevent those events from creating a -new `origin: 'live'` resume record after retirement. Tests must cover both event -orders: close then late status, and status queued in the same turn as close. - -Periodic capture iterates only current live status entries. Once close removes -the live entry and resume record, the periodic pass has nothing to persist. - -### 4. Origin-aware defensive record expiry - -An unowned defensive `live` or `quit` record may expire when: - -```ts -Date.now() - record.capturedAt > DEFENSIVE_AGENT_RESUME_MAX_AGE_MS -``` - -Intentional `worktree-sleep` records are exempt from the agent-status freshness -window. Originless legacy behavior remains unchanged until its migration policy -is explicitly chosen. The same validator must run before desktop activation and -mobile/background wake so one entry point cannot bypass it. - -Expiry is defense in depth, not the primary close mechanism. A correctly closed -record is deleted immediately; it is not retained for 30 minutes. - -### 5. Pane close remains pane-scoped - -`PaneManager.closePane` already distinguishes `reason: 'close'` from -`reason: 'detach'`. Preserve that distinction with explicit pane authority: - -- `close` must clear the exact pane's resume authority, add a pane-scoped late - event tombstone, and destroy the pane transport; -- `detach` must atomically transfer or alias resume ownership because the agent - process retains its immutable source `ORCA_PANE_KEY`. - -The last-pane path routes to terminal-tab retirement so it receives the same -parked, split, SSH, and resume cleanup behavior as a tab-bar close. - -### 6. Provider-side safety net - -Client correctness is necessary but not sufficient for a daemon deliberately -designed to outlive renderer and app processes. Add a follow-up provider-owned -orphan policy: - -- track attachments/ownership leases rather than treating `detach` as a no-op; -- distinguish warm-reattach grace from ownerless retirement; -- reap sessions that have no owner after a bounded grace period; -- never idle-reap a session merely because it produces no output; -- scope leases to native, WSL, SSH provider, relay connection, or runtime host; -- expose orphan count and oldest orphan age in Resource Manager diagnostics. - -This hardening is not required to land the immediate close fix, because a lease -protocol changes daemon compatibility and needs its own migration. It is the -defense against a future client bookkeeping regression. - -## Provider behavior - -| Provider/session kind | Close operation | Notes | -| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- | -| Local native | `pty.kill(id)` | Main routes to the owning local/daemon provider. | -| Persistent daemon | `pty.kill(id)` | Explicit close overrides warm-reattach persistence. | -| Windows ConPTY | `pty.kill(id)` | Provider shutdown owns Windows process-tree semantics. | -| WSL | `pty.kill(id)` | Route by PTY ownership; do not infer from path separators. | -| SSH | `pty.kill(appScopedId)` | Main resolves the connection-specific SSH provider. | -| Disconnected SSH | `pty.kill(appScopedId)` | Tombstones locally and prevents fallback; relay-side death requires reconnect-aware follow-up. | -| Ordinary remote runtime | host `terminal.close` | Classify environment and handle from the `remote:` ID. | -| Paired web host tab | host `session.tabs.close` | Host owns the complete tab and pane graph. | -| Web client local mirror | optimistic prune after close intent | Host snapshot confirms final removal. | - -## Failure and race handling - -### Duplicate close - -The first close snapshots and retires the tab. Later closes find no target and -no-op. Provider shutdown is idempotent for an already exited session. - -### PTY exit races close - -Observers are unregistered before shutdown. If an exit event was already -queued, the recently-closed tab guard and missing tab make it a no-op. It must -not recreate a tab, completion row, resume record, or notification. - -### Spawn completes after close - -The transport already kills a PTY when its spawn resolves after `destroyed` is -set, but the two direct background launchers do not use that transport. Each -direct launcher must revalidate tab ownership immediately after `pty.spawn` or -`terminal.create` resolves and before writing any binding, layout, eager buffer, -subscription, or mount request. A retired local or SSH result is killed; a -retired runtime result is closed through `terminal.close`. - -### Provider shutdown fails - -The tab remains closed; reappearing would violate user intent. Report the -provider class through structured diagnostics and leave the session visible in -provider inventory so existing Resource Manager orphan cleanup can retry. A -durable pending-retirement inventory is a later main-process API and is not -claimed by Phase 1. Do not restore resume authority after a failed terminal -kill. - -### Remote host is unavailable - -Keep the local close intent until either a host snapshot confirms removal or the -existing intent TTL expires. If it expires, the host remains authoritative and -may republish the tab; log the failed close. Never pretend a local mirror prune -terminated the host PTY. - -### App exits during close - -Submit shutdown IPC before discarding the only local PTY identifiers. The main -process/provider owns completion after accepting the request. A future batched -shutdown IPC may return per-ID acceptance, but UI close does not wait for it. - -## Implementation plan - -### Phase 1: correctness patch (implemented) - -1. Add a small store-independent terminal-tab retirement planner that collects - all six ID sources, classifies execution owners, and excludes IDs shared by - another live surface. -2. Add an explicit close reason, update natural-exit callers, and make explicit - `closeTab` retirement dispose the whole parked-tab registry, submit local, - SSH, or runtime shutdown, atomically clear all reconnect maps, and remove - tab-owned resume records. -3. Add post-await owner checks to both direct background launchers so a late - local, SSH, or runtime spawn is terminated before any renderer binding. -4. Defer decision-time expiry until unowned defensive `live` or `quit` records - can be distinguished without changing intentional `worktree-sleep` or legacy - recovery behavior. -5. Add focused planner, store, launcher-race, and resume-policy tests. Keep one - local Electron parked-close proof as an RC release gate. - -### Phase 2: remaining API cleanup - -1. Rename the state-only reducer primitive so `closeTab` cannot ambiguously mean - either UI cleanup or lifecycle teardown. -2. Route all user and runtime close call sites through one domain command. -3. Keep explicit `detach` and `sleep` APIs; do not encode them as close options. -4. Add a development assertion when tab state is removed with provider PTY IDs - but no retirement plan. - -Exact pane retirement, persisted detach authority transfer, and unified-only -terminal routing landed with Phase 1 because exact-head review found they were -required to avoid introducing adjacent close regressions. - -### Phase 3: daemon ownership leases - -1. Define versioned attach/detach/retire lease messages. -2. Preserve warm reattach across renderer/app restarts within a bounded grace. -3. Reap ownerless sessions and expose pressure telemetry. -4. Add compatibility tests across the supported daemon protocol versions and - native/WSL/SSH host isolation. - -## Test plan - -### Unit/store tests - -- Closing an active single-pane tab submits exactly its PTY for shutdown and - removes all tab state. -- Closing a parked tab with no mounted transport still submits its PTY. -- Closing a split tab submits every unique PTY found across tab row, index, and - layout plus relay/deferred/reconnect maps, including IDs present in only one - source. -- Duplicate IDs are submitted once by the authoritative retirement plan. -- An ID referenced by another live tab is not killed when the source tab closes. -- `remote:` IDs are excluded from local PTY shutdown. -- App-scoped SSH IDs are included and are not mistaken for remote runtime IDs. -- All sleeping records for the tab are removed by key prefix or `record.tabId`; - sibling records retain identity. -- Periodic capture after close does not recreate the record. -- Late agent status after close cannot recreate live status or resume authority. -- Closing twice and close-after-exit are no-ops after the first retirement. -- A spawn resolving after close kills the newly returned PTY. -- A defensive live/quit record at the expiry boundary is retained; one - millisecond past it is cleared and never launched, while an older intentional - worktree-sleep record still resumes. - -### Renderer integration tests - -- Mounted tab close still destroys its xterm and terminates the PTY. -- Parked watcher disposal occurs before or with PTY shutdown and emits no final - completion/bell notification. -- Closing one split pane preserves the sibling; closing the last pane retires - the tab. -- Pane-to-tab detach preserves the PTY and resume record. -- Pinned-tab cancellation performs no shutdown; confirmed close does. -- Bulk close (`close others`, `close right`, kill-all) uses the same semantics. - -### Provider/main tests - -- Local, daemon, WSL, ConPTY, and SSH ownership route explicit close to the - correct provider. -- Disconnected SSH close tombstones the app-scoped ID and never falls through to - a local provider. -- Repeated `pty.kill` for the same ID is benign. -- Remote-runtime IDs cannot reach the local kill handler from renderer close. - -### Electron end-to-end regression - -1. Create a worktree terminal and start a deterministic long-lived child. -2. Switch worktrees and wait until the source worktree is cold-parked. -3. Close the parked tab. -4. Assert its exact PTY disappears from `pty:listSessions` and the child exits. -5. Restart/reload Orca and activate the worktree. -6. Assert no terminal or agent resume command is recreated for the closed pane. -7. Repeat with two split panes and with an SSH fixture where CI supports it. - -### Soak and pressure gate - -Run repeated create, park, close, reload, and reopen cycles. App-owned PTY count -must return to baseline after each cycle and remain bounded over the run. Record -the peak and final provider session count in CI artifacts. - -## Observability - -Emit a synchronous scheduled summary and an asynchronous completion summary: - -- tab/worktree identifiers in hashed or existing diagnostic form; -- number of PTY IDs discovered by each source; -- provider classification counts; -- submitted count, then fulfilled/rejected counts after `Promise.allSettled`; -- number of resume records removed; -- whether the tab was parked or mounted when retired. - -After the daemon-lease phase, Resource Manager should distinguish: - -- attached sessions; -- warm-detached sessions eligible for reattach; -- ownerless/orphan sessions; -- pending/failed explicit retirement. - -Alert locally before app-owned PTYs approach platform pressure. The warning is a -defense, not a substitute for lifecycle correctness. - -## Rollout - -1. Land the correctness patch without a feature flag. User close semantics are - restorative behavior, not an experiment. -2. Keep hidden-view parking enabled; disabling it masks the ownership bug and - forfeits its memory benefit. -3. Run focused unit/integration tests plus the live parked-close Electron gate. -4. Cut an RC and soak create/close/restart cycles while monitoring provider and - OS PTY counts. -5. Ship daemon leases separately behind protocol compatibility and telemetry. - -## Alternatives rejected - -### Disable hidden-view parking - -This restores the old accidental unmount behavior at significant memory cost -and leaves every other state-only close path fragile. It does not repair resume -records or daemon ownership. - -### Kill only from `TerminalPane` cleanup - -There is intentionally no pane during parking and some headless/runtime flows. -View cleanup cannot be process authority. - -### Clear only resume records - -This prevents resurrection but still leaks the live PTY until app/daemon exit. - -### Kill only the tab row's `ptyId` - -Split panes and partially reconciled layouts can own multiple IDs. The full -union is required. - -### Rely only on a daemon idle timeout - -Interactive agents can be legitimately idle for hours. Reaping by output -idleness loses user work; ownership, not activity, is the safe signal. - -### Await every provider exit before hiding the tab - -Remote and disconnected providers can take seconds or fail. UI retirement must -be immediate once the user confirms; provider completion is asynchronous and -observable. - -## Phase 1 acceptance criteria - -- A confirmed terminal-tab close submits retirement for every exclusively owned - session Orca can currently reach and reports rejected requests without an - unhandled promise. -- A closed agent session is never resumed by restart, activation, or mobile wake. -- Parking, move/detach, sleep/wake, and warm app reattach retain their documented - behavior. -- Split, local, SSH, and remote-runtime ownership are covered by deterministic - tests; Windows/WSL process-tree behavior remains a live-platform follow-up. -- PTY counts remain bounded across the soak scenario. -- No process-lifetime decision depends on React component mount state. - -## Full-contract follow-ups - -- Durable main-owned retirement intent and bounded retry inventory. -- Disconnected SSH relay kill-on-reconnect. -- Serializable daemon attachment leases with protocol-version fallback. -- Positive ownership validation for late hook events beyond the bounded recent- - close registry. diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md deleted file mode 100644 index 852a607d5bf..00000000000 --- a/docs/reference/terminal-side-effect-authority.md +++ /dev/null @@ -1,252 +0,0 @@ -# Terminal Side-Effect Authority - -Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch -`terminalMainSideEffectAuthority` (default on). Builds on -[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and -[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1). - -## Problem - -Main parses every local/daemon/SSH PTY byte before renderer delivery -(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`: -side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL -watchers; SSH feeds the same path from `wireUpPtyEvents` in -`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects -users see — bell unread/notifications, title transitions, agent-complete -notifications, command lifecycle, PR links — were derived a second time by -renderer byte parsers. That duplication forced Phase 1's watcher to parse -bytes, forced main to fabricate synthetic OSC title frames over `pty:data` -just so renderer parsers could see them, and blocked Phase 4 from ever -stopping hidden byte delivery. Phase 3 made main the side-effect parser for -every PTY whose bytes transit local main; the renderer byte parsers -(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte -mode) survive only for remote-runtime PTYs and the kill-switch-off fallback. - -## Authority Matrix - -"Main" means parsed once in `onPtyData` and delivered as derived facts. -Remote-runtime PTYs (`remote:`) never transit local main; the renderer -(`remote-runtime-pty-transport.ts:74`) stays their parser permanently. - -| Side effect | local-daemon | SSH | remote-runtime | -| --- | --- | --- | --- | -| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) | -| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer | -| BEL attention (OSC-aware stateful detector) | main | main | renderer | -| OSC 133;D command-finished exit code | main | main | renderer | -| GitHub PR-link scan | main | main | renderer | -| Command Code output scrape | main (per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main | renderer | -| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer | -| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer | - -## Main-Side Tracker - -- The side-effect core shared with the renderer processor lives in - `src/shared/terminal-output-side-effects.ts`: all-titles ordering via - `extractAllOscTitles` (coalesced working→idle transitions are why last-title - is insufficient — issue #1083), `normalizeTerminalTitle`, the literal - `cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`, - `src/shared/agent-detection.ts`), the `createAgentStatusTracker` - transitions, the stale-working-title 3s timer - (`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector - (`src/shared/terminal-bell-detector.ts`). -- One tracker per PTY on `OrcaRuntimeService`, lazily created like - `agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the - stale-title timer). -- It replaced the chunk-level last-title extraction in `onPtyData`: titles - feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters, - and pending-message delivery see intermediate transitions instead of only - the chunk's last title. PTY/leaf records keep the **raw** last title - (worktree `ps` and mobile tab titles expect raw); emitted facts carry - `(normalizedTitle, rawTitle)` like `onTitleChange`. -- No deferred drain in main — the renderer's setTimeout(0) batching - (`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which - does not exist in main. Main applies synchronously and batches the IPC per - flush. -- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own - last-title scan, untouched: synthetic titles must never reach it. - -## Event Transport: `pty:sideEffect` - -One batched main→renderer channel (`window.api.pty.onSideEffect`, -`src/preload/index.ts`). It is **not** routed through the pty dispatcher: -the renderer fact-consumer registry -(`terminal-side-effect-facts-handler.ts`) subscribes directly via -`window.api.pty.onSideEffect` — one channel subscription per renderer, with -exactly one registered fact consumer per PTY. Events are **facts, not -decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title), -`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries -`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf -records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY -`outputSequence`. - -Ordering rules: - -1. Per-PTY in-order; facts from one chunk are emitted in byte order (status - payloads, then titles in sequence, then bell — the renderer drain's order). -2. Deliberately **not** synchronized with `pty:data`: side effects must keep - advancing while renderer delivery is ACK-gated (contract invariant 1). A - completion title may reach the store before the visible xterm paints the - final output; that is acceptable — attention/title state is out-of-band UI - state, and today's renderer drain already decouples by many batches under - timer throttling. -3. No attention replay: facts emitted while no renderer is subscribed are - dropped. On transport attach/park-handoff the renderer pulls a title-only - snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces - the eager-buffer behavior where replay restores titles but is barred from - bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The - store handler ignores a replay title older (by `outputSequence`) than the - last live title fact it applied. - -## Renderer Store Handler (policy stays in the renderer) - -Notification semantics, all preserved across the authority flip: - -- BEL marks worktree+tab unread unconditionally — including the focused pane - (`onBell`, `pty-connection.ts`); pane unread only behind - `experimentalTerminalAttention`; keydown clears unread - (`onTerminalKeyDown`, `pty-connection.ts`). -- BEL's OS notification is delayed 250 ms and yields to a pending - agent-task-complete (`scheduleTerminalBellNotification`, - `pty-connection.ts`). -- working→idle starts the Claude cache timer (null settings = not hydrated, - treat enabled) and schedules completion with 250 ms grace + 1500 ms max - wait + detail-wait store subscription - (`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` / - `AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`, - `agent-task-complete-policy.ts`). -- Completion unread is suppressed only for the exact visible foreground pane - (`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread - has no such check. -- Dispatch-time liveness/staleness guards - (`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's - 5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`, - `src/main/ipc/notifications.ts`) remain the final gates. - -These need live renderer store state (PTY/layout maps, pane visibility, -settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer: -the pane-independent per-paneKey handler module -(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and -subsumes both `pty-connection.ts`'s callbacks and the parked watcher's -callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) — -one policy path whether the tab is mounted, hidden, or parked. Main holds -**no** notification timers; only the stale-title timer (parser state) lives -in main. - -## Synthetic Frame Reroute - -`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`, -`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`, -so synthetic agent-title/BEL frames enter the per-PTY tracker directly — -**not** `onPtyData`, so emulator state, tails, transcripts, and stats never -see them. The decorative-frame visibility gating -(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data` -copy survives only in kill-switch-off mode, where renderer parsers still -need the bytes. The visible xterm renders nothing from titles, but -`pane.terminal.onTitleChange` feeds `registerPtyTitleSource` -(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile -parity); main prefers its own tracked title over renderer snapshot -`lastTitle` in both serialize paths. Under main authority synthetic frames -no longer produce phantom ACKs for bytes main never metered (`ackPtyData`, -`pty-dispatcher.ts`). - -## Migration Switch and Double-Fire Prevention - -Authority is structural per PTY kind — the predicate is "bytes transit local -main", exactly the `shouldOwnAgentStatusInRenderer` split -(`pty-connection.ts`). One renderer-consulted kill switch -(`settings.terminalMainSideEffectAuthority`, default on, mirroring -`terminalHiddenViewParking`): when on, IPC transports and the parked watcher -do not register byte parsers for local/SSH and the store handler consumes -`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect` -events are ignored. Main always parses and emits (its internal consumers need -the tracker regardless); main consults the same setting only to keep the -legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly -one consumer per fact at any time — decided at transport/watcher creation, so -no per-chunk race. - -## Sidecar Consumers and Phase 4 - -Keep renderer byte access (input pacing / raw-output consumers, not side -effects): `agent-paste-draft.ts` (DECSET 2004 readiness), -`launch-agent-background-session.ts` (startup-injection pacing, onData -passthrough), `automation-session-observer.ts` (onData passthrough), and -`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for -parked tabs while the delivery gate is off). Their duplicated local OSC 9999 -store writes are gated off under main authority (the `onAgentStatus` -automation callbacks still fire; only the racing `setAgentStatus` store -writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active -`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a -ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main -authoritative, the parked watcher is purely fact-driven: byte parsing exists -only in kill-switch-off mode, and the 2031 reply comes from the -`2031-subscribe` fact when the gate is on (the byte responder sidecar only -when it is off). The watcher file is deleted outright only when the kill -switch retires — it returns as a byte parser only if remote-runtime tabs -ever become parkable. - -## Invariants - -1. Every byte is side-effect-parsed exactly once, by exactly one authority, - chosen structurally per PTY kind. -2. Attention facts never replay: snapshot/eager/attach replays restore title - state only. -3. Notification policy (grace timers, yielding, suppression, dispatch guards) - lives with the renderer store; main emits facts with ordering metadata. -4. Side-effect facts keep flowing while renderer byte delivery is - backpressured, parked, or stopped by the hidden-delivery gate. -5. Synthetic agent frames feed the model tracker, never the emulator, tails, - transcripts, or stats. - -## Test Strategy - -- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte - fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning - OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under - fake timers, OSC 133;D, split PR URLs) run through the renderer - `createPtyOutputProcessor` and the main tracker; assert identical ordered - fact sequences. -- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle - parity, tui-idle waiter transitions, synthetic ingestion); store-handler - tests reusing `parked-terminal-byte-watcher.test.ts` scenarios. -- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring, - `parked-terminal-byte-watcher.test.ts` (retires with the watcher); - `pty-transport*.test.ts` stay (processor remains for remote + kill switch). -- E2E gates that must stay green throughout: `terminal-attention.spec.ts`, - `droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`, - `terminal-parked-memory.spec.ts`; add main-authority bell/completion cases - (parked tab, focused-pane suppression, kill switch off). SSH parity is - exercised manually per the SSH test procedure before each slice ships. - -## Cut-Offs (shipped as four stacked slices) - -1. **Shared tracker in main.** Extract the processor core to shared, run the - per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity - tests. Main-internal consumers only; no IPC or renderer change. -2. **Authority flip.** `pty:sideEffect` channel, renderer store handler, - titles/bell/tracker authority to main for local+SSH behind the kill - switch; parked watcher stops byte parsing for those kinds. -3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`; - OSC 133;D and PR-link facts; mobile `lastTitle` source preference. -4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked - watcher shrunk to fact-driven mode (deletion waits on kill-switch - retirement), Phase 4 delivery-interest registration documented in the gate - design. - -## Open Items - -- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic - checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon - `Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is - derived from real PTY bytes only — synthetic hook title frames never reach - the daemon process, so that field cannot carry hook-driven titles. Today no - restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots - surface only the ANSI payload), so there is nothing to fix. Main-side - consumers of the renderer serializer's `lastTitle` (mobile snapshot reads - and the headless hydration seed) prefer main's tracked title. If a future - consumer starts reading checkpoint `lastTitle`, it must route through the - same tracked-title preference. -- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is - removed, the parked watcher's byte-parser mode, the renderer transport - parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all - become dead code and the watcher byte path can be deleted outright. diff --git a/docs/reference/terminal-switch-typing-lag-investigation.md b/docs/reference/terminal-switch-typing-lag-investigation.md deleted file mode 100644 index d8f6da5a608..00000000000 --- a/docs/reference/terminal-switch-typing-lag-investigation.md +++ /dev/null @@ -1,362 +0,0 @@ -# Terminal Switch Typing Lag Investigation - -Date: 2026-07-01 - -## Scope - -This note tracks the investigation into terminal input lag that appears after switching workspaces or terminals in a heavy packaged Orca profile. - -The user-visible symptom is that typing after switching back to a workspace can feel delayed for about one second. Text may appear all at once after the delay. The issue reproduced in the user's main packaged Orca app, but not reliably in lighter dev profiles. - -## Current Reproduction Setup - -- Main packaged Orca app is used for the meaningful repro. -- The main profile had roughly 150 terminals when the lag reproduced. -- The current worktree under test is `/Users/jinwoohong/orca/workspaces/orca/osprey`. -- The main app did not expose CDP, so browser-level renderer profiling was limited. -- The harness creates a throwaway bash terminal, switches away and back, sends a marker command, waits for a receipt file, and closes the throwaway terminal. -- Harness path: `.tmp/terminal-main-app-typing-lag/probe.mjs` -- The strongest repro switches through an old, output-rich workspace before typing in the probe terminal. -- Current deterministic alternate terminal: - `term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408` in the `triage-issues` workspace. - -Useful harness modes: - -```sh -ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=terminal-send ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs -ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs -ORCA_PROBE_RUNS=8 ORCA_PROBE_SKIP_SWITCH=1 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs -ORCA_PROBE_RUNS=4 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_SKIP_FOCUS=1 ORCA_PROBE_SAMPLE_RENDERER=0 ORCA_PROBE_ALT_TERMINAL=term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs -``` - -## Key Measurements - -### 2026-07-01 follow-up: cold visit vs warm revisit - -After the metadata-only `listSessions()` fix, the user reported a sharper -pattern: - -- First visit to a terminal, where the terminal briefly shows blank while the - renderer/webgl surface loads, does not show the typing lag. -- Revisiting that same already-mounted terminal brings the lag back. - -Code-level reproduction: - -- `connectPanePty` previously armed a one-shot input liveness check from - `noteVisibilityResume()`. -- The first `xterm.onData` input after a warm visibility resume called - `window.api.pty.listSessions()` before forwarding the byte with - `transport.sendInput(data)`. -- A focused unit regression captured the bad order as - `["listSessions", "sendInput"]`. - -Fix direction: - -- Terminal input no longer starts a renderer→main→daemon session enumeration. -- Hidden→visible lifecycle reconciliation and daemon missing-session exit events - remain responsible for stale pane cleanup. -- The focused regression now asserts that repeated warm resumes plus typing - produce zero `listSessions()` calls from the input handler. - -### 2026-07-01 follow-up: preserved old daemon after input fix - -After the input-handler fix, the main packaged profile still reproduced the -warm-switch lag because the live v18 daemon was preserved from an older build: - -- Fresh main-profile repro: - `.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-08-111Z.json` -- Direct daemon writes after switching took 661, 982, 998, 980, and 1000 ms. -- Receipt latency after the daemon write returned stayed low at 77-85 ms. -- No-switch control: - `.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-28-346Z.json` - ended with fast direct daemon writes once the probe terminal settled. - -The remaining source hot path was the visibility-resume dead-session sweep: - -1. Switching a warm terminal hidden→visible ran the lifecycle visibility effect. -2. The effect scheduled `reconcileDeadSessions`. -3. `reconcileDeadSessions` invoked `window.api.pty.listSessions()`. -4. A preserved old daemon still implemented `listSessions` by snapshotting every - live session, so the user's next daemon `write` queued behind that work. - -Fix: - -- Replace the automatic visibility-resume `listSessions()` sweep with a targeted - single-session liveness check. -- Main exposes `pty:hasPty(id)`, which reads provider-owned in-memory PTY state - and returns `null` when the provider cannot answer authoritatively. -- Renderer visible-resume asks only about each mounted pane's current PTY id. - The pane tears down only on an authoritative `false`; `true`, `null`, rejected - checks, remote-runtime ids, SSH ids, and stale/newborn races all fail open. -- Keep visibility resume process tracking and PTY-size reassertion intact. -- This preserves the recovery added by `a9ef6f916` for panes that missed - `pty:exit` while hidden, without putting a daemon-wide session enumeration on - the warm-switch/input path. - -Verification: - -- Focused vitest suite: `508` tests passed across terminal lifecycle, - pty-connection, dead-session reconcile, and PTY IPC. -- Headful fullscreen E2E harness: - `tests/e2e/terminal-warm-switch-no-list-sessions.tmp.spec.ts` - wraps main-process `pty:listSessions` with an 800 ms stall and then switches - warm workspaces and types into the terminal. -- Latest E2E artifact: - `.tmp/terminal-warm-switch-no-list-sessions/result-1782929853828.json` - showed `fullscreen: true`, `listSessionCallCount: 0`, and - `postTypeEchoLatencyMs: 10`. - -### CLI `terminal-send`, switch away/back - -Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T06-59-45-098Z.json` - -- Average echo latency was about 1841 ms. -- Receipt latency was roughly 836-1445 ms. - -### CLI `terminal-send`, no switch - -- Average echo latency was about 312 ms. - -### CLI `terminal-send`, switch away/back, 1000 ms settle before send - -- Average echo latency was about 574 ms. -- Receipt latency was mostly 1-227 ms, with one run around 684 ms. -- This suggests the problematic window is bounded and concentrated immediately after switch/resume. - -### Direct daemon request, switch away/back - -Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-04-43-497Z.json` - -- The harness opened its own daemon control socket and sent the real daemon `write` request directly. -- Daemon write response time was roughly 947-1142 ms. -- Receipt latency after the daemon write response was about 76 ms. -- Average echo latency was about 1385 ms. - -### Direct daemon request, no switch - -Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-05-12-496Z.json` - -- Daemon write response time was 0-45 ms. -- Receipt latency was roughly 76-85 ms. -- Average echo latency was about 258 ms. - -### Direct daemon request, output-rich workspace switch - -Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-22-06-789Z.json` - -- The alternate terminal was `term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408` in the `triage-issues` workspace. -- All four runs delayed. -- Direct daemon `write` response times were 1566, 1527, 1477, and 1333 ms. -- Average echo latency was about 1790 ms. -- A daemon CPU sample was captured at `.tmp/terminal-main-app-typing-lag/daemon-71111-sample-20260701032206.txt`. - -Latest reproduced run: -`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-25-513Z.json` - -- Two runs after switching through `triage-issues`. -- Direct daemon `write` response times were 1556 and 1582 ms. -- Receipt latency after the daemon write response was about 76-77 ms. -- This confirms the daemon request itself waits; once it returns, the PTY and shell process the input quickly. - -### Direct daemon ping loop during switch - -A direct daemon client sent `ping` requests every roughly 50 ms while the harness issued `orca terminal switch` away and back. - -- Pings before and after the switch were effectively immediate. -- One ping sent around 469 ms after switch start waited 1212 ms. -- No terminal input was involved in this probe. - -This is the strongest evidence so far that workspace/terminal switching creates a daemon event-loop stall. The typing lag is a user-visible symptom of the same stall, not the root trigger. - -### Direct daemon request, no switch - -Latest no-switch result: -`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-58-622Z.json` - -- First run had a direct daemon `write` response of 571 ms, likely a new-probe/settling artifact. -- Next two runs were 0-1 ms. -- No-switch is generally fast once the probe terminal is settled. - -### Pause after switching away - -- Adding `ORCA_PROBE_AFTER_SWITCH_AWAY_MS=1500` before switching back made direct daemon writes fast again. -- This indicates expensive work starts when Orca switches into or resumes the output-rich workspace, then spills into the immediate switch-back/write window. - -Latest pause-control result: -`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-29-58-568Z.json` - -- Direct daemon `write` response times were 0, 0, and 0 ms. -- Echo latency was about 343-363 ms. -- This bounds the problematic window to roughly the first 1-1.5 seconds after switching into/through the expensive workspace. - -### Synthetic heavy terminals - -- Synthetic split panes with large scrollback were not enough to reproduce reliably. -- A plain heavy scrollback split reproduced once in four runs, then larger synthetic scrollback stayed fast. -- A synthetic TUI repaint split stayed fast. -- The issue is therefore not simply "large output" or "any hidden repaint"; old retained/reattach/snapshot state is still suspect. - -### Output timestamp check - -- `lastOutputAt` did not advance when switching through the `triage-issues` terminal and away. -- That weakens the theory that a resume-triggered SIGWINCH caused the child TUI to emit a fresh repaint burst. -- The delay can happen without fresh PTY output from the child process. - -### Snapshot and resize controls - -Direct `getSnapshot` probes on real sessions were much cheaper than the observed lag: - -- Output-rich `triage` terminal: about 10 ms, roughly 122 KB response. -- Active `osprey` Codex terminal: about 57 ms, roughly 804 KB response. - -Synthetic resize pulses on throwaway heavy-scrollback terminals were also cheap: - -- Background 80x24 resize pulse: about 2 ms. -- Focused 232x86 resize pulse: about 2 ms. - -These controls weaken the idea that one ordinary snapshot or one ordinary resize explains a 1.2-1.6 second stall. The remaining suspicious shape is switch-time fanout: many warm reattachments, snapshots, visibility/resume requests, pending-output drains, or checkpoint-like work running serially in the daemon. - -### Daemon `listSessions` proof - -The daemon-only measurement identified the blocking request: - -```json -[ - { "type": "ping", "elapsedMs": 0 }, - { "type": "listSessions", "elapsedMs": 552, "count": 137 }, - { "type": "ping", "elapsedMs": 0 }, - { "type": "listSessions", "elapsedMs": 547, "count": 137 }, - { "type": "ping", "elapsedMs": 0 } -] -``` - -A second daemon-only queue test sent two `listSessions` requests and then a `ping` from another client: - -```json -{ - "totalMs": 1069, - "listSessions1Ms": 1068, - "listSessions2Ms": 1068, - "pingBehindListSessionsMs": 1034, - "sessions": 137 -} -``` - -This reproduces the same stall shape without terminal input or UI switching: a control request sent behind resume-time `listSessions` waits about one second. - -Source cause: - -- `TerminalHost.listSessions()` loops every live daemon session. -- For each session, it calls `session.getSnapshot()` only to read `cols` and `rows`. -- `getSnapshot()` serializes the headless xterm buffer, so a liveness/session-list request scales with terminal scrollback/state across the whole profile. -- Renderer visibility resume calls `window.api.pty.listSessions()` for dead-session reconciliation. With about 137 live daemon sessions, one resume-time list was about 550 ms; two back-to-back resumes were about 1.0-1.1 seconds. - -## What This Rules Out - -- It is probably not keyboard focus. Direct daemon writes reproduce the delay. -- It is probably not only renderer paint. Direct daemon writes wait before the shell receives bytes. -- It is probably not bash or node-pty readiness. No-switch direct writes are fast, and receipt latency after a delayed daemon response is low. -- It is probably not queueing only on Orca's normal daemon client socket. The direct-daemon harness uses a separate socket and still sees the delay. -- It is not caused by typing itself. A ping loop with no write showed the daemon stall during switching. -- It is unlikely to be a single normal `getSnapshot` or `resize` call, because direct controls for those operations are much cheaper than the observed stall. -- The resume-time dead-pane recovery is still necessary; the hot path should - use single-PTY liveness, not a global session list. - -## Current Conclusion - -Switching workspaces or terminals can create a short daemon/main busy window -when warm resume triggers global session enumeration. During that window, even a -direct terminal `write` request waits before the daemon services it. Once the -daemon services the write, the shell receives and processes the bytes quickly. - -The confirmed root for the remaining warm-switch lag is the visibility-resume -dead-session reconciliation path calling global `listSessions()` in profiles -with many preserved daemon sessions. The original dead-session recovery is valid; -the expensive primitive was the problem. - -## Leading Hypotheses - -1. Confirmed: resume-time dead-session reconciliation called daemon - `listSessions`, and older daemons synchronously snapshot every live session - to return cols/rows. -2. Confirmed: avoiding global `listSessions()` on warm resume removes the - request that queued ahead of first post-switch input. -3. Possible secondary contributor: switching to certain old or output-rich - workspaces may also reattach existing daemon-backed PTYs. The daemon - `createOrAttach` path synchronously calls `existing.getSnapshot()` before - responding. -4. Possible secondary contributor: hidden-output recovery, pending-output - draining, or another snapshot-like serialization path runs synchronously on - resume and delays request handling. -5. Less likely: workspace or terminal resume triggers visible-terminal - resize/SIGWINCH or TUI repaint output. The unchanged `lastOutputAt` - observation currently makes this less likely than `listSessions`. - -## Relevant Code Areas - -- CLI terminal send: `src/cli/handlers/terminal.ts` -- Runtime terminal send/focus/write: `src/main/runtime/orca-runtime.ts` -- Runtime PTY data handling: `onPtyData`, `trackHeadlessTerminalData`, hidden-output serialization in `src/main/runtime/orca-runtime.ts` -- Daemon request routing: `src/main/daemon/daemon-server.ts` -- Daemon session write/resize/output handling: `src/main/daemon/session.ts` -- Daemon-side headless terminal state: `src/main/daemon/headless-emulator.ts` -- Daemon adapter: `src/main/daemon/daemon-pty-adapter.ts` -- Main IPC PTY controller: `src/main/ipc/pty.ts` -- Renderer terminal resume: `src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts` -- Renderer PTY connection and resume size reassertion: `src/renderer/src/components/terminal-pane/pty-connection.ts` -- Renderer output scheduler: `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` -- Existing-session reattach snapshot: `TerminalHost.createOrAttach` in `src/main/daemon/terminal-host.ts` -- Session snapshot serialization: `TerminalSession.getSnapshot()` in `src/main/daemon/session.ts` -- Snapshot cost benchmark: `src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts` - -## Current Root-Cause Theory - -The root cause is using global session enumeration as a per-pane liveness check: - -1. Workspace switching makes a terminal pane visible again. -2. The renderer schedules dead-session reconciliation on hidden-to-visible resume. -3. The old reconcile path called `window.api.pty.listSessions()`, which reaches - the daemon `listSessions` RPC. -4. Older preserved daemons implement `TerminalHost.listSessions()` by calling - `session.getSnapshot()` for every live session. -5. With a heavy profile, those snapshot serializations block the daemon event - loop for about 550 ms per list. -6. A switch away/back can put a `write` behind two resume-time lists, yielding - about 1.0-1.6 seconds of delayed input. - -This theory fits the current evidence: - -- Direct daemon writes block, so the delay is below keyboard focus and normal CLI plumbing. -- Receipt handling after the daemon write response is fast, so the shell and PTY are not the bottleneck. -- No-switch writes are fast. -- Waiting after switching away lets the expensive resume work finish, so switching back and typing is fast. -- `lastOutputAt` does not advance, so the child process probably is not generating the expensive work. -- Synthetic fresh heavy output did not reproduce reliably, which points toward retained old session/state, request fanout, or workspace-specific resume behavior rather than simple line count. - -The correct fix is two-layered: - -1. Keep daemon `listSessions` metadata-only for builds where callers genuinely - need the global session list. -2. Do not use `listSessions` for warm-resume dead-pane recovery. Use - `pty:hasPty(id)` to ask about the pane's own PTY id, backed by provider - in-memory state. Close only on authoritative `false`; fail open on `true`, - `null`, unsupported providers, remote-runtime/SSH ids, and stale/newborn - races. - -## Investigation Constraints - -- Do not kill or restart the user's main packaged Orca app or daemon without explicit approval. -- Use throwaway terminals for probes and close them afterward. -- Keep temp harnesses and screenshots out of commits unless explicitly requested. -- Reproduce in the real main-app flow when possible; lighter dev profiles may not show the problem. - -## Verification Plan - -1. Unit-test that `pty:hasPty(id)` does not call provider `listProcesses()`. -2. Unit-test that targeted liveness still closes a missing local PTY and fails - open for live/unknown/stale cases. -3. Unit-test that terminal input and warm resume do not call `listSessions()`. -4. Re-run the headful fullscreen warm-switch E2E with `pty:listSessions` - artificially delayed and assert the count stays zero. -5. Re-run the main-app direct-daemon switch/no-switch harness when validating - against the user's heavy profile. diff --git a/docs/wsl-osc7-sleep-wake-cwd.md b/docs/wsl-osc7-sleep-wake-cwd.md new file mode 100644 index 00000000000..24d46b70660 --- /dev/null +++ b/docs/wsl-osc7-sleep-wake-cwd.md @@ -0,0 +1,153 @@ +# WSL OSC 7 Sleep/Wake CWD + +## Problem + +On native Windows, a WSL terminal can persist a fake UNC current working directory and then fail to wake. The deterministic trigger is a WSL shell emitting `file:///home/...` before sleep: + +- `osc7-file-uri.ts` treats every non-local authority as a UNC server when parsing Win32 paths. +- `Session` constructs its daemon `HeadlessEmulator` without the WSL execution context, so checkpoints can store `\\\home\...`. +- `OrcaRuntimeService` likewise gives every local Windows PTY Win32 OSC 7 semantics; `remotePosixAuthority` only protects POSIX SSH PTYs. +- `HistoryReader.restoreFromIncrementalLog` creates a third context-free emulator. A log containing the OSC 7 can therefore re-create the bad CWD even if the base checkpoint is valid. +- `DaemonPtyAdapter.doSpawn` prefers `restoreInfo.cwd` over the requested worktree CWD. `pty-subprocess.ts` later validates the fake UNC and rejects it before spawning WSL. + +Reproduction on Windows 11 build 26200, WSL 2.3.26.0, Ubuntu 24.04.1 LTS, and Orca 1.4.144-rc.4: + +1. Open a terminal in `\\wsl.localhost\Ubuntu\home\\...`. +2. Emit `OSC 7;file:///home//...`. +3. Sleep for 38-69 seconds, then wake. + +Both triggered attempts failed. The saved CWD became `\\\home\\...`; the original WSL UNC remained valid. A 60-second control sleep without the hostname OSC 7 passed. + +## Root cause and invariant + +OSC 7 authority semantics follow the PTY's execution environment, not Electron's host OS. A native Windows shell may legitimately mean `\\server\share` by `file://server/share`; a WSL shell means a POSIX pathname inside its already-known distro. Orca currently classifies local WSL PTYs as generic Win32 PTYs. + +The fix must establish one immutable `wslDistro: string | null` per PTY incarnation before any OSC 7 bytes or recovered history are parsed. The same value must drive daemon live parsing, incremental-history replay, runtime parsing, and legacy CWD recovery. URI authority must never select a distro. + +Resolve that value with the same precedence already used to launch WSL: + +1. distro in an explicit WSL UNC `cwd`; +2. distro in the worktree encoded by the daemon session ID; +3. trimmed `terminalWindowsWslDistro` selected for the spawn. + +Centralize this resolution with the existing WSL session-context code so the parser and subprocess cannot disagree. Do not infer local WSL context for `connectionId`/SSH PTYs. + +## Non-goals + +- Do not change native Windows UNC, POSIX SSH, or remote Windows drive-path semantics. +- Do not redesign terminal history, sleep, renderer link routing, or PTY identity. +- Do not probe WSL, DNS, the filesystem, or the network on the output path. +- Do not infer a distro from an OSC hostname or mutable global/default-distro state. + +## Design + +### 1. Pure conversion and parser context + +Move `toWindowsWslPath` to `src/shared/wsl-paths.ts` and re-export it from `src/main/wsl.ts` for existing callers. Preserve its current rules: lowercase `/mnt/` maps to a native drive; every other absolute Linux path maps under `\\wsl.localhost\`. `/MNT` and `/mnt/C` are case-sensitive Linux paths, not drvfs aliases. + +Add `wslDistro?: string` to `ParseFileUriPathOptions`. When present, `parseFileUriPathParts` must: + +- decode `url.pathname` exactly once; +- construct the path with `toWindowsWslPath(decodedPath, wslDistro)`, regardless of URI authority; +- continue returning the normalized URI hostname as metadata; +- reject malformed URLs/percent encoding as today and leave the prior CWD unchanged. + +Without `wslDistro`, retain every existing `pathFlavor` and `remotePosixAuthority` branch. + +Thread the option through `TerminalOscCwdTitleScanner` and `HeadlessEmulator`. Scanner options are constructor-scoped, so split sequences retain the same context without global state. + +### 2. Daemon and history ownership + +`TerminalHost` resolves the immutable distro before spawning. Pass it both to `createPtySubprocess` and to a new `SessionOptions.wslDistro`; `Session` passes it to its `HeadlessEmulator`. Store the resolved value on `Session` and return it on create/attach so a later window observes the live session's context instead of reinterpreting it from current settings. Make the response field additive/optional for compatibility with an older preserved daemon. + +Extend `HistoryReader.detectColdRestore` with optional parser context and use it for the scratch `HeadlessEmulator` in `restoreFromIncrementalLog`. Every detect path in `DaemonPtyAdapter`--initial detection, probe/create race recovery, and failed history seeding--must pass the same resolved distro. This is required; fixing only the live `Session` leaves incremental restore able to reproduce the bug. + +### 3. Narrow legacy checkpoint recovery + +Immediately after each cold-restore detection, normalize `restoreInfo.cwd` before it becomes `effectiveCwd` or `coldRestore.cwd`. Recovery runs only on native Windows with a resolved local WSL distro: + +1. Preserve an absolute drive path. +2. Preserve a `\\wsl.localhost\...` or `\\wsl$\...` path only when its distro matches the resolved distro case-insensitively. +3. Convert an absolute POSIX path through the resolved distro. +4. Repair the known legacy shape only when the UNC server equals `os.hostname()` case-insensitively: strip the server, interpret the share and tail as the Linux absolute path, then convert it. Thus `\\HOST\mnt\c\x` becomes `C:\x`. +5. For a mismatched-distro WSL UNC, another UNC server, a relative path, or malformed input, discard the recovered CWD and fall back to the current requested CWD (or the normal spawn default if none was supplied). Do not guess. + +Apply the corrected/fallback value to both subprocess creation and the returned cold-restore payload. This keeps runtime seeding, the sticky restore cache, `initialCwds`, and the next checkpoint consistent. The repair is idempotent and never mutates history files in place. + +### 4. Runtime ownership and races + +Add `wslDistro: string | null` to `RuntimePtyWorktreeRecord`; a boolean `isWsl` is insufficient for multi-distro parsing. Pass the resolved distro from the spawn result into runtime registration. For reconstructed local records, a WSL UNC worktree may supply a fallback distro via `parseWslUncPath`; never do this for SSH records. + +Daemon PTYs can emit output before `provider.spawn` resolves, and cold-restore seeding currently runs before `registerPty`. Register the expected daemon session's execution context before spawn, then replace it with the daemon-returned immutable value before snapshot/cold-restore seeding. If late discovery changes a context after a runtime emulator was created, discard that emulator and re-seed it from the authoritative provider snapshot; never keep a buffer whose CWD was parsed under mixed contexts. + +Clear execution context with the other per-PTY parser maps on exit, pruning, failed spawn, and provider-generation reset. Reusing a PTY ID must not inherit a prior distro. + +## Data flow + +```text +spawn cwd/session/preference + -> resolve one immutable local WSL distro + -> daemon Session scanner + HistoryReader replay scanner + -> decoded OSC 7 pathname (authority retained only as metadata) + -> toWindowsWslPath(pathname, distro) + -> daemon checkpoint + runtime live/headless CWD + -> sleep/wake with a valid WSL UNC or drive CWD +``` + +Legacy recovery is a boundary repair: + +```text +WSL cold restore + old checkpoint CWD + -> exact allowlist normalization + -> corrected spawn and coldRestore payload + -> next checkpoint naturally persists the corrected live CWD +``` + +## Consistency and failure modes + +- Context is per PTY incarnation, not global, so simultaneous Ubuntu and Debian panes cannot contaminate each other. +- The first creator owns a daemon session's immutable context. Concurrent/multi-window attaches consume the stored value; changed settings do not mutate a live session. +- A distro mismatch in externally changed history falls back to the requested worktree CWD instead of launching the wrong distro. +- Atomic checkpoint replacement remains unchanged. Concurrent restore callers may read different complete generations, but each normalizes before spawn and daemon create/attach still selects one live session. +- Missing context deliberately preserves current behavior. Tests must cover every local WSL construction path so this fallback cannot silently remain on the affected path. +- Native UNC and SSH behavior remain isolated because neither receives local WSL context. +- No filesystem/network work is added per output chunk; parsing remains bounded string work on completed OSC sequences. + +## Test plan + +- `osc7-file-uri.test.ts` and shared WSL-path tests: hostname/localhost/empty WSL authorities; hostname metadata; `/home`, `/`, lowercase `/mnt/c`, `/MNT`, `/mnt/C`; spaces/percent decoding; invalid encoding; native UNC; POSIX SSH; Windows SSH drive paths. +- `headless-emulator.test.ts`: WSL context propagation across ordinary and split OSC sequences; two emulators with different distros. +- `terminal-host.test.ts`/`session.test.ts`: resolved context reaches the daemon emulator; attach returns the session's stored context and does not adopt a conflicting later preference. +- `history-reader.test.ts`: incremental-log replay containing the exact hostname OSC 7 yields the correct WSL CWD. +- `daemon-pty-adapter.test.ts`: cover every `detectColdRestore` branch plus sticky-cache output. Repair hostname UNC and POSIX CWDs; preserve matching WSL UNC and drive paths; reject mismatched distro, other UNC, relative, native, and SSH/non-WSL cases. Assert both create/attach CWD and `coldRestore.cwd`. +- `orca-runtime.test.ts` and IPC PTY tests: context exists before early daemon output and before headless seeding; Windows-host worktree with a selected WSL distro; WSL UNC fallback after reconstruction; attach correction; PTY-ID reuse; simultaneous distros; local native and SSH isolation. +- Run focused tests, then `pnpm typecheck` and `pnpm lint`. +- Electron on native Windows: in a real Ubuntu WSL pane, emit the exact hostname OSC 7, sleep/wake twice, run `pwd` after each wake, and verify no `DaemonProtocolError`. Native UNC semantics are covered by deterministic unit tests; a screenshot of an arbitrary UNC string is not meaningful validation. + +## UI quality and review evidence + +There is no UI, layout, copy, or interaction change, so the Stage 5 visual-quality loop is skipped. Terminal content, CWD, and adjacent native/SSH behavior must remain unchanged. + +Electron validation still requires three evidence screenshots for user review: + +1. Before sleep: the WSL terminal shows the printed hostname/emission command and `pwd`. +2. After the first wake: the same pane shows the same `pwd` and no wake error. +3. After the second wake: the same pane again shows the same `pwd` and no wake error. + +## Lightweight engineering review + +- **Scope:** Parser context, immutable context propagation, history replay, and narrow legacy recovery. Runtime/IPC fields are necessary because parsing begins outside the daemon and may precede spawn completion. +- **Architecture/data flow:** One resolved distro drives every parser for one PTY incarnation. The URI hostname remains metadata; it never selects path namespace or distro. +- **Failure modes:** Covers missing/stale context, incremental replay, probe/create races, sticky restore, mismatched external history, split chunks, concurrent attaches, multi-window reuse, multi-distro isolation, PTY-ID reuse, and SSH/native boundaries. +- **Tests:** Requires parser tables, propagation tests at every emulator construction site, all adapter restore branches, runtime early-byte/seed races, and the deterministic Windows Electron reproduction twice. +- **Performance/blast radius:** One optional string per PTY/session and bounded conversion per OSC 7; no probes or new hot-path I/O. Non-WSL behavior is unchanged when the option is absent. +- **UI/screenshots:** No design-review loop. Three Electron screenshots are required as functional evidence; native UNC stays an automated regression test. +- **Residual risk:** Legacy checkpoints created from a non-machine hostname cannot be distinguished safely from a real UNC and therefore fall back to the requested CWD. A renamed/uninstalled distro still fails normally; this change does not guess a replacement. + +## Rollout + +1. Centralize distro resolution and WSL path conversion. +2. Make the parser, daemon Session, and history replay distro-aware. +3. Add allowlisted legacy recovery and keep spawn/cold-restore metadata consistent. +4. Make runtime context available before output/seeding and reset it per incarnation. +5. Add regressions, run static checks, then validate two Windows sleep/wake cycles with review screenshots. diff --git a/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts b/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts index c96edaded01..b64bb888798 100644 --- a/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts +++ b/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts @@ -35,6 +35,8 @@ function eligibility(overrides: Record) { defaultBaseRef: 'main', title: 'Ship mobile PR create', body: 'Generated body', + // Accepted no-review lookup so Create / Push & Create is allowed unless overridden. + reviewLookupOutcome: 'not_found', ...overrides } } diff --git a/mobile/src/source-control/mobile-hosted-review-service.ts b/mobile/src/source-control/mobile-hosted-review-service.ts index c219586edc7..c040b7a2c19 100644 --- a/mobile/src/source-control/mobile-hosted-review-service.ts +++ b/mobile/src/source-control/mobile-hosted-review-service.ts @@ -3,6 +3,7 @@ import type { HostedReviewCreationBlockedReason, HostedReviewCreationEligibility, HostedReviewCreationNextAction, + HostedReviewLookupOutcome, HostedReviewProvider } from '../../../src/shared/hosted-review' import type { RpcClient } from '../transport/rpc-client' @@ -62,6 +63,10 @@ export type MobileHostedReviewPrefill = { canCreate?: boolean blockedReason?: HostedReviewCreationBlockedReason nextAction?: HostedReviewCreationNextAction + // Why: mobile lacks the desktop refresh/review-lookup signals, so it fails + // closed on ambiguity. When the host could not prove the branch has no review + // (`unavailable`), create — including the Push & Create path — stays blocked. + reviewLookupOutcome?: HostedReviewLookupOutcome } // Resolve the mobile compose prefill from the same hosted-review eligibility @@ -97,7 +102,14 @@ export async function resolveMobileHostedReviewPrefill( behind: args.behind }) if (!eligibility) { - return { ...fallback, canCreate: false, blockedReason: null, nextAction: null } + // Eligibility itself could not be resolved: the review lookup is unproven. + return { + ...fallback, + canCreate: false, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'unavailable' + } } return { provider: eligibility.provider, @@ -106,10 +118,17 @@ export async function resolveMobileHostedReviewPrefill( body: eligibility.body || '', canCreate: eligibility.canCreate, blockedReason: eligibility.blockedReason, - nextAction: eligibility.nextAction + nextAction: eligibility.nextAction, + reviewLookupOutcome: eligibility.reviewLookupOutcome } } catch { - return { ...fallback, canCreate: false, blockedReason: null, nextAction: null } + return { + ...fallback, + canCreate: false, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'unavailable' + } } } diff --git a/mobile/src/source-control/mobile-pr-create.test.ts b/mobile/src/source-control/mobile-pr-create.test.ts index 28f2e4b691f..deb36b9e9f0 100644 --- a/mobile/src/source-control/mobile-pr-create.test.ts +++ b/mobile/src/source-control/mobile-pr-create.test.ts @@ -40,6 +40,7 @@ function eligibility( canCreate: true, blockedReason: null, nextAction: null, + reviewLookupOutcome: 'not_found', defaultBaseRef: 'main', title: 'Add feature', body: '', @@ -118,12 +119,115 @@ describe('mobile create form gating parity', () => { body: desktopEligibility.body ?? '', canCreate: desktopEligibility.canCreate, blockedReason: desktopEligibility.blockedReason, - nextAction: desktopEligibility.nextAction + nextAction: desktopEligibility.nextAction, + // Mobile receives the lookup outcome from eligibility; thread it so the + // gate reflects real prefills (current hosts always populate it). + reviewLookupOutcome: desktopEligibility.reviewLookupOutcome }) === null expect(mobileAllowsComposer).toBe(desktopAllowsComposer) }) + it('fails closed when the review-lookup outcome is missing (older host)', () => { + // A host that predates `reviewLookupOutcome` leaves review existence unproven. + // Mobile must not open Create / Push & Create on that ambiguity. + expect( + getMobilePrCreateBlockMessage({ + provider: 'github', + base: 'main', + title: 'Add feature', + body: '', + canCreate: true, + blockedReason: null + }) + ).toBe( + 'Orca could not confirm whether this branch already has a pull request. Try again in a moment.' + ) + expect( + getMobilePrCreateBlockMessage({ + provider: 'github', + base: 'main', + title: 'Add feature', + body: '', + canCreate: false, + blockedReason: 'needs_push' + }) + ).toBe( + 'Orca could not confirm whether this branch already has a pull request. Try again in a moment.' + ) + }) + + it('desktop gate hard-blocks on positive unresolved review evidence', () => { + // Mobile lacks review-lookup signals, so it fails closed on ambiguity: the + // shared desktop gate must return false even when eligibility looks ready. + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: true }), + reviewLookup: 'positive_unresolved' + }) + ).toBe(false) + }) + + it('desktop gate hard-blocks during a hard refresh error', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: true }), + hasHardRefreshError: true + }) + ).toBe(false) + }) + + it('fails closed on an unavailable review lookup even when eligibility looks ready', () => { + // The existing-review lookup could not prove there is no PR; mobile has no + // review-lookup signal of its own, so create must be blocked. + expect( + getMobilePrCreateBlockMessage({ + provider: 'github', + base: 'main', + title: 'Add feature', + body: '', + canCreate: true, + blockedReason: null, + reviewLookupOutcome: 'unavailable' + }) + ).toBe( + 'Orca could not confirm whether this branch already has a pull request. Try again in a moment.' + ) + }) + + it('fails closed on unavailable even on the needs_push Push & Create path', () => { + // needs_push would normally be allowed (Push & Create); an unavailable lookup + // must still block it — this is the fail-open gap the parity gate closes. + const mobileBlocked = + getMobilePrCreateBlockMessage({ + provider: 'github', + base: 'main', + title: 'Add feature', + body: '', + canCreate: false, + blockedReason: 'needs_push', + reviewLookupOutcome: 'unavailable' + }) !== null + const desktopAllowsComposer = shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/x', + hostedReviewCreation: eligibility({ + canCreate: false, + blockedReason: 'needs_push', + reviewLookupOutcome: 'unavailable' + }) + }) + expect(mobileBlocked).toBe(true) + expect(desktopAllowsComposer).toBe(false) + }) + it('stays safely blocked for a reason added by a newer desktop contract', () => { expect( getMobilePrCreateBlockMessage({ @@ -158,7 +262,8 @@ describe('resolveMobilePrPrefill', () => { nextAction: null, defaultBaseRef: 'develop', title: 'Add feature', - body: 'Body' + body: 'Body', + reviewLookupOutcome: 'not_found' }) ]) await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({ @@ -168,7 +273,8 @@ describe('resolveMobilePrPrefill', () => { body: 'Body', canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' }) }) @@ -182,7 +288,8 @@ describe('resolveMobilePrPrefill', () => { nextAction: 'push', defaultBaseRef: 'main', title: 'Add feature', - body: '' + body: '', + reviewLookupOutcome: 'not_found' }) ]) const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs) @@ -198,7 +305,8 @@ describe('resolveMobilePrPrefill', () => { review: null, blockedReason: 'dirty', nextAction: 'commit', - defaultBaseRef: 'main' + defaultBaseRef: 'main', + reviewLookupOutcome: 'not_found' }) ]) const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs) @@ -209,15 +317,39 @@ describe('resolveMobilePrPrefill', () => { it('returns a blocked fallback when eligibility is unavailable', async () => { const client = clientWith([fail('nope')]) - await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({ + const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs) + expect(prefill).toEqual({ provider: 'github', base: 'main', title: 'feature/x', body: '', canCreate: false, blockedReason: null, - nextAction: null + nextAction: null, + // Eligibility could not be resolved, so the review lookup is unproven. + reviewLookupOutcome: 'unavailable' }) + // A prefill Orca could not resolve must not offer create. + expect(getMobilePrCreateBlockMessage(prefill)).not.toBeNull() + }) + + it('threads reviewLookupOutcome from eligibility into the prefill and blocks needs_push', async () => { + const client = clientWith([ + ok({ + provider: 'github', + canCreate: false, + review: null, + blockedReason: 'needs_push', + nextAction: 'push', + defaultBaseRef: 'main', + title: 'Add feature', + body: '', + reviewLookupOutcome: 'unavailable' + }) + ]) + const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs) + expect(prefill.reviewLookupOutcome).toBe('unavailable') + expect(getMobilePrCreateBlockMessage(prefill)).not.toBeNull() }) it('blocks without calling the RPC when there is no branch', async () => { diff --git a/mobile/src/source-control/mobile-pr-create.ts b/mobile/src/source-control/mobile-pr-create.ts index b584b86e202..20f908d3df2 100644 --- a/mobile/src/source-control/mobile-pr-create.ts +++ b/mobile/src/source-control/mobile-pr-create.ts @@ -43,10 +43,18 @@ export function getMobilePrCreateSuccessWarning( } export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string | null { + const copy = hostedReviewCopy(prefill.provider) if (prefill.canCreate !== false || shouldPushBeforeMobileHostedReviewCreate(prefill)) { + // Fail closed: only an accepted no-review lookup (`not_found`) may open + // Create / Push & Create. `unavailable`, `found`, or a missing outcome (an + // older host that predates the field) all leave review existence unproven — + // mobile has no refresh/review-lookup signal of its own, so it must not + // offer create, or the needs_push Push & Create path would slip through. + if (prefill.reviewLookupOutcome !== 'not_found') { + return `Orca could not confirm whether this branch already has a ${copy.reviewLabel}. Try again in a moment.` + } return null } - const copy = hostedReviewCopy(prefill.provider) switch (prefill.blockedReason) { case 'dirty': return `Commit changes before creating a ${copy.reviewLabel}.` diff --git a/package.json b/package.json index 5035c621383..e5734083688 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,8 @@ "sherpa-onnx-darwin-x64": "1.12.37", "sherpa-onnx-linux-arm64": "1.12.37", "sherpa-onnx-linux-x64": "1.12.37", - "sherpa-onnx-win-x64": "1.12.37" + "sherpa-onnx-win-x64": "1.12.37", + "windows-native-registry": "3.2.2" }, "lint-staged": { "*.{ts,tsx,js,jsx,mjs,mts,cts}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6ee54760e0..0ed99b766af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -399,6 +399,9 @@ importers: sherpa-onnx-win-x64: specifier: 1.12.37 version: 1.12.37 + windows-native-registry: + specifier: 3.2.2 + version: 3.2.2 packages: @@ -5453,6 +5456,9 @@ packages: node-addon-api@1.7.2: resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + node-addon-api@4.3.0: + resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -6745,6 +6751,10 @@ packages: engines: {node: '>=8'} hasBin: true + windows-native-registry@3.2.2: + resolution: {integrity: sha512-yg1ZnuzUExn+Uq3cODB23Ju15URw0F/sRrSpjavVhHMi46s6Pcukp4+PtumoELmefoeHarCgci0XV34COpuYkw==} + os: [win32] + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -12003,6 +12013,9 @@ snapshots: node-addon-api@1.7.2: optional: true + node-addon-api@4.3.0: + optional: true + node-addon-api@7.1.1: {} node-api-version@0.2.1: @@ -13465,6 +13478,11 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + windows-native-registry@3.2.2: + dependencies: + node-addon-api: 4.3.0 + optional: true + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json index b0053b7b118..1b489f39c0f 100644 --- a/resources/skills/current-manifest.json +++ b/resources/skills/current-manifest.json @@ -130,18 +130,18 @@ { "name": "orchestration", "sourcePath": "skills/orchestration", - "releaseRevision": 24, - "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", - "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "releaseRevision": 25, + "packageDigest": "c19171d213e827bdf5364b733b67889566aaa2fb3667ebe029db87044d08f908", + "gitTreeSha": "da346803bccae7fb1fdade31bbe9b4d851b25e38", "files": [ { "path": "SKILL.md", - "size": 22861, + "size": 22676, "executable": false, "classification": "text", - "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", - "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", - "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + "exactSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305", + "textNormalizedSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305", + "identitySha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305" } ] } diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json index 58d6c7f6c0e..f522706ff6c 100644 --- a/resources/skills/snapshot-registry.json +++ b/resources/skills/snapshot-registry.json @@ -899,6 +899,22 @@ "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" } ] + }, + { + "releaseRevision": 25, + "packageDigest": "c19171d213e827bdf5364b733b67889566aaa2fb3667ebe029db87044d08f908", + "gitTreeSha": "da346803bccae7fb1fdade31bbe9b4d851b25e38", + "files": [ + { + "path": "SKILL.md", + "size": 22676, + "executable": false, + "classification": "text", + "exactSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305", + "textNormalizedSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305", + "identitySha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305" + } + ] } ], "mobile-fit-debug": [ diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index 583fc2ad661..c7d15250e00 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -183,7 +183,7 @@ Wait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor ## Worker Terminals -Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, create the worker in the active worktree: +Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree: ```bash orca terminal create --worktree active --title --command "codex" --json @@ -191,7 +191,9 @@ orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca orchestration dispatch --task --to --inject --json ``` -Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Use a new worktree only when explicitly requested or when independent isolated checkout state is intended. For supervised new-worktree workers, decide the desired Orca lineage before creation: use child lineage only when the work is conceptually stacked under or dependent on the active worktree, and use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks. Decide the Git base separately from lineage: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base. +Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements. + +When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base. ```bash orca worktree create --name --agent codex --json @@ -203,11 +205,11 @@ orca orchestration dispatch --task --to --inject --json For new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `. -**Agent-first (required for ordinary agent workers):** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. +**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble. -Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent under the same worktree in the sidebar even though it is a child dispatch in Orca orchestration state. A visible parent/child worktree relationship requires creating a child worktree, but do that only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree. +Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage. Other terminal commands coordinators often need: @@ -234,7 +236,6 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding - If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene. - Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop. - Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps. -- Prefer inter-worktree workers only for independent work that does not need current uncommitted state. When same-worktree work is required, create fresh terminals in that worktree and keep edit ownership clear. ## Example diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index 583fc2ad661..c7d15250e00 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -183,7 +183,7 @@ Wait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor ## Worker Terminals -Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, create the worker in the active worktree: +Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree: ```bash orca terminal create --worktree active --title --command "codex" --json @@ -191,7 +191,9 @@ orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca orchestration dispatch --task --to --inject --json ``` -Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Use a new worktree only when explicitly requested or when independent isolated checkout state is intended. For supervised new-worktree workers, decide the desired Orca lineage before creation: use child lineage only when the work is conceptually stacked under or dependent on the active worktree, and use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks. Decide the Git base separately from lineage: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base. +Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements. + +When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base. ```bash orca worktree create --name --agent codex --json @@ -203,11 +205,11 @@ orca orchestration dispatch --task --to --inject --json For new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `. -**Agent-first (required for ordinary agent workers):** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. +**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble. -Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent under the same worktree in the sidebar even though it is a child dispatch in Orca orchestration state. A visible parent/child worktree relationship requires creating a child worktree, but do that only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree. +Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage. Other terminal commands coordinators often need: @@ -234,7 +236,6 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding - If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene. - Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop. - Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps. -- Prefer inter-worktree workers only for independent work that does not need current uncommitted state. When same-worktree work is required, create fresh terminals in that worktree and keep edit ownership clear. ## Example diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 8f9a747993a..ed49d565cd4 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject` or `orca orchestration run`.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Ownership\n\nOrchestration messages and tasks are runtime-global. Lifecycle authority comes from the payload `taskId` + `dispatchId` of the active dispatch, verified against the dispatched pane. Terminal handles are routing metadata — a pane can receive a new handle after restart — so never accept or reject lifecycle provenance by comparing handles. Send `worker_done` and `heartbeat` from the worker's own terminal; the runtime ignores them when sent from a different pane.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--unread|--peek|--all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask --to --question [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- `check` and `check --unread` return unread matches and mark them read. Use `--peek` for unread matches without consuming them; use `--all` for read and unread history without consuming anything. If an older CLI rejects `--peek` as an unknown flag, use `--all` and filter unread rows yourself.\n- Message **one** live agent handle per worker. Use `startupTerminal.handle` from the create response when present; if it is missing or later returns `terminal_handle_stale`, re-resolve with `orca terminal list --worktree ... --json` and continue with the replacement only.\n- `orca orchestration check --unread --inject --json` renders unread mail for the agent terminal that runs it; it does not remotely wake another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms ` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id --body --json`, then keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly.\n- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also dispatch-scoped. Send it only to the concrete coordinator handle with both `taskId` and `dispatchId`; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA task is the work item, a dispatch assigns it to a terminal, and a gate blocks progress until a coordinator or user decision is recorded.\n\n```bash\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Gates And Coordinator\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\norca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json]\norca orchestration run-stop [--json]\n```\n\n`run` returns immediately with a run ID. Query progress with `task-list`. Use `ask` for worker-to-coordinator questions; it creates a `decision_gate` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears runtime-global orchestration state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, create the worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Use a new worktree only when explicitly requested or when independent isolated checkout state is intended. For supervised new-worktree workers, decide the desired Orca lineage before creation: use child lineage only when the work is conceptually stacked under or dependent on the active worktree, and use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks. Decide the Git base separately from lineage: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\n```bash\norca worktree create --name --agent codex --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from startupTerminal.handle in the create response.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**Agent-first (required for ordinary agent workers):** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent under the same worktree in the sidebar even though it is a child dispatch in Orca orchestration state. A visible parent/child worktree relationship requires creating a child worktree, but do that only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal, even on failure:\n `orca orchestration send --to --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"filesModified\":[\"path/a\"],\"reportPath\":\"\"}' --json`\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --to --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n- Prefer inter-worktree workers only for independent work that does not need current uncommitted state. When same-worktree work is required, create fresh terminals in that worktree and keep edit ownership clear.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,decision_gate --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, inspect `task-list`/`dispatch-show` if inheriting state, then choose either a manual loop (`task-create` -> worker -> `dispatch --inject` -> `check --wait`) or `orchestration run`.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject` or `orca orchestration run`.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Ownership\n\nOrchestration messages and tasks are runtime-global. Lifecycle authority comes from the payload `taskId` + `dispatchId` of the active dispatch, verified against the dispatched pane. Terminal handles are routing metadata — a pane can receive a new handle after restart — so never accept or reject lifecycle provenance by comparing handles. Send `worker_done` and `heartbeat` from the worker's own terminal; the runtime ignores them when sent from a different pane.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--unread|--peek|--all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask --to --question [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- `check` and `check --unread` return unread matches and mark them read. Use `--peek` for unread matches without consuming them; use `--all` for read and unread history without consuming anything. If an older CLI rejects `--peek` as an unknown flag, use `--all` and filter unread rows yourself.\n- Message **one** live agent handle per worker. Use `startupTerminal.handle` from the create response when present; if it is missing or later returns `terminal_handle_stale`, re-resolve with `orca terminal list --worktree ... --json` and continue with the replacement only.\n- `orca orchestration check --unread --inject --json` renders unread mail for the agent terminal that runs it; it does not remotely wake another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms ` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id --body --json`, then keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly.\n- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also dispatch-scoped. Send it only to the concrete coordinator handle with both `taskId` and `dispatchId`; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA task is the work item, a dispatch assigns it to a terminal, and a gate blocks progress until a coordinator or user decision is recorded.\n\n```bash\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Gates And Coordinator\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\norca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json]\norca orchestration run-stop [--json]\n```\n\n`run` returns immediately with a run ID. Query progress with `task-list`. Use `ask` for worker-to-coordinator questions; it creates a `decision_gate` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears runtime-global orchestration state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\n```bash\norca worktree create --name --agent codex --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from startupTerminal.handle in the create response.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal, even on failure:\n `orca orchestration send --to --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"filesModified\":[\"path/a\"],\"reportPath\":\"\"}' --json`\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --to --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,decision_gate --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, inspect `task-list`/`dispatch-show` if inheriting state, then choose either a manual loop (`task-create` -> worker -> `dispatch --inject` -> `check --wait`) or `orchestration run`.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index b666c9329bb..f1aba1cb09f 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -1425,7 +1425,92 @@ describe('AgentHookServer listener replay', () => { server.clearPaneState(PANE) expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith(PANE) + expect(listener).toHaveBeenCalledWith({ paneKey: PANE }) + }) + + it('batches connection cleanup and retains sibling and local statuses', () => { + const server = new AgentHookServer() + const paneKeyAt = (prefix: string, index: number): string => + makePaneKey( + `${prefix}-tab-${index}`, + `00000000-0000-4000-8000-${(index + 1).toString(16).padStart(12, '0')}` + ) + const targetPaneKeys = Array.from({ length: 100 }, (_, index) => paneKeyAt('target', index)) + const siblingPaneKeys = Array.from({ length: 100 }, (_, index) => + paneKeyAt('sibling', index + 100) + ) + const unstampedPaneKey = paneKeyAt('legacy', 250) + const statusListener = vi.fn() + const clearListener = vi.fn() + const internals = server as unknown as AgentHookServerCacheInternals + const persistSpy = vi.spyOn(internals, 'scheduleStatusPersist') + server.subscribeStatusChanges(statusListener) + server.setPaneStatusClearListener(clearListener) + for (const paneKey of targetPaneKeys) { + server.ingestRemote({ paneKey, payload: { state: 'working', agentType: 'claude' } }, 'ssh-a') + } + for (const paneKey of siblingPaneKeys) { + server.ingestRemote({ paneKey, payload: { state: 'working', agentType: 'claude' } }, 'ssh-b') + } + server.ingestTerminalStatus({ + paneKey: unstampedPaneKey, + payload: { state: 'working', prompt: '', agentType: 'codex' } + }) + statusListener.mockClear() + persistSpy.mockClear() + + server.clearStatusEntriesForConnection('ssh-a') + + expect(statusListener).toHaveBeenCalledOnce() + expect(persistSpy).toHaveBeenCalledOnce() + expect(clearListener).toHaveBeenCalledOnce() + expect(clearListener).toHaveBeenCalledWith({ + transient: true, + connectionId: 'ssh-a', + clearedAt: expect.any(Number) + }) + expect(server.getStatusSnapshot().map((entry) => entry.paneKey)).toEqual([ + ...siblingPaneKeys, + unstampedPaneKey + ]) + }) + + it('emits a connection cutoff after a pane-key collision and orders replay after it', () => { + vi.useFakeTimers() + vi.setSystemTime(1_700_000_000_000) + const server = new AgentHookServer() + const clearListener = vi.fn() + server.setPaneStatusClearListener(clearListener) + server.ingestRemote( + { paneKey: PANE, payload: { state: 'working', agentType: 'claude' } }, + 'ssh-a' + ) + server.ingestRemote( + { paneKey: PANE, payload: { state: 'working', agentType: 'codex' } }, + 'ssh-b' + ) + + server.clearStatusEntriesForConnection('ssh-a') + const clear = clearListener.mock.calls[0]?.[0] as { + transient: true + connectionId: string + clearedAt: number + } + server.ingestRemote( + { paneKey: GOOD_PANE, payload: { state: 'working', agentType: 'claude' }, isReplay: true }, + 'ssh-a' + ) + + expect(clear).toMatchObject({ transient: true, connectionId: 'ssh-a' }) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ paneKey: PANE, connectionId: 'ssh-b' }), + expect.objectContaining({ + paneKey: GOOD_PANE, + connectionId: 'ssh-a', + receivedAt: clear.clearedAt + 1 + }) + ]) + vi.useRealTimers() }) it('drops cached statuses and pane-scoped listener caches under one tab prefix', () => { @@ -6111,6 +6196,68 @@ describe('Last-status persistence', () => { } }) + it('keeps SSH status ordering monotonic across hydration and clock rollback', async () => { + const now = 1_700_000_000_000 + vi.spyOn(Date, 'now').mockReturnValue(now) + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + const receivedAt = now + 1_000 + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: 'ssh-a', + receivedAt, + stateStartedAt: receivedAt, + payload: { state: 'working', prompt: 'before restart', agentType: 'claude' } + } + } + }), + 'utf8' + ) + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const clearListener = vi.fn() + server.setPaneStatusClearListener(clearListener) + server.ingestRemote( + { paneKey: PANE, payload: { state: 'working', agentType: 'codex' } }, + 'ssh-a' + ) + + expect(server.getStatusSnapshot()[0]?.receivedAt).toBe(receivedAt + 1) + server.clearStatusEntriesForConnection('ssh-a') + const clearedAt = receivedAt + 2 + expect(clearListener).toHaveBeenCalledWith({ + transient: true, + connectionId: 'ssh-a', + clearedAt + }) + server.ingestRemote( + { + paneKey: GOOD_PANE, + isReplay: true, + payload: { state: 'working', agentType: 'claude' } + }, + 'ssh-a' + ) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: GOOD_PANE, + connectionId: 'ssh-a', + receivedAt: clearedAt + 1 + }) + ]) + } finally { + server.stop() + } + }) + it('drops persisted idle Claude child rows from hydration replay', async () => { mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) const receivedAt = recentTs() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e810a45f3d4..4c5d4c80cc1 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -44,6 +44,7 @@ import { import type { AgentHookSource } from '../../shared/agent-hook-relay' import { AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusClearIpcPayload, type AgentStatusIpcPayload, type AgentType, type AgentStatusState, @@ -93,7 +94,7 @@ export type AgentHookStatusChangeEntry = { } type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void -type PaneStatusClearListener = (paneKey: string) => void +type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void type PaneKeyAliasEntry = { stablePaneKey: string @@ -528,6 +529,7 @@ export class AgentHookServer { private promptSentHashSalt = randomBytes(16).toString('hex') private closedAgentStatusTabIds = new Set() private closedAgentStatusPaneKeys = new Set() + private connectionTimestampWatermarkById = new Map() // Why: identity check — skip writes when the JSON-stringified contents // exactly match the last successful disk write. Cheap protection against // re-firing trailing timers when nothing changed. @@ -896,7 +898,15 @@ export class AgentHookServer { const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as | EnrichedAgentHookEventPayload | undefined - const now = Date.now() + const connectionClearWatermark = payload.connectionId + ? this.connectionTimestampWatermarkById.get(payload.connectionId) + : undefined + // Why: Date.now() can repeat across disconnect and reconnect. A remote + // replay must sort strictly after its connection's transient clear. + const now = Math.max(Date.now(), (connectionClearWatermark ?? -1) + 1) + if (payload.connectionId) { + this.connectionTimestampWatermarkById.set(payload.connectionId, now) + } if (payload.providerSessionOnly) { // Why: Pi session_start must replace stale turn state and survive snapshot // replay, but it must not emit prompt telemetry or a fabricated status. @@ -1305,7 +1315,7 @@ export class AgentHookServer { this.scheduleStatusPersist() this.notifyStatusChangeListeners() for (const paneKey of clearedStatusPaneKeys) { - this.onPaneStatusCleared?.(paneKey) + this.onPaneStatusCleared?.({ paneKey }) } } } @@ -1677,6 +1687,7 @@ export class AgentHookServer { this.promptSentDedupeByPaneKey.clear() this.closedAgentStatusTabIds.clear() this.closedAgentStatusPaneKeys.clear() + this.connectionTimestampWatermarkById.clear() this.legacyPaneKeyAliases.clear() clearAllListenerCaches(this.state) this.notifyStatusChangeListeners() @@ -1690,21 +1701,69 @@ export class AgentHookServer { * lands. clearPaneState (which wipes all three caches) is the right shape * only for PTY-teardown. */ dropStatusEntry(paneKey: string): void { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - if (!this.state.lastStatusByPaneKey.has(resolvedPaneKey)) { + if (!this.deleteStatusEntry(paneKey)) { return } - const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) - this.state.lastStatusByPaneKey.delete(resolvedPaneKey) - this.clearAssistantMessageRetry(resolvedPaneKey) - this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) - if (existing?.payload.state === 'done') { - this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) - } this.scheduleStatusPersist() this.notifyStatusChangeListeners() } + /** Clear statuses proven to belong to one lost SSH transport. */ + clearStatusEntriesForConnection(connectionId: string): void { + const normalizedConnectionId = connectionId.trim() + if (normalizedConnectionId.length === 0) { + return + } + const clearedAt = Math.max( + Date.now(), + (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 + ) + this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) + let statusChanged = false + for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { + const entry = rawEntry as EnrichedAgentHookEventPayload + // Why: legacy/unstamped rows cannot be safely attributed to one host. + // Leave them for normal pane teardown instead of risking cross-host loss. + if (entry.connectionId !== normalizedConnectionId) { + continue + } + if (this.deleteStatusEntry(paneKey)) { + statusChanged = true + } + } + if (statusChanged) { + // Why: one disconnect can own many panes; persist and notify subscribers + // once so cleanup cost does not fan out with the pane count. + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + // Why: another host can overwrite the same pane key in main's cache while + // renderer still shows this connection's older row. Always send the + // connection cutoff, even when no current main entry matched. + this.onPaneStatusCleared?.({ + transient: true, + connectionId: normalizedConnectionId, + clearedAt + }) + } + + private deleteStatusEntry(paneKey: string): EnrichedAgentHookEventPayload | null { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return null + } + this.state.lastStatusByPaneKey.delete(resolvedPaneKey) + this.clearAssistantMessageRetry(resolvedPaneKey) + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) + if (existing.payload.state === 'done') { + this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + } + return existing + } + dropStatusEntriesByTabPrefix(tabId: string): void { this.markTabClosedForAgentStatus(tabId) const paneKeysToClear = new Set() @@ -1802,7 +1861,7 @@ export class AgentHookServer { this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.scheduleStatusPersist() this.notifyStatusChangeListeners() - this.onPaneStatusCleared?.(resolvedPaneKey) + this.onPaneStatusCleared?.({ paneKey: resolvedPaneKey }) } } @@ -1917,6 +1976,15 @@ export class AgentHookServer { entry.payload = hydratedPayload } this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) + if (entry.connectionId) { + // Why: a restarted process can observe an earlier wall clock; seed + // transport ordering so new events and clears stay after disk state. + const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) + this.connectionTimestampWatermarkById.set( + entry.connectionId, + Math.max(previousWatermark ?? -1, entry.receivedAt) + ) + } // Why: preserve only working children across restart. Live activity // confirms them; a later complete inventory may reap stale seeds. if (entry.payload.subagents) { @@ -2043,6 +2111,10 @@ export class AgentHookServer { _resetPromptSentDedupeForTests(): void { this.promptSentDedupeByPaneKey.clear() } + + _resetConnectionTimestampWatermarksForTests(): void { + this.connectionTimestampWatermarkById.clear() + } } export const agentHookServer = new AgentHookServer() @@ -2061,5 +2133,6 @@ export const _internals = { resetCachesForTests: (): void => { clearAllListenerCaches(agentHookServer._getStateForTests()) agentHookServer._resetPromptSentDedupeForTests() + agentHookServer._resetConnectionTimestampWatermarksForTests() } } diff --git a/src/main/ai-vault/session-parse-cache-persistence.test.ts b/src/main/ai-vault/session-parse-cache-persistence.test.ts new file mode 100644 index 00000000000..4d7aa7113d6 --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-persistence.test.ts @@ -0,0 +1,437 @@ +import { existsSync } from 'node:fs' +import { + appendFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + utimes, + writeFile +} from 'node:fs/promises' +import * as fsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ensureSessionParseCacheLoaded, + flushSessionParseCachePersistForTests, + initSessionParseCachePersistence, + resetSessionParseCachePersistenceForTests, + scheduleSessionParseCachePersist +} from './session-parse-cache-persistence' +import { scanAiVaultSessions } from './session-scanner' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests, + seedSessionParseCache, + type PersistedSessionParseCacheEntry, + type SessionParseStats +} from './session-scanner-parse-cache' +import { isolatedScanRoots } from './session-scanner-test-fixtures' +import { parseClaudeSessionFile } from './session-scanner-primary-parsers' +import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' + +// Spy-wrap (real implementations still run) so the zero-disk-IO test can +// assert the uninitialized module never touches the filesystem. +vi.mock('node:fs/promises', { spy: true }) + +const APP_VERSION = '1.2.3-test' + +let tempRoots: string[] = [] + +beforeEach(() => { + resetSessionParseCacheForTests() + resetSessionParseCachePersistenceForTests() +}) + +afterEach(async () => { + resetSessionParseCachePersistenceForTests() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeTempDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-parse-cache-persist-')) + tempRoots.push(root) + return root +} + +async function claudeCandidate(path: string): Promise { + const fileStat = await stat(path) + const file: FileWithMtime = { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + } + return { agent: 'claude', file, codexHome: null } +} + +function userRecord(index: number, text: string): string { + return JSON.stringify({ + type: 'user', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + cwd: '/repo/app', + gitBranch: 'main', + message: { role: 'user', content: text } + }) +} + +function assistantRecord(index: number, text: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text }], + usage: { input_tokens: 100, output_tokens: 40 } + } + }) +} + +async function writeTranscript(root: string): Promise { + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile(path, `${userRecord(0, 'first question')}\n${assistantRecord(1, 'answer')}\n`) + return path +} + +async function parseAndPersist(path: string): Promise { + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats) + scheduleSessionParseCachePersist(stats) + await flushSessionParseCachePersistForTests() + return stats +} + +// Clear the in-memory cache and the persistence module's memoized state, then +// re-enable persistence against the same file — a fresh launch, same profile. +function simulateRestart(cacheFile: string, appVersion = APP_VERSION): void { + resetSessionParseCacheForTests() + resetSessionParseCachePersistenceForTests() + initSessionParseCachePersistence({ filePath: cacheFile, appVersion }) +} + +async function coldParseStats(path: string): Promise { + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats) + return stats +} + +describe('session parse cache persistence', () => { + it('round-trips: a persisted entry is a reused hit after a restart, without reading the transcript', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'vault-state', 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + const first = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(first).not.toBeNull() + expect(stats.fullParses).toBe(1) + + scheduleSessionParseCachePersist(stats) + await flushSessionParseCachePersistForTests() + expect(existsSync(cacheFile)).toBe(true) + + simulateRestart(cacheFile) + await ensureSessionParseCacheLoaded() + + // Deleting the transcript proves the hit needs no transcript read at all. + await rm(transcript) + const reusedStats = createSessionParseStats() + const reused = await parseAgentSessionFileCached(candidate, process.platform, reusedStats) + expect(reusedStats.reused).toBe(1) + expect(reusedStats.fullParses).toBe(0) + expect(reusedStats.incremental).toBe(0) + expect(reusedStats.bytesRead).toBe(0) + expect(reused).toEqual(first) + }) + + it('ignores a corrupt cache file and scans cold', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + await writeFile(cacheFile, 'not json {{{') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + + const transcript = await writeTranscript(root) + const stats = await coldParseStats(transcript) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(0) + }) + + it('ignores a cache file with a mismatched schemaVersion', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + + const persisted = JSON.parse(await readFile(cacheFile, 'utf-8')) + persisted.schemaVersion = 999 + await writeFile(cacheFile, JSON.stringify(persisted)) + + simulateRestart(cacheFile) + const stats = await coldParseStats(transcript) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(0) + }) + + it('ignores a cache file written by a different app version', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + + simulateRestart(cacheFile, '9.9.9-other') + const stats = await coldParseStats(transcript) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(0) + }) + + it('seeding never clobbers a live in-memory entry', async () => { + const root = await makeTempDir() + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + const live = await parseAgentSessionFileCached(candidate, process.platform) + expect(live).not.toBeNull() + + // A stale persisted entry for the same path (session: null marker). + seedSessionParseCache([ + [ + transcript, + { + mtimeMs: candidate.file.mtimeMs, + sizeBytes: candidate.file.sizeBytes ?? null, + platform: process.platform, + session: null + } + ] + ]) + + const stats = createSessionParseStats() + const after = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(stats.reused).toBe(1) + expect(after).toBe(live) + }) + + it('falls through to a full parse when a seeded file changed while the app was closed', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + + simulateRestart(cacheFile) + await ensureSessionParseCacheLoaded() + + // Grown while "closed": seeded entries have no resume state, so this is a + // full parse (not incremental) whose result matches a cold parse. + await appendFile(transcript, `${userRecord(2, 'follow-up')}\n${assistantRecord(3, 'more')}\n`) + const stats = createSessionParseStats() + const reparsed = await parseAgentSessionFileCached( + await claudeCandidate(transcript), + process.platform, + stats + ) + expect(stats.fullParses).toBe(1) + expect(stats.incremental).toBe(0) + expect(stats.reused).toBe(0) + expect(reparsed).toEqual(await parseClaudeSessionFile((await claudeCandidate(transcript)).file)) + expect(reparsed?.messageCount).toBe(4) + }) + + it('performs zero disk IO when never initialized', async () => { + vi.clearAllMocks() + + await ensureSessionParseCacheLoaded() + scheduleSessionParseCachePersist({ reused: 0, incremental: 2, fullParses: 5, bytesRead: 10 }) + await flushSessionParseCachePersistForTests() + + expect(fsPromises.readFile).not.toHaveBeenCalled() + expect(fsPromises.writeFile).not.toHaveBeenCalled() + expect(fsPromises.mkdir).not.toHaveBeenCalled() + expect(fsPromises.rename).not.toHaveBeenCalled() + expect(fsPromises.rm).not.toHaveBeenCalled() + }) + + it('collapses back-to-back schedules into one write', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + const stats = await coldParseStats(transcript) + + vi.clearAllMocks() + scheduleSessionParseCachePersist(stats) + scheduleSessionParseCachePersist(stats) + await flushSessionParseCachePersistForTests() + + expect(fsPromises.rename).toHaveBeenCalledTimes(1) + expect(existsSync(cacheFile)).toBe(true) + }) + + it('sweeps orphaned temp files from a prior crashed save on load', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + const orphan = join(root, 'session-parse-cache-12345-99.tmp') + await writeFile(orphan, '{"half":"written') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + + await ensureSessionParseCacheLoaded() + expect(existsSync(orphan)).toBe(false) + }) + + it('scanAiVaultSessions seeds from the persisted cache and persists after parsing', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const roots = isolatedScanRoots(root) + const transcript = join(roots.claudeProjectsDir, 'project', 'scan-session.jsonl') + await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true }) + // Same-length markers so the rewrite below preserves sizeBytes exactly. + await writeFile( + transcript, + `${userRecord(0, 'persisted-scan-marker-AAAA')}\n${assistantRecord(1, 'answer')}\n` + ) + const pinnedMtime = new Date(1740000000000) + await utimes(transcript, pinnedMtime, pinnedMtime) + + const first = await scanAiVaultSessions(roots) + expect(first.sessions).toHaveLength(1) + expect(JSON.stringify(first.sessions[0])).toContain('persisted-scan-marker-AAAA') + // The scan itself (not a manual schedule call) must have queued the save. + await flushSessionParseCachePersistForTests() + expect(existsSync(cacheFile)).toBe(true) + + simulateRestart(cacheFile) + // Rewrite with identical length and mtime: only a seeded cache hit can + // still return the original marker; a cold re-parse would see BBBB. + await writeFile( + transcript, + `${userRecord(0, 'persisted-scan-marker-BBBB')}\n${assistantRecord(1, 'answer')}\n` + ) + await utimes(transcript, pinnedMtime, pinnedMtime) + + const second = await scanAiVaultSessions(roots) + expect(second.sessions).toHaveLength(1) + expect(JSON.stringify(second.sessions[0])).toContain('persisted-scan-marker-AAAA') + expect(second.sessions[0]).toEqual(first.sessions[0]) + }) + + it('an over-cap seed list keeps the newest tail of the snapshot order', async () => { + const root = await makeTempDir() + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + // Snapshot order is oldest→newest, so a foreign over-cap file must keep + // its newest (last) entries; the real transcript rides at the very end. + const fakes: [string, PersistedSessionParseCacheEntry][] = Array.from( + { length: 4100 }, + (_, index): [string, PersistedSessionParseCacheEntry] => [ + `/nonexistent/fake-${index}.jsonl`, + { mtimeMs: index, sizeBytes: 1, platform: process.platform, session: null } + ] + ) + seedSessionParseCache([ + ...fakes, + [ + transcript, + { + mtimeMs: candidate.file.mtimeMs, + sizeBytes: candidate.file.sizeBytes ?? null, + platform: process.platform, + session: null + } + ] + ]) + + const stats = createSessionParseStats() + await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(stats.reused).toBe(1) + expect(stats.fullParses).toBe(0) + }) + + it('a failing rename cleans up its temp file and keeps the previous snapshot usable', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + const previousSnapshot = await readFile(cacheFile, 'utf-8') + + // A second session parsed after the good save; its save's rename is + // rejected (the Windows EPERM/EBUSY story: target held open elsewhere). + const other = join(root, 'ffffffff-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile(other, `${userRecord(2, 'second session')}\n${assistantRecord(3, 'reply')}\n`) + const stats = createSessionParseStats() + await parseAgentSessionFileCached(await claudeCandidate(other), process.platform, stats) + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.mocked(fsPromises.rename).mockRejectedValueOnce( + Object.assign(new Error('EPERM: rename blocked'), { code: 'EPERM' }) + ) + scheduleSessionParseCachePersist(stats) + await expect(flushSessionParseCachePersistForTests()).resolves.toBeUndefined() + expect(debugSpy).toHaveBeenCalled() + + // The temp file was written before the rename failed; it must not linger, + // and the previous snapshot must be byte-identical (never torn). + expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + expect(await readFile(cacheFile, 'utf-8')).toBe(previousSnapshot) + + // That intact previous snapshot still round-trips on the next launch. + simulateRestart(cacheFile) + await ensureSessionParseCacheLoaded() + const reusedStats = createSessionParseStats() + await parseAgentSessionFileCached( + await claudeCandidate(transcript), + process.platform, + reusedStats + ) + expect(reusedStats.reused).toBe(1) + debugSpy.mockRestore() + }) + + it('swallows save failures and leaves scan results unaffected', async () => { + const root = await makeTempDir() + // A regular file where the cache directory should be makes mkdir fail on + // every platform (no chmod tricks, which don't hold on Windows or as root). + const blocker = join(root, 'blocker') + await writeFile(blocker, 'a file, not a directory') + initSessionParseCachePersistence({ + filePath: join(blocker, 'session-parse-cache.json'), + appVersion: APP_VERSION + }) + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}) + + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + const session = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(session).not.toBeNull() + + scheduleSessionParseCachePersist(stats) + await expect(flushSessionParseCachePersistForTests()).resolves.toBeUndefined() + expect(debugSpy).toHaveBeenCalled() + + // The in-memory cache still serves hits and no partial files were left behind. + const reusedStats = createSessionParseStats() + expect(await parseAgentSessionFileCached(candidate, process.platform, reusedStats)).toBe( + session + ) + expect(reusedStats.reused).toBe(1) + expect(await readdir(root)).toEqual(expect.arrayContaining(['blocker'])) + expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + debugSpy.mockRestore() + }) +}) diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts new file mode 100644 index 00000000000..f4a924bd82f --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -0,0 +1,209 @@ +// Persists the reusable portion of the AI Vault session parse cache to one +// JSON file under userData so a fresh launch reuses prior parse work instead +// of re-reading the whole transcript corpus (issue #9210: 6.7 GB / 109 s cold +// scans). Disabled unless the composition root calls init; every failure mode +// degrades to today's cold-scan behavior. +import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + seedSessionParseCache, + snapshotSessionParseCacheForPersistence, + type PersistedSessionParseCacheEntry, + type SessionParseStats +} from './session-scanner-parse-cache' + +// Bump when the persisted entry layout changes; a mismatched file is discarded whole. +const SCHEMA_VERSION = 1 +// Debounce so back-to-back scans (desktop IPC + runtime RPC) collapse into one write. +const SAVE_DEBOUNCE_MS = 1_500 +// The payload contains transcript-derived preview text; keep it user-only +// (mode bits are inert on Windows — the userData ACL grant is the boundary there). +const PRIVATE_DIRECTORY_MODE = 0o700 +const PRIVATE_FILE_MODE = 0o600 + +type SessionParseCachePersistenceOptions = { + filePath: string + appVersion: string +} + +let options: SessionParseCachePersistenceOptions | null = null +let loadPromise: Promise | null = null +let saveTimer: NodeJS.Timeout | null = null +let lastSave: Promise = Promise.resolve() + +/** Enable persistence. Called only from the composition root; every export is a no-op until then. */ +export function initSessionParseCachePersistence(next: SessionParseCachePersistenceOptions): void { + options = next +} + +export function resetSessionParseCachePersistenceForTests(): void { + options = null + loadPromise = null + if (saveTimer) { + clearTimeout(saveTimer) + saveTimer = null + } + lastSave = Promise.resolve() +} + +/** + * Seed the in-memory parse cache from disk. Memoized: concurrent scans at + * startup all await the same load. Resolves immediately when uninitialized. + */ +export function ensureSessionParseCacheLoaded(): Promise { + if (options === null) { + return Promise.resolve() + } + loadPromise ??= loadPersistedEntries(options) + return loadPromise +} + +/** + * Schedule a debounced snapshot write after a scan that parsed something. + * Reused-only scans schedule no write (the file already reflects the cache). + */ +export function scheduleSessionParseCachePersist(stats: SessionParseStats): void { + if (options === null || stats.incremental + stats.fullParses <= 0) { + return + } + const current = options + if (saveTimer) { + clearTimeout(saveTimer) + } + saveTimer = setTimeout(() => { + saveTimer = null + // Chained so a slow write and a rescheduled save can't rename out of order + // (an older snapshot landing last); persistSnapshot never rejects. + lastSave = lastSave.then(() => persistSnapshot(current)) + }, SAVE_DEBOUNCE_MS) + // Why: a pending cache save must not keep a quitting process alive. + if (typeof saveTimer.unref === 'function') { + saveTimer.unref() + } +} + +/** Run any pending debounced save immediately and wait for it. Test-only. */ +export async function flushSessionParseCachePersistForTests(): Promise { + if (saveTimer) { + clearTimeout(saveTimer) + saveTimer = null + if (options !== null) { + const current = options + lastSave = lastSave.then(() => persistSnapshot(current)) + } + } + await lastSave +} + +async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise { + await sweepOrphanedTempFiles(current.filePath) + try { + const raw = await readFile(current.filePath, 'utf-8') + const entries = parsePersistedFile(JSON.parse(raw), current.appVersion) + if (entries) { + seedSessionParseCache(entries) + } + } catch { + // Why: a missing/corrupt/foreign cache file must never fail the scan; + // worst case is exactly today's cold scan. + } +} + +// A death between temp-write and rename orphans a uniquely named .tmp forever; +// sweep once per launch so they can't accumulate. Racing another instance's +// in-flight save at worst loses that save — the already-accepted rename trade. +async function sweepOrphanedTempFiles(filePath: string): Promise { + const directory = dirname(filePath) + try { + const names = await readdir(directory) + await Promise.all( + names + .filter((name) => name.startsWith('session-parse-cache-') && name.endsWith('.tmp')) + .map((name) => rm(join(directory, name), { force: true }).catch(() => {})) + ) + } catch { + // Directory missing or unreadable — nothing to sweep. + } +} + +function parsePersistedFile( + parsed: unknown, + appVersion: string +): [string, PersistedSessionParseCacheEntry][] | null { + if (typeof parsed !== 'object' || parsed === null) { + return null + } + const file = parsed as Record + // Why: parser output shape/semantics may change between app versions, so a + // cross-version file is discarded — one cold scan per update is the price. + if (file.schemaVersion !== SCHEMA_VERSION || file.appVersion !== appVersion) { + return null + } + if (!Array.isArray(file.entries)) { + return null + } + const entries: [string, PersistedSessionParseCacheEntry][] = [] + for (const item of file.entries) { + const entry = parsePersistedEntry(item) + if (entry === null) { + // One malformed entry means the file can't be trusted; discard it whole. + return null + } + entries.push(entry) + } + return entries +} + +function parsePersistedEntry(item: unknown): [string, PersistedSessionParseCacheEntry] | null { + if (!Array.isArray(item) || item.length !== 2) { + return null + } + const [path, value] = item as [unknown, unknown] + if (typeof path !== 'string' || typeof value !== 'object' || value === null) { + return null + } + const entry = value as Record + if (typeof entry.mtimeMs !== 'number') { + return null + } + if (entry.sizeBytes !== null && typeof entry.sizeBytes !== 'number') { + return null + } + if (typeof entry.platform !== 'string') { + return null + } + if (entry.session !== null && typeof entry.session !== 'object') { + return null + } + return [ + path, + { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform as NodeJS.Platform, + session: entry.session as PersistedSessionParseCacheEntry['session'] + } + ] +} + +async function persistSnapshot(current: SessionParseCachePersistenceOptions): Promise { + const directory = dirname(current.filePath) + const tempPath = join(directory, `session-parse-cache-${process.pid}-${Date.now()}.tmp`) + try { + const payload = JSON.stringify({ + schemaVersion: SCHEMA_VERSION, + appVersion: current.appVersion, + entries: snapshotSessionParseCacheForPersistence() + }) + await mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }) + await writeFile(tempPath, payload, { mode: PRIVATE_FILE_MODE }) + // Atomic on POSIX; on Windows a rename racing an open handle fails and is + // caught below (save lost, never a torn file). + await rename(tempPath, current.filePath) + } catch (err) { + // Why: the save runs from a timer — every error must be swallowed here or + // it becomes an unhandled rejection. Worst case is the no-file case. + await rm(tempPath, { force: true }).catch(() => {}) + console.debug('[ai-vault] session parse cache save failed', err) + } +} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index d42da123437..b7aab770432 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -97,6 +97,52 @@ export function resetSessionParseCacheForTests(): void { cache.clear() } +// Persisted subset of a cache entry: the non-serializable `resume` parser +// state is dropped (see session-parse-cache-persistence.ts). +export type PersistedSessionParseCacheEntry = Omit + +export function snapshotSessionParseCacheForPersistence(): [ + string, + PersistedSessionParseCacheEntry +][] { + return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [ + path, + { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session + } + ]) +} + +// Seeded entries carry `resume: null`: after a restart an unchanged file is a +// cache hit; a file that changed while the app was closed pays one full +// (not incremental) re-parse. +export function seedSessionParseCache( + entries: Iterable<[string, PersistedSessionParseCacheEntry]> +): void { + const list = [...entries] + // Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest + // tail rather than seeding the oldest entries and dropping the tail. + for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) { + if (cache.size >= MAX_CACHE_ENTRIES) { + return + } + // In-process entries are always fresher than persisted ones; never clobber. + if (cache.has(path)) { + continue + } + cache.set(path, { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session, + resume: null + }) + } +} + function storeEntry(path: string, entry: SessionParseCacheEntry): void { cache.delete(path) cache.set(path, entry) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index ddb9ba7a861..795257c7408 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -13,6 +13,10 @@ import { } from './session-scanner-antigravity-history' import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' +import { + ensureSessionParseCacheLoaded, + scheduleSessionParseCachePersist +} from './session-parse-cache-persistence' import { createSessionParseStats, parseAgentSessionFileCached, @@ -61,6 +65,9 @@ export async function scanAiVaultSessions( const issues: AiVaultScanIssue[] = [] const parseStats = createSessionParseStats() const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) + // Why: persisted entries must be seeded before any candidate is parsed, or + // the cold scan gains nothing from the cache file (#9210). + await ensureSessionParseCacheLoaded() const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) const candidates = discoveries @@ -113,6 +120,8 @@ export async function scanAiVaultSessions( span.setAttribute('bytesRead', parseStats.bytesRead) span.setAttribute('issues', issues.length) + scheduleSessionParseCachePersist(parseStats) + return { sessions: mergeSessions(cappedSessions, scopeSessions), issues: issues.map((issue) => ({ executionHostId, ...issue })), diff --git a/src/main/azure-devops/azure-devops-api-request.ts b/src/main/azure-devops/azure-devops-api-request.ts index 089975cf5a2..5fc8f256793 100644 --- a/src/main/azure-devops/azure-devops-api-request.ts +++ b/src/main/azure-devops/azure-devops-api-request.ts @@ -73,7 +73,11 @@ function apiUrl( export async function requestAzureDevOpsJsonAtBase( baseUrl: string, path: string, - options: AzureDevOpsRequestOptions = {} + options: AzureDevOpsRequestOptions = {}, + // Why: the existing-review lookup behind Create must distinguish a real + // transport/auth failure from an accepted "no PR". When true, a failed request + // throws instead of collapsing to null so callers never report false not_found. + throwOnFailure = false ): Promise { const config = getAzureDevOpsAuthConfig() try { @@ -86,10 +90,16 @@ export async function requestAzureDevOpsJsonAtBase( }) if (!response.ok) { await cancelUnreadResponseBody(response) + if (throwOnFailure) { + throw new Error(`Azure DevOps request failed: HTTP ${response.status}`) + } return null } return (await response.json()) as T - } catch { + } catch (error) { + if (throwOnFailure) { + throw error + } return null } } @@ -97,7 +107,8 @@ export async function requestAzureDevOpsJsonAtBase( export function requestAzureDevOpsJson( repo: AzureDevOpsRepoRef, path: string, - options: AzureDevOpsRequestOptions = {} + options: AzureDevOpsRequestOptions = {}, + throwOnFailure = false ): Promise { - return requestAzureDevOpsJsonAtBase(configuredApiBaseUrl(repo), path, options) + return requestAzureDevOpsJsonAtBase(configuredApiBaseUrl(repo), path, options, throwOnFailure) } diff --git a/src/main/azure-devops/client.test.ts b/src/main/azure-devops/client.test.ts index d394962fcff..8c993a9db0d 100644 --- a/src/main/azure-devops/client.test.ts +++ b/src/main/azure-devops/client.test.ts @@ -3,6 +3,7 @@ import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixture import { getAzureDevOpsAuthStatus, getAzureDevOpsPullRequestForBranch, + getAzureDevOpsPullRequestForBranchOrThrow, normalizeAzureDevOpsApiBaseUrl } from './client' import { _resetAzureDevOpsRepoRefCache } from './repository-ref' @@ -122,6 +123,37 @@ describe('Azure DevOps client', () => { ]) }) + it('getAzureDevOpsPullRequestForBranchOrThrow surfaces a list failure instead of null (finding 4)', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' + }) + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)) + if (url.pathname === '/acme/Project/_apis/git/repositories/repo') { + return new Response( + JSON.stringify({ + id: 'repo-guid', + webUrl: 'https://dev.azure.com/acme/Project/_git/repo' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + } + // The pull-request list lookup fails (auth/transport). + return new Response(JSON.stringify({ message: 'forbidden' }), { status: 403 }) + }) as never + + // The swallowing variant collapses the failure into a false "no PR". + await expect( + getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure') + ).resolves.toBeNull() + _resetAzureDevOpsRepoRefCache() + // The throwing variant makes the failure visible so eligibility records + // `unavailable` rather than a false "No pull request found". + await expect( + getAzureDevOpsPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/azure') + ).rejects.toThrow(/Azure DevOps request failed/) + }) + it('uses the most recent branch PR instead of preferring stale active PRs', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' diff --git a/src/main/azure-devops/client.ts b/src/main/azure-devops/client.ts index 2bed23c2cd4..25390ba4bfa 100644 --- a/src/main/azure-devops/client.ts +++ b/src/main/azure-devops/client.ts @@ -197,7 +197,8 @@ export async function getAzureDevOpsPullRequestForBranch( branch: string, linkedPRNumber?: number | null, connectionId?: string | null, - options: HostedReviewExecutionOptions = {} + options: HostedReviewExecutionOptions = {}, + throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { @@ -224,7 +225,8 @@ export async function getAzureDevOpsPullRequestForBranch( 'searchCriteria.status': 'all', $top: 10 } - } + }, + throwOnFailure ) const raw = (list?.value ?? []).sort(sortPullRequestsForBranch)[0] if (raw) { @@ -239,11 +241,35 @@ export async function getAzureDevOpsPullRequestForBranch( repo, `/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests/${encodePathSegment( String(linkedPRNumber) - )}` + )}`, + {}, + throwOnFailure ) return raw ? normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw) : null } +/** + * Existing-review lookup that surfaces transport/auth failures instead of + * collapsing them to null, so a failed lookup becomes + * `reviewLookupOutcome: 'unavailable'` rather than a false "No pull request found". + */ +export function getAzureDevOpsPullRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedPRNumber?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getAzureDevOpsPullRequestForBranch( + repoPath, + branch, + linkedPRNumber, + connectionId, + options, + true + ) +} + export async function getAzureDevOpsRepoSlug( repoPath: string, connectionId?: string | null, diff --git a/src/main/bitbucket/client.test.ts b/src/main/bitbucket/client.test.ts index 4e552ab59cd..7ecfeb64151 100644 --- a/src/main/bitbucket/client.test.ts +++ b/src/main/bitbucket/client.test.ts @@ -9,7 +9,11 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -import { getBitbucketAuthStatus, getBitbucketPullRequestForBranch } from './client' +import { + getBitbucketAuthStatus, + getBitbucketPullRequestForBranch, + getBitbucketPullRequestForBranchOrThrow +} from './client' import { _resetBitbucketRepoRefCache } from './repository-ref' const OLD_ENV = process.env @@ -93,6 +97,19 @@ describe('Bitbucket client', () => { ) }) + it('getBitbucketPullRequestForBranchOrThrow surfaces a failure instead of null (finding 4)', async () => { + const fetchMock = vi.fn(async () => Response.json({ error: 'forbidden' }, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + + // The swallowing variant collapses a real failure into a false "no PR". + await expect(getBitbucketPullRequestForBranch('/repo', 'feature/bitbucket')).resolves.toBeNull() + // The throwing variant makes the failure visible so eligibility records + // `unavailable` rather than a false "No pull request found". + await expect( + getBitbucketPullRequestForBranchOrThrow('/repo', 'feature/bitbucket') + ).rejects.toThrow(/Bitbucket request failed/) + }) + it('falls back to a linked PR number when branch lookup misses', async () => { const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => { if (url.includes('/statuses/build')) { diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index 6dbc11f65da..ae4158fa180 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -87,7 +87,14 @@ function apiUrl(path: string, searchParams?: RequestOptions['searchParams']): st return url.toString() } -async function requestJson(path: string, options: RequestOptions = {}): Promise { +async function requestJson( + path: string, + options: RequestOptions = {}, + // Why: the existing-review lookup behind Create must distinguish a real + // transport/auth failure from an accepted "no PR". When true, a failed request + // throws instead of collapsing to null so callers never report false not_found. + throwOnFailure = false +): Promise { const config = getAuthConfig() try { const response = await fetch(apiUrl(path, options.searchParams), { @@ -99,10 +106,16 @@ async function requestJson(path: string, options: RequestOptions = {}): Promi }) if (!response.ok) { await cancelUnreadResponseBody(response) + if (throwOnFailure) { + throw new Error(`Bitbucket request failed: HTTP ${response.status}`) + } return null } return (await response.json()) as T - } catch { + } catch (error) { + if (throwOnFailure) { + throw error + } return null } } @@ -184,7 +197,8 @@ export async function getBitbucketPullRequestForBranch( branch: string, linkedPRNumber?: number | null, connectionId?: string | null, - options: HostedReviewExecutionOptions = {} + options: HostedReviewExecutionOptions = {}, + throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { @@ -214,7 +228,8 @@ export async function getBitbucketPullRequestForBranch( q: query, state: ALL_PULL_REQUEST_STATES } - } + }, + throwOnFailure ) const raw = list?.values?.[0] if (raw) { @@ -226,11 +241,36 @@ export async function getBitbucketPullRequestForBranch( return null } const raw = await requestJson( - `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}` + `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`, + {}, + throwOnFailure ) return raw ? normalizePullRequest(repo, raw) : null } +/** + * Existing-review lookup that surfaces transport/auth failures instead of + * collapsing them to null. The hosted-review creation preflight uses this so a + * failed lookup becomes `reviewLookupOutcome: 'unavailable'`, never a false + * "No pull request found". + */ +export function getBitbucketPullRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedPRNumber?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getBitbucketPullRequestForBranch( + repoPath, + branch, + linkedPRNumber, + connectionId, + options, + true + ) +} + export async function getBitbucketRepoSlug( repoPath: string, connectionId?: string | null, diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index a42805313c0..9c17fd21116 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -29,6 +29,14 @@ vi.mock('node:child_process', () => ({ import { CliInstaller } from './cli-installer' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { + WindowsUserPathRegistryReader, + type WindowsUserPathReadResult +} from './windows-user-path-registry' + +function userPathRead(value: string | null, expandable = false): WindowsUserPathReadResult { + return { state: 'success', value, expandable } +} async function makeFixture(): Promise<{ root: string @@ -323,7 +331,7 @@ describe('CliInstaller', () => { execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', appPath: fixture.appPath, commandPathOverride: installPath, - userPathReader: async () => userPath, + userPathReader: async () => userPathRead(userPath), userPathWriter: async (value) => { userPath = value } @@ -358,7 +366,7 @@ describe('CliInstaller', () => { execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', appPath: fixture.appPath, commandPathOverride: installPath, - userPathReader: async () => 'C:\\Windows\\System32', + userPathReader: async () => userPathRead('C:\\Windows\\System32'), userPathWriter: async () => { // The .NET error id survives localized or mojibake PowerShell output. const error = new Error( @@ -390,7 +398,7 @@ describe('CliInstaller', () => { execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', appPath: fixture.appPath, commandPathOverride: join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd'), - userPathReader: async () => 'C:\\Windows\\System32', + userPathReader: async () => userPathRead('C:\\Windows\\System32'), userPathWriter }) @@ -416,7 +424,7 @@ describe('CliInstaller', () => { execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', appPath: fixture.appPath, commandPathOverride: installPath, - userPathReader: async () => 'C:\\Windows\\System32', + userPathReader: async () => userPathRead('C:\\Windows\\System32'), userPathWriter: async () => { throw new Error(message) } @@ -428,36 +436,133 @@ describe('CliInstaller', () => { } ) - it('settles when the Windows PATH query hangs', async () => { - vi.useFakeTimers() + it('reports an unknown Windows PATH without spawning PowerShell', async () => { const fixture = await makeFixture() const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') - const killMock = vi.fn() - execFileMock.mockImplementation(() => ({ kill: killMock })) const installer = new CliInstaller({ platform: 'win32', isPackaged: false, userDataPath: fixture.userDataPath, execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', appPath: fixture.appPath, - commandPathOverride: installPath + commandPathOverride: installPath, + userPathReader: async () => ({ + state: 'unknown', + detail: 'Orca could not read the Windows user PATH registry value.' + }) }) - const promise = installer.getStatus() - let settled = false - void promise - .catch(() => undefined) - .finally(() => { - settled = true + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'not_installed', + pathConfigured: null, + detail: expect.stringContaining('could not read') + }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('fails closed without writing when a Windows PATH mutation cannot read the registry', async () => { + const fixture = await makeFixture() + const userPathWriter = vi.fn() + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd'), + userPathReader: async () => ({ + state: 'unknown', + detail: 'Orca could not read the Windows user PATH registry value.' + }), + userPathWriter + }) + + await expect(installer.install()).rejects.toThrow('No PATH changes were made') + expect(userPathWriter).not.toHaveBeenCalled() + }) + + it('bypasses cached status data before a Windows PATH mutation', async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + const pathDirectory = dirname(installPath) + let registryPath = 'C:\\Tools' + const registryReader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader: async () => ({ + HK: { CU: 0x80000001 }, + getRegistryKey: () => ({ + Path: { name: 'Path', type: 2, value: registryPath } + }) }) + }) + const userPathWriter = vi.fn(async (value: string) => { + registryPath = value + }) + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + userPathReader: () => registryReader.read(), + userPathMutationReader: () => registryReader.readFresh(), + userPathWriter, + userPathCacheInvalidator: () => registryReader.invalidate() + }) - await vi.waitFor(() => expect(execFileMock).toHaveBeenCalled()) - await vi.advanceTimersByTimeAsync(5_000) - await Promise.resolve() + await installer.getStatus() + registryPath = 'C:\\Tools;C:\\AddedByAnotherInstaller' + await installer.install() - expect(settled).toBe(true) - await expect(promise).rejects.toThrow('Windows PATH command timed out') - expect(killMock).toHaveBeenCalled() + expect(userPathWriter).toHaveBeenCalledWith( + `C:\\Tools;C:\\AddedByAnotherInstaller;${pathDirectory}` + ) + }) + + it('matches expandable Windows PATH entries case-insensitively without rewriting them', async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Local App Data', 'Orca', 'bin', 'orca.cmd') + const userPathWriter = vi.fn() + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + windowsEnvironment: { LOCALAPPDATA: join(fixture.root, 'Local App Data') }, + userPathReader: async () => userPathRead('%localappdata%\\Orca\\bin\\', true), + userPathWriter + }) + + await expect(installer.install()).resolves.toMatchObject({ + state: 'installed', + pathConfigured: true + }) + expect(userPathWriter).not.toHaveBeenCalled() + }) + + it('does not expand environment variables stored in a REG_SZ Windows PATH', async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Local App Data', 'Orca', 'bin', 'orca.cmd') + const pathDirectory = dirname(installPath) + const userPathWriter = vi.fn() + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + windowsEnvironment: { LOCALAPPDATA: join(fixture.root, 'Local App Data') }, + userPathReader: async () => userPathRead('%LOCALAPPDATA%\\Orca\\bin'), + userPathWriter + }) + + await installer.install() + + expect(userPathWriter).toHaveBeenCalledWith(`%LOCALAPPDATA%\\Orca\\bin;${pathDirectory}`) }) // Why: this test creates a Unix symlink to /tmp/not-orca, which only applies on macOS/Linux. @@ -1189,7 +1294,7 @@ describe('CliInstaller', () => { userDataPath: fixture.userDataPath, execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, - userPathReader: async () => null, + userPathReader: async () => userPathRead(null), userPathWriter: async () => {} }) @@ -1197,6 +1302,33 @@ describe('CliInstaller', () => { expect(status.commandPath).toBe(join(resourcesPath, 'bin', 'orca.exe')) }) + it('keeps a bundled Windows launcher installed when the user PATH read is unknown', async () => { + const fixture = await makeFixture() + const resourcesPath = join(fixture.root, 'resources') + const bundledLauncher = join(resourcesPath, 'bin', 'orca.exe') + await mkdir(dirname(bundledLauncher), { recursive: true }) + await writeFile(bundledLauncher, 'native launcher', 'utf8') + + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: true, + resourcesPath, + userDataPath: fixture.userDataPath, + execPath: join(fixture.root, 'Orca.exe'), + appPath: fixture.appPath, + userPathReader: async () => ({ + state: 'unknown', + detail: 'Orca could not read the Windows user PATH registry value.' + }) + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'installed', + pathConfigured: null, + detail: expect.stringContaining('could not read') + }) + }) + it('does not overwrite the packaged Windows launcher while registering PATH', async () => { const fixture = await makeFixture() const localAppDataPath = join(fixture.root, 'AppData', 'Local') @@ -1215,7 +1347,7 @@ describe('CliInstaller', () => { userDataPath: fixture.userDataPath, execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, - userPathReader: async () => userPath, + userPathReader: async () => userPathRead(userPath), userPathWriter: async (value) => { userPath = value } diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index c5f1286126b..431c04e917b 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -18,6 +18,12 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:pat import { promisify } from 'node:util' import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { + invalidateWindowsUserPathRegistryCache, + readFreshWindowsUserPathRegistry, + readWindowsUserPathRegistry, + type WindowsUserPathReadResult +} from './windows-user-path-registry' const execFileAsync = promisify(execFile) const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca' @@ -25,7 +31,7 @@ const DEV_COMMAND_NAME = 'orca-dev' const LINUX_COMMAND_NAME = 'orca-ide' const LEGACY_LINUX_COMMAND_NAME = 'orca' const DEV_LAUNCHER_DIR = ['cli', 'bin'] -const WINDOWS_PATH_COMMAND_TIMEOUT_MS = 5_000 +const WINDOWS_PATH_WRITE_TIMEOUT_MS = 5_000 type CliInstallerOptions = { platform?: NodeJS.Platform @@ -41,8 +47,11 @@ type CliInstallerOptions = { /** Feeds into the /usr/local/bin existence check at construction time; used in tests to simulate absent /usr/local/bin on arm64 without relying on real filesystem state. */ defaultMacCommandPath?: string privilegedRunner?: (command: string) => Promise - userPathReader?: () => Promise + userPathReader?: () => Promise + userPathMutationReader?: () => Promise userPathWriter?: (value: string) => Promise + userPathCacheInvalidator?: () => void + windowsEnvironment?: NodeJS.ProcessEnv /** Why: AppImage reports a stable outer file path via $APPIMAGE while bundled resources live in an ephemeral FUSE mount. */ appImagePath?: string | null } @@ -65,8 +74,11 @@ export class CliInstaller { private readonly commandPathOverride: string | null private readonly macCommandPath: string private readonly privilegedRunner: (command: string) => Promise - private readonly userPathReader: () => Promise + private readonly userPathReader: () => Promise + private readonly userPathMutationReader: () => Promise private readonly userPathWriter: (value: string) => Promise + private readonly userPathCacheInvalidator: () => void + private readonly windowsEnvironment: NodeJS.ProcessEnv private readonly appImagePath: string | null private get commandName(): string { @@ -105,8 +117,13 @@ export class CliInstaller { ? candidateMacPath : join(this.homePath, '.local', 'bin', 'orca') this.privilegedRunner = options.privilegedRunner ?? runMacPrivilegedCommand - this.userPathReader = options.userPathReader ?? (() => readWindowsUserPath()) + this.userPathReader = options.userPathReader ?? readWindowsUserPathRegistry + this.userPathMutationReader = + options.userPathMutationReader ?? options.userPathReader ?? readFreshWindowsUserPathRegistry this.userPathWriter = options.userPathWriter ?? ((value) => writeWindowsUserPath(value)) + this.userPathCacheInvalidator = + options.userPathCacheInvalidator ?? invalidateWindowsUserPathRegistryCache + this.windowsEnvironment = options.windowsEnvironment ?? process.env this.appImagePath = this.platform === 'linux' && this.isPackaged ? (options.appImagePath ?? process.env.APPIMAGE ?? null) @@ -164,8 +181,8 @@ export class CliInstaller { ? await this.inspectAppImageWrapper(spec.commandPath, launcherPath) : await this.inspectWindowsWrapper(spec.commandPath, launcherPath) const pathDirectory = dirname(spec.commandPath) - const pathConfigured = await this.isPathConfigured(pathDirectory) - return this.withPathInfo(baseStatus, pathDirectory, pathConfigured) + const pathProbe = await this.probePathConfiguration(pathDirectory) + return this.withPathInfo(baseStatus, pathDirectory, pathProbe) } async install(): Promise { @@ -763,23 +780,40 @@ export class CliInstaller { } } - private async isPathConfigured(pathDirectory: string): Promise { - const pathValue = - this.platform === 'win32' ? await this.userPathReader() : (this.processPathEnv ?? '') - return splitPathEntries(this.platform, pathValue).some((entry) => - samePathEntry(this.platform, entry, pathDirectory) - ) + private async probePathConfiguration( + pathDirectory: string + ): Promise<{ configured: boolean | null; detail: string | null }> { + if (this.platform !== 'win32') { + return { + configured: splitPathEntries(this.platform, this.processPathEnv ?? '').some((entry) => + samePathEntry(this.platform, entry, pathDirectory) + ), + detail: null + } + } + + const result = await this.userPathReader() + if (result.state === 'unknown') { + return { configured: null, detail: result.detail } + } + return { + configured: splitPathEntries('win32', result.value).some((entry) => + samePathEntry('win32', entry, pathDirectory, this.windowsEnvironment, result.expandable) + ), + detail: null + } } private withPathInfo( status: CliInstallStatus, pathDirectory: string, - pathConfigured: boolean + pathProbe: { configured: boolean | null; detail: string | null } ): CliInstallStatus { + const { configured: pathConfigured } = pathProbe if ( this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath) && status.state === 'installed' && - !pathConfigured + pathConfigured === false ) { return { ...status, @@ -791,6 +825,17 @@ export class CliInstaller { } } + if (pathConfigured === null) { + return { + ...status, + pathDirectory, + pathConfigured, + detail: + pathProbe.detail ?? + 'The Orca launcher exists, but Orca could not check your Windows user PATH.' + } + } + if (status.state !== 'installed') { return { ...status, @@ -819,9 +864,13 @@ export class CliInstaller { } private async ensureWindowsPathEntry(pathDirectory: string): Promise { - const current = await this.userPathReader() - const entries = splitPathEntries('win32', current) - if (entries.some((entry) => samePathEntry('win32', entry, pathDirectory))) { + const current = await this.readWindowsUserPathForMutation() + const entries = splitPathEntries('win32', current.value) + if ( + entries.some((entry) => + samePathEntry('win32', entry, pathDirectory, this.windowsEnvironment, current.expandable) + ) + ) { return } entries.push(pathDirectory) @@ -832,15 +881,31 @@ export class CliInstaller { if (this.platform !== 'win32') { return } - const current = await this.userPathReader() - const entries = splitPathEntries('win32', current) - const nextEntries = entries.filter((entry) => !samePathEntry('win32', entry, pathDirectory)) + const current = await this.readWindowsUserPathForMutation() + const entries = splitPathEntries('win32', current.value) + const nextEntries = entries.filter( + (entry) => + !samePathEntry('win32', entry, pathDirectory, this.windowsEnvironment, current.expandable) + ) if (nextEntries.length === entries.length) { return } await this.writeWindowsUserPathEntry(nextEntries.join(';'), pathDirectory, 'remove') } + private async readWindowsUserPathForMutation(): Promise<{ + value: string | null + expandable: boolean + }> { + const result = await this.userPathMutationReader() + if (result.state === 'success') { + return { value: result.value, expandable: result.expandable } + } + // Why: PATH updates are read-modify-write; continuing after an unknown read + // could replace the user's existing PATH with an incomplete value. + throw new Error(`${result.detail} No PATH changes were made.`) + } + // Why: raw PowerShell errors reach the UI, so translate denied PATH writes // while preserving the original diagnostic as the error cause. private async writeWindowsUserPathEntry( @@ -850,6 +915,7 @@ export class CliInstaller { ): Promise { try { await this.userPathWriter(value) + this.userPathCacheInvalidator() } catch (error) { if (!isWindowsUserPathPermissionError(error)) { throw error @@ -1019,9 +1085,16 @@ function uniquePathEntries(platform: NodeJS.Platform, entries: string[]): string return result } -function samePathEntry(platform: NodeJS.Platform, left: string, right: string): boolean { +function samePathEntry( + platform: NodeJS.Platform, + left: string, + right: string, + windowsEnvironment: NodeJS.ProcessEnv = process.env, + expandWindowsVariables = true +): boolean { return platform === 'win32' - ? normalizeWindowsPath(left) === normalizeWindowsPath(right) + ? normalizeWindowsPath(left, windowsEnvironment, expandWindowsVariables) === + normalizeWindowsPath(right, windowsEnvironment, expandWindowsVariables) : left === right } @@ -1043,8 +1116,22 @@ async function isExecutableFile(commandPath: string): Promise { } } -function normalizeWindowsPath(value: string): string { - return value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase() +function normalizeWindowsPath( + value: string, + env: NodeJS.ProcessEnv = process.env, + expandEnvironmentVariables = true +): string { + return (expandEnvironmentVariables ? expandWindowsEnvironmentVariables(value, env) : value) + .replaceAll('/', '\\') + .replace(/\\+$/, '') + .toLowerCase() +} + +function expandWindowsEnvironmentVariables(value: string, env: NodeJS.ProcessEnv): string { + return value.replace(/%([^%]+)%/g, (match, rawName: string) => { + const envKey = Object.keys(env).find((key) => key.toLowerCase() === rawName.toLowerCase()) + return envKey && env[envKey] ? env[envKey] : match + }) } function escapeWindowsBatchValue(value: string): string { @@ -1108,15 +1195,6 @@ function isAbsoluteForPlatform(platform: NodeJS.Platform, value: string): boolea return isAbsolute(value) } -async function readWindowsUserPath(): Promise { - const stdout = await runWindowsPathCommand([ - '-NoProfile', - '-Command', - "[Environment]::GetEnvironmentVariable('Path','User')" - ]) - return stdout.trim() || null -} - async function writeWindowsUserPath(value: string): Promise { await runWindowsPathCommand([ '-NoProfile', @@ -1150,16 +1228,14 @@ function runWindowsPathCommand(args: string[]): Promise { // not keep command registration status or install/remove pending forever. const timeout = setTimeout(() => { child?.kill() - finish( - new Error(`Windows PATH command timed out after ${WINDOWS_PATH_COMMAND_TIMEOUT_MS}ms.`) - ) - }, WINDOWS_PATH_COMMAND_TIMEOUT_MS) + finish(new Error(`Windows PATH command timed out after ${WINDOWS_PATH_WRITE_TIMEOUT_MS}ms.`)) + }, WINDOWS_PATH_WRITE_TIMEOUT_MS) try { child = execFile( 'powershell', args, - { encoding: 'utf8', timeout: WINDOWS_PATH_COMMAND_TIMEOUT_MS }, + { encoding: 'utf8', timeout: WINDOWS_PATH_WRITE_TIMEOUT_MS }, (error, stdout) => { finish(error ?? null, stdout) } diff --git a/src/main/cli/windows-user-path-registry.test.ts b/src/main/cli/windows-user-path-registry.test.ts new file mode 100644 index 00000000000..12beca13852 --- /dev/null +++ b/src/main/cli/windows-user-path-registry.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import { + invalidateWindowsUserPathRegistryCache, + readWindowsUserPathRegistry, + WindowsUserPathRegistryReader +} from './windows-user-path-registry' + +function registryModule(pathValue?: string, type = 2) { + return { + HK: { CU: 0x80000001 }, + REG: { SZ: 1, EXPAND_SZ: 2 }, + getRegistryKey: vi.fn(() => + pathValue === undefined ? {} : { Path: { name: 'Path', type, value: pathValue } } + ) + } +} + +describe('WindowsUserPathRegistryReader', () => { + it('reads Unicode REG_EXPAND_SZ values without expanding the stored text', async () => { + const registry = registryModule('%LOCALAPPDATA%\\Orca;C:\\工具\\bin;C:\\Développement') + const reader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader: async () => registry + }) + + await expect(reader.read()).resolves.toEqual({ + state: 'success', + value: '%LOCALAPPDATA%\\Orca;C:\\工具\\bin;C:\\Développement', + expandable: true + }) + expect(registry.getRegistryKey).toHaveBeenCalledWith(0x80000001, 'Environment') + }) + + it('distinguishes a missing Path value from a registry read failure', async () => { + const missingReader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader: async () => registryModule() + }) + const failedReader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader: async () => { + throw new Error('native addon unavailable') + } + }) + + await expect(missingReader.read()).resolves.toEqual({ + state: 'success', + value: null, + expandable: false + }) + await expect(failedReader.read()).resolves.toMatchObject({ state: 'unknown' }) + }) + + it('coalesces concurrent reads, caches status reads, and bypasses the cache when fresh', async () => { + let now = 100 + let resolveRegistry!: (value: ReturnType) => void + const registryLoader = vi.fn( + () => + new Promise>((resolve) => { + resolveRegistry = resolve + }) + ) + const reader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader, + now: () => now, + cacheTtlMs: 1_000 + }) + + const first = reader.read() + const concurrent = reader.read() + expect(registryLoader).toHaveBeenCalledOnce() + resolveRegistry(registryModule('C:\\Tools')) + await expect(Promise.all([first, concurrent])).resolves.toEqual([ + { state: 'success', value: 'C:\\Tools', expandable: true }, + { state: 'success', value: 'C:\\Tools', expandable: true } + ]) + + await reader.read() + expect(registryLoader).toHaveBeenCalledOnce() + + now += 1 + const refreshed = reader.readFresh() + expect(registryLoader).toHaveBeenCalledTimes(2) + resolveRegistry(registryModule('C:\\Tools;C:\\Orca')) + await expect(refreshed).resolves.toEqual({ + state: 'success', + value: 'C:\\Tools;C:\\Orca', + expandable: true + }) + }) + + it.each([ + { type: 1, expandable: false }, + { type: 2, expandable: true } + ])('preserves whether registry type $type expands variables', async ({ type, expandable }) => { + const reader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader: async () => registryModule('%LOCALAPPDATA%\\Orca', type) + }) + + await expect(reader.read()).resolves.toEqual({ + state: 'success', + value: '%LOCALAPPDATA%\\Orca', + expandable + }) + }) + + it('does not load the Windows-only dependency on other platforms', async () => { + const registryLoader = vi.fn(async () => registryModule('C:\\Tools')) + const reader = new WindowsUserPathRegistryReader({ + platform: 'linux', + registryLoader + }) + + await expect(reader.read()).resolves.toMatchObject({ state: 'unknown' }) + expect(registryLoader).not.toHaveBeenCalled() + }) + + it.runIf(process.platform === 'win32')( + 'loads the real native addon and reads the current Windows user PATH', + async () => { + invalidateWindowsUserPathRegistryCache() + await expect(readWindowsUserPathRegistry()).resolves.toMatchObject({ state: 'success' }) + } + ) +}) diff --git a/src/main/cli/windows-user-path-registry.ts b/src/main/cli/windows-user-path-registry.ts new file mode 100644 index 00000000000..d3e493fd87d --- /dev/null +++ b/src/main/cli/windows-user-path-registry.ts @@ -0,0 +1,160 @@ +import { createRequire } from 'node:module' + +export type WindowsUserPathReadResult = + | { state: 'success'; value: string | null; expandable: boolean } + | { state: 'unknown'; detail: string } + +type RegistryValue = { + name?: unknown + type?: unknown + value?: unknown +} + +type WindowsRegistryModule = { + HK: { CU: number } + getRegistryKey: ( + root: number, + path: string + ) => Record | null | undefined +} + +type WindowsUserPathRegistryReaderOptions = { + platform?: NodeJS.Platform + registryLoader?: () => Promise + now?: () => number + cacheTtlMs?: number +} + +const DEFAULT_CACHE_TTL_MS = 1_000 +const USER_ENVIRONMENT_KEY = 'Environment' +const USER_PATH_VALUE = 'Path' +const REG_SZ = 1 +const REG_EXPAND_SZ = 2 +const requireFromMain = createRequire(__filename) + +async function loadWindowsRegistryModule(): Promise { + // Why: the optional dependency is not present in non-Windows installs, so + // TypeScript and the main bundle must not resolve it eagerly. + return requireFromMain('windows-native-registry') as WindowsRegistryModule +} + +export class WindowsUserPathRegistryReader { + private readonly platform: NodeJS.Platform + private readonly registryLoader: () => Promise + private readonly now: () => number + private readonly cacheTtlMs: number + private cached: { readAt: number; result: WindowsUserPathReadResult } | null = null + private inFlight: Promise | null = null + private generation = 0 + + constructor(options: WindowsUserPathRegistryReaderOptions = {}) { + this.platform = options.platform ?? process.platform + this.registryLoader = options.registryLoader ?? loadWindowsRegistryModule + this.now = options.now ?? Date.now + this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS + } + + async read(): Promise { + if (this.platform !== 'win32') { + return { + state: 'unknown', + detail: 'The Windows user PATH registry is only available on Windows.' + } + } + + const now = this.now() + const cacheAge = this.cached ? now - this.cached.readAt : null + if (this.cached && cacheAge !== null && cacheAge >= 0 && cacheAge < this.cacheTtlMs) { + return this.cached.result + } + if (this.inFlight) { + return this.inFlight + } + + const generation = this.generation + const inFlight = this.readUncached().then((result) => { + if (result.state === 'success' && generation === this.generation) { + this.cached = { readAt: this.now(), result } + } + return result + }) + this.inFlight = inFlight + try { + return await inFlight + } finally { + if (this.inFlight === inFlight) { + this.inFlight = null + } + } + } + + async readFresh(): Promise { + this.invalidate() + const generation = this.generation + const result = await this.readUncached() + if (result.state === 'success' && generation === this.generation) { + this.cached = { readAt: this.now(), result } + } + return result + } + + invalidate(): void { + this.generation += 1 + this.cached = null + // Why: a mutation must not join a registry read that may predate an external PATH update. + this.inFlight = null + } + + private async readUncached(): Promise { + try { + const registry = await this.registryLoader() + const key = registry.getRegistryKey(registry.HK.CU, USER_ENVIRONMENT_KEY) + if (!key || typeof key !== 'object') { + return { + state: 'unknown', + detail: 'Orca could not read the Windows user PATH registry key.' + } + } + + const pathEntry = Object.entries(key).find( + ([name]) => name.toLowerCase() === USER_PATH_VALUE.toLowerCase() + )?.[1] + if (!pathEntry) { + return { state: 'success', value: null, expandable: false } + } + if ( + (pathEntry.type !== REG_SZ && pathEntry.type !== REG_EXPAND_SZ) || + typeof pathEntry.value !== 'string' + ) { + return { + state: 'unknown', + detail: 'The Windows user PATH registry value has an unsupported format.' + } + } + return { + state: 'success', + value: pathEntry.value || null, + expandable: pathEntry.type === REG_EXPAND_SZ + } + } catch { + return { + state: 'unknown', + detail: 'Orca could not read the Windows user PATH registry value.' + } + } + } +} + +const defaultWindowsUserPathRegistryReader = new WindowsUserPathRegistryReader() + +export function readWindowsUserPathRegistry(): Promise { + return defaultWindowsUserPathRegistryReader.read() +} + +export function readFreshWindowsUserPathRegistry(): Promise { + return defaultWindowsUserPathRegistryReader.readFresh() +} + +export function invalidateWindowsUserPathRegistryCache(): void { + defaultWindowsUserPathRegistryReader.invalidate() +} diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 547679df46f..13bd8088de3 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -206,6 +206,27 @@ describe('WslCliInstaller', () => { expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]') }) + it('continues checking WSL when the host launcher exists but host PATH is unknown', async () => { + const wsl = createWslRunner() + const hostStatus = { + ...makeHostStatus(), + pathConfigured: null, + detail: 'Orca could not read the Windows user PATH registry value.' + } satisfies CliInstallStatus + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => hostStatus }, + wslRunner: wsl.runner + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + supported: true, + state: 'not_installed', + commandPath: '/home/alice/.local/bin/orca-ide' + }) + }) + it('derives the shared WSL bridge path for current and legacy command names', () => { expect(_internals.getBridgePathFromCommandPath('/home/alice/.local/bin/orca-ide')).toBe( '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' diff --git a/src/main/daemon/daemon-create-or-attach-result.ts b/src/main/daemon/daemon-create-or-attach-result.ts new file mode 100644 index 00000000000..79417577ab7 --- /dev/null +++ b/src/main/daemon/daemon-create-or-attach-result.ts @@ -0,0 +1,31 @@ +import type { TuiAgent } from '../../shared/types' +import type { ShellReadyState, TerminalSnapshot } from './types' + +export type DaemonCreateOrAttachResult = { + isNew: boolean + snapshot: TerminalSnapshot | null + pid: number | null + shellState: ShellReadyState + historySeeded?: boolean + launchAgent?: TuiAgent + /** Undefined only when talking to a daemon predating WSL session context. */ + wslDistro?: string | null +} + +export function getDaemonSessionResultMetadata(session: { + launchAgent: TuiAgent | null + historySeeded: boolean | undefined + wslDistro: string | null +}): { + launchAgent?: TuiAgent + historySeeded?: boolean + wslDistro: string | null +} { + return { + ...(session.launchAgent ? { launchAgent: session.launchAgent } : {}), + ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), + // Why: null authoritatively identifies a native session; omission is + // reserved for older daemons that predate this wire field. + wslDistro: session.wslDistro + } +} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 37232547d76..707d2800e15 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -1,6 +1,6 @@ /* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'node:os' +import { hostname, tmpdir } from 'node:os' import { join } from 'node:path' import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' import { DaemonClient } from './client' @@ -215,6 +215,38 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.id).toContain('wt-1') }) + it('keeps a reattached native UNC session native despite a conflicting WSL preference', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const sessionId = 'native-conflicting-wsl-attach' + const created = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId, + cwd: '\\\\server\\share\\repo', + shellOverride: 'powershell.exe' + }) + const attached = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId, + cwd: 'C:\\repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + + expect(created.wslDistro).toBeNull() + expect(attached.wslDistro).toBeNull() + expect(attached.isReattach).toBe(true) + expect(lastSpawnOpts?.cwd).toBe('\\\\server\\share\\repo') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + itOnPosix('keeps plain Codex startup on the short daemon shell-ready timeout', async () => { await adapter.spawn({ cols: 80, @@ -1416,6 +1448,46 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + it('repairs legacy hostname UNC cwd for WSL spawn and cold-restore metadata', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const sessionId = 'wsl-legacy-cwd' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: `\\\\${hostname()}\\home\\jin`, + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'legacy WSL output\r\n') + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + terminalWindowsWslDistro: 'Debian', + sessionId + }) + + const repaired = '\\\\wsl.localhost\\Ubuntu\\home\\jin' + expect(lastSpawnOpts?.cwd).toBe(repaired) + expect(result.coldRestore?.cwd).toBe(repaired) + expect(result.wslDistro).toBe('Ubuntu') + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('returns cold restore OSC link ranges from checkpoint history', async () => { const sessionId = 'cold-restore-osc-links' const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index ef54f716976..ce2f63aae2b 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -30,9 +30,12 @@ import type { PtySpawnResult } from '../providers/types' import { isShellProcess } from '../../shared/agent-detection' +import { resolveWslSessionContext } from './wsl-session-context' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd' type ColdRestorePayload = { scrollback: string @@ -107,6 +110,7 @@ export class DaemonPtyAdapter implements IPtyProvider { private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] private removeEventListener: (() => void) | null = null private initialCwds = new Map() + private wslDistrosBySessionId = new Map() // Why: React re-renders and StrictMode double-mounts can call createOrAttach // for a session the user just killed. Without tombstones, the daemon would // create a fresh session — resurrecting a terminal the user explicitly closed. @@ -201,6 +205,28 @@ export class DaemonPtyAdapter implements IPtyProvider { private async doSpawn(opts: PtySpawnOptions): Promise { const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId) + let wslDistro = resolveWslSessionContext({ + cwd: opts.cwd, + sessionId, + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro + })?.distro + const detectColdRestore = (options?: { ignoreCleanEnd?: boolean }): ColdRestoreInfo | null => { + const restoreInfo = + this.historyReader?.detectColdRestore(sessionId, { ...options, wslDistro }) ?? null + if (!restoreInfo) { + return null + } + return { + ...restoreInfo, + cwd: + normalizeWslColdRestoreCwd({ + recoveredCwd: restoreInfo.cwd, + requestedCwd: opts.cwd ?? resolveSafePtyDefaultCwd(), + wslDistro + }) ?? '' + } + } if (this.killedSessionTombstones.has(sessionId)) { throw new TerminalKilledError(sessionId) @@ -233,7 +259,7 @@ export class DaemonPtyAdapter implements IPtyProvider { if ((await this.getAppliedSize(sessionId)) !== null) { restoreSkippedForLiveSession = true } else { - restoreInfo = this.historyReader.detectColdRestore(sessionId) + restoreInfo = detectColdRestore() } } let effectiveCwd = restoreInfo?.cwd ?? opts.cwd @@ -283,6 +309,15 @@ export class DaemonPtyAdapter implements IPtyProvider { let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null let result = await createOrAttach(scrollback) + let providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + // Why: explicit null from a current daemon overrides the caller's WSL + // preference; undefined preserves compatibility with older daemons. + wslDistro = providerWslDistro ?? undefined + if (wslDistro) { + this.wslDistrosBySessionId.set(sessionId, wslDistro) + } else if (providerWslDistro === null || result.isNew) { + this.wslDistrosBySessionId.delete(sessionId) + } const launchIdentity = (): { launchAgent?: NonNullable } => result.launchAgent ? { launchAgent: result.launchAgent } : {} @@ -313,6 +348,7 @@ export class DaemonPtyAdapter implements IPtyProvider { pid, ...launchIdentity(), coldRestore: cachedRestore, + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(!result.isNew ? { isReattach: true } : {}) } } @@ -326,8 +362,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // must not null the restore here, or the openSession branch below would // delete the checkpoint instead of restoring it. if (result.isNew && restoreSkippedForLiveSession) { - restoreInfo = - this.historyReader?.detectColdRestore(sessionId, { ignoreCleanEnd: true }) ?? null + restoreInfo = detectColdRestore({ ignoreCleanEnd: true }) scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null if (restoreInfo && scrollback) { // Why: the aliveness probe raced with session death, so the first @@ -337,11 +372,18 @@ export class DaemonPtyAdapter implements IPtyProvider { effectiveCols = restoreInfo.cols effectiveRows = restoreInfo.rows result = await createOrAttach(scrollback) + providerWslDistro = result.wslDistro === undefined ? wslDistro : result.wslDistro + wslDistro = providerWslDistro ?? undefined + if (wslDistro) { + this.wslDistrosBySessionId.set(sessionId, wslDistro) + } else if (providerWslDistro === null || result.isNew) { + this.wslDistrosBySessionId.delete(sessionId) + } pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null this.initialCwds.set(sessionId, effectiveCwd) } } else if (!result.isNew && result.historySeeded === false) { - restoreInfo = this.historyReader?.detectColdRestore(sessionId) ?? null + restoreInfo = detectColdRestore() scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null } @@ -379,6 +421,7 @@ export class DaemonPtyAdapter implements IPtyProvider { pid, ...launchIdentity(), coldRestore, + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), ...(!result.isNew ? { isReattach: true } : {}) } @@ -387,6 +430,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}) } } @@ -426,6 +470,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), ...(providerSequence ? { providerSequence } : {}), ...(isReattach ? { isReattach: true } : {}) } @@ -445,6 +490,7 @@ export class DaemonPtyAdapter implements IPtyProvider { id: sessionId, pid, ...launchIdentity(), + ...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}), snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, @@ -535,7 +581,19 @@ export class DaemonPtyAdapter implements IPtyProvider { await this.checkpointInFlight } await this.checkpointSessions([id], { final: true, teardown: true }) - const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null + const wslDistro = this.wslDistrosBySessionId.get(id) + const detected = this.historyReader?.detectColdRestore(id, { wslDistro }) ?? null + const restoreInfo = detected + ? { + ...detected, + cwd: + normalizeWslColdRestoreCwd({ + recoveredCwd: detected.cwd, + requestedCwd: this.initialCwds.get(id) ?? resolveSafePtyDefaultCwd(), + wslDistro + }) ?? '' + } + : null const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null if (coldRestore) { this.coldRestoreCache.set(id, coldRestore) @@ -560,6 +618,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.lastFullCheckpointAt.delete(id) this.stopCheckpointTimerIfIdle() this.initialCwds.delete(id) + this.wslDistrosBySessionId.delete(id) // Why: history removal is for the "user explicitly closed this terminal" // path. Sleep also calls shutdown but expects scrollback to survive — wake // re-spawns and the cold-restore reader needs the dir intact. Caller @@ -917,6 +976,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() + this.wslDistrosBySessionId.clear() this.pausedProducerSessionIds.clear() this.producerResumesOwedOnReconnect.clear() this.removeEventListener?.() @@ -955,6 +1015,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() + this.wslDistrosBySessionId.clear() // Why: the detached daemon keeps these PTYs alive for warm reattach; a // pause left behind would block their shells for a failsafe window. for (const id of this.pausedProducerSessionIds) { @@ -1402,6 +1463,7 @@ export class DaemonPtyAdapter implements IPtyProvider { .catch((err) => console.warn('[history] closeSession failed:', event.sessionId, err)) } this.initialCwds.delete(event.sessionId) + this.wslDistrosBySessionId.delete(event.sessionId) // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration for (const listener of [...this.exitListeners]) { listener({ id: event.sessionId, code: event.payload.code }) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 2ca2323e2ae..2da35f53743 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -479,6 +479,7 @@ export class DaemonServer { pid: result.pid, shellState: result.shellState, ...(result.launchAgent ? { launchAgent: result.launchAgent } : {}), + wslDistro: result.wslDistro, ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}) } } diff --git a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts index 8ce1191722d..77db320650c 100644 --- a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts +++ b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts @@ -19,8 +19,8 @@ import { writeChunksToTerminal } from '../../shared/terminal-restore-parity-fixture' -// Differential garble gate for the hidden-terminal model/view contract -// (docs/reference/terminal-model-view-contract.md): with the hidden-delivery +// Differential garble gate for the hidden-terminal model/view contract: +// with the hidden-delivery // gate on, a hidden pane receives NOTHING — main's HeadlessEmulator is the // source of truth and reveal repaints the renderer xterm from // preamble + rehydrateSequences + snapshotAnsi (applyMainBufferSnapshot). diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index b9630b8ff7e..37633f32334 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -263,6 +263,22 @@ describe('HeadlessEmulator', () => { expect(emulator.getSnapshot().cwd).toBe(expectedNativePath('/split-escape')) }) + + it('keeps split WSL OSC-7 parsing scoped to each emulator distro', async () => { + const ubuntu = new HeadlessEmulator({ cols: 80, rows: 24, wslDistro: 'Ubuntu' }) + const debian = new HeadlessEmulator({ cols: 80, rows: 24, wslDistro: 'Debian' }) + try { + await ubuntu.write('\x1b]7;file://machine/home/jin') + await debian.write('\x1b]7;file://machine/home/jin/repo\x07') + await ubuntu.write('/repo\x07') + + expect(ubuntu.getSnapshot().cwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + expect(debian.getSnapshot().cwd).toBe('\\\\wsl.localhost\\Debian\\home\\jin\\repo') + } finally { + ubuntu.dispose() + debian.dispose() + } + }) }) describe('OSC title tracking', () => { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index e7da2a4589e..e1950d9caf5 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -35,6 +35,7 @@ export type HeadlessEmulatorOptions = { onQueryReply?: (reply: string) => void pathFlavor?: 'posix' | 'win32' remotePosixFileUriAuthority?: boolean + wslDistro?: string } export type HeadlessEmulatorWriteOptions = { @@ -93,7 +94,8 @@ export class HeadlessEmulator { this.remotePosixFileUriAuthority = opts.remotePosixFileUriAuthority === true this.oscText = new TerminalOscCwdTitleScanner({ pathFlavor: this.pathFlavor, - remotePosixAuthority: this.remotePosixFileUriAuthority + remotePosixAuthority: this.remotePosixFileUriAuthority, + wslDistro: opts.wslDistro }) this.terminal = new Terminal({ cols: opts.cols, @@ -129,7 +131,7 @@ export class HeadlessEmulator { // the visible casualty). Only main's runtime per-PTY emulators pass a // sink, and even then replies flow only for chunks the hidden-delivery // gate DROPPED, where the renderer never sees the bytes and main is the - // single answerer. See docs/reference/terminal-query-authority.md. + // single answerer under main's terminal query authority. this.onQueryReply = opts.onQueryReply ?? null if (this.onQueryReply) { this.terminal.onData((reply) => this.emitQueryReply(reply)) diff --git a/src/main/daemon/history-reader.test.ts b/src/main/daemon/history-reader.test.ts index 50caff6d486..4330b0e2c9e 100644 --- a/src/main/daemon/history-reader.test.ts +++ b/src/main/daemon/history-reader.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { HistoryReader } from './history-reader' import { getHistorySessionDirName } from './history-paths' import type { SessionMeta } from './history-manager' +import { encodeLogBatch, encodeLogHeader } from './terminal-history-log' function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'history-reader-test-')) @@ -164,6 +165,24 @@ describe('HistoryReader', () => { }) }) + it('replays incremental hostname OSC-7 with the same WSL context', () => { + writeSessionWithCheckpoint(dir, 'wsl-log', makeMeta(), makeCheckpoint({ generation: 7 })) + const sessionDir = join(dir, getHistorySessionDirName('wsl-log')) + writeFileSync( + join(sessionDir, 'output.log'), + Buffer.concat([ + encodeLogHeader(7), + encodeLogBatch(1, [ + { kind: 'output', data: '\x1b]7;file://DESKTOP-ORCA/home/user/project\x07' } + ]) + ]) + ) + + const info = reader.detectColdRestore('wsl-log', { wslDistro: 'Ubuntu' }) + + expect(info?.cwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\user\\project') + }) + describe('detectColdRestore — scrollback.bin fallback (backward compatibility)', () => { it('restores from scrollback.bin when checkpoint.json is absent', () => { writeSessionWithScrollback(dir, 'old-sess', makeMeta(), 'old format data\r\n') diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index 8d31e3c638d..e219d58cfc4 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -39,7 +39,7 @@ export class HistoryReader { detectColdRestore( sessionId: string, - opts?: { ignoreCleanEnd?: boolean } + opts?: { ignoreCleanEnd?: boolean; wslDistro?: string } ): ColdRestoreInfo | null { const meta = this.readMeta(sessionId) if (!meta) { @@ -69,7 +69,7 @@ export class HistoryReader { // byte-exact output up to ~5s before the crash (up to the full-snapshot // cooldown, ~45s, for a streaming session mid-deferral), while the // checkpoint can be a full log-cap (~5MB of output) stale. - const logRestore = this.restoreFromIncrementalLog(sessionDir, meta, checkpoint) + const logRestore = this.restoreFromIncrementalLog(sessionDir, meta, checkpoint, opts?.wslDistro) if (logRestore) { return logRestore } @@ -122,7 +122,8 @@ export class HistoryReader { private restoreFromIncrementalLog( sessionDir: string, meta: SessionMeta, - checkpoint: TerminalCheckpointFile | null + checkpoint: TerminalCheckpointFile | null, + wslDistro?: string ): ColdRestoreInfo | null { let logBuffer: Buffer try { @@ -148,7 +149,8 @@ export class HistoryReader { const emulator = new HeadlessEmulator({ cols: checkpoint?.cols ?? meta.cols, - rows: checkpoint?.rows ?? meta.rows + rows: checkpoint?.rows ?? meta.rows, + wslDistro }) try { if (checkpoint) { diff --git a/src/main/daemon/osc7-file-uri.test.ts b/src/main/daemon/osc7-file-uri.test.ts index faa84879c55..4aa20a0dbfc 100644 --- a/src/main/daemon/osc7-file-uri.test.ts +++ b/src/main/daemon/osc7-file-uri.test.ts @@ -3,7 +3,9 @@ import { parseFileUriPath, parseFileUriPathParts } from './osc7-file-uri' describe('parseFileUriPath', () => { it('accepts host-qualified POSIX file URI authorities as plain paths', () => { - expect(parseFileUriPath('file://remote-host/tmp/result.json')).toBe('/tmp/result.json') + expect(parseFileUriPath('file://remote-host/tmp/result.json', { pathFlavor: 'posix' })).toBe( + '/tmp/result.json' + ) }) it('accepts empty and localhost POSIX file URI authorities', () => { @@ -51,4 +53,20 @@ describe('parseFileUriPath', () => { } } }) + + it.each(['machine-name', 'localhost', ''])( + 'uses the resolved WSL distro for authority %s', + (authority) => { + const uri = authority ? `file://${authority}/home/me/my%20repo` : 'file:///home/me/my%20repo' + expect(parseFileUriPathParts(uri, { pathFlavor: 'win32', wslDistro: 'Ubuntu' })).toEqual({ + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\my repo', + hostname: authority === 'localhost' ? '' : authority + }) + } + ) + + it('maps lowercase drvfs paths and rejects malformed percent encoding in WSL context', () => { + expect(parseFileUriPath('file://host/mnt/c/work', { wslDistro: 'Ubuntu' })).toBe('C:\\work') + expect(parseFileUriPath('file://host/home/%ZZ', { wslDistro: 'Ubuntu' })).toBeNull() + }) }) diff --git a/src/main/daemon/osc7-file-uri.ts b/src/main/daemon/osc7-file-uri.ts index f931174ee03..ce7b8493462 100644 --- a/src/main/daemon/osc7-file-uri.ts +++ b/src/main/daemon/osc7-file-uri.ts @@ -1,3 +1,5 @@ +import { toWindowsWslPath } from '../../shared/wsl-paths' + export type ParsedFileUriPath = { path: string hostname: string @@ -6,6 +8,7 @@ export type ParsedFileUriPath = { export type ParseFileUriPathOptions = { pathFlavor?: 'posix' | 'win32' remotePosixAuthority?: boolean + wslDistro?: string } export function parseFileUriPath( @@ -27,6 +30,9 @@ export function parseFileUriPathParts( const decodedPath = decodeURIComponent(url.pathname) const hostname = url.hostname.toLowerCase() + if (options.wslDistro) { + return { path: toWindowsWslPath(decodedPath, options.wslDistro), hostname } + } const pathFlavor = options.pathFlavor ?? (process.platform === 'win32' ? 'win32' : 'posix') if (pathFlavor !== 'win32') { return { path: decodedPath, hostname } diff --git a/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts b/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts new file mode 100644 index 00000000000..31b7f5562ca --- /dev/null +++ b/src/main/daemon/pty-subprocess-foreground-degraded-scan.test.ts @@ -0,0 +1,167 @@ +// Guards the daemon foreground identity against Windows scan degradation: a +// timed-out CIM scan (available:false) or an incomplete snapshot (available:true +// with the agent row missing) must not retire a still-working agent and make +// the coordinator read the shell as a false "agent done". Console presence is +// the arbiter of a real exit. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock, readConptyMock } = + vi.hoisted(() => ({ + spawnMock: vi.fn(), + isPwshAvailableMock: vi.fn(), + resolveAgentForegroundProcessMock: vi.fn(), + readConptyMock: vi.fn() + })) + +vi.mock('node-pty', () => ({ spawn: spawnMock })) +vi.mock('../pwsh', () => ({ isPwshAvailable: isPwshAvailableMock })) + +const PWSH7_ABS = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' +const WINDOWS_POWERSHELL_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' +const CMD_ABS = 'C:\\Windows\\System32\\cmd.exe' +vi.mock('../providers/windows-powershell-executable', () => ({ + resolveWindowsPowerShellExecutablePath: (family: 'pwsh.exe' | 'powershell.exe') => + family === 'pwsh.exe' ? PWSH7_ABS : WINDOWS_POWERSHELL_ABS, + resolveWindowsPowerShellSpawnChain: (family: 'pwsh.exe' | 'powershell.exe') => + family === 'pwsh.exe' + ? [PWSH7_ABS, WINDOWS_POWERSHELL_ABS, CMD_ABS] + : [WINDOWS_POWERSHELL_ABS, CMD_ABS], + getWindowsCmdPath: () => CMD_ABS +})) + +vi.mock('../providers/agent-foreground-process', () => ({ + resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) => + resolveAgentForegroundProcessMock(...args) +})) + +vi.mock('../providers/windows-conpty-process-membership', () => ({ + readWindowsConptyProcessIds: (...args: unknown[]) => readConptyMock(...args) +})) + +import { createPtySubprocess } from './pty-subprocess' + +const BASE_TIME_MS = 1_000_000 + +function mockPtyProcess(processName: string, pid = 12345) { + return { + pid, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + process: processName, + onData: vi.fn(() => ({ dispose: vi.fn() })), + onExit: vi.fn(() => ({ dispose: vi.fn() })) + } +} + +async function flushAsyncTicks(count = 12): Promise { + for (let i = 0; i < count; i++) { + await Promise.resolve() + } +} + +async function readForegroundAt( + handle: { getForegroundProcess: () => string | null }, + atMs: number +): Promise { + vi.setSystemTime(BASE_TIME_MS + atMs) + const foreground = handle.getForegroundProcess() + await flushAsyncTicks() + return foreground +} + +describe('daemon pty foreground degraded-scan handling', () => { + let platform: PropertyDescriptor | undefined + let previousUserDataPath: string | undefined + let userDataPath: string + + beforeEach(() => { + spawnMock.mockReset() + isPwshAvailableMock.mockReset() + isPwshAvailableMock.mockReturnValue(false) + resolveAgentForegroundProcessMock.mockReset() + readConptyMock.mockReset() + readConptyMock.mockResolvedValue(null) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'daemon-pty-degraded-scan-test-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + platform = Object.getOwnPropertyDescriptor(process, 'platform') + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(BASE_TIME_MS) + }) + + afterEach(() => { + vi.useRealTimers() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + }) + + function spawnWindowsShell() { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const proc = mockPtyProcess('powershell.exe') + spawnMock.mockReturnValue(proc) + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + return { proc, handle } + } + + it('keeps a cached agent across a degraded (timed-out) scan', async () => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: false, processName: null }) + const { handle } = spawnWindowsShell() + + await readForegroundAt(handle, 0) // establishes 'claude' + expect(await readForegroundAt(handle, 1_000)).toBe('claude') // refresh returns degraded → keep + // Past the 1s TTL with a shell fallback: pre-fix this returned the shell. + expect(await readForegroundAt(handle, 2_500)).toBe('claude') + expect(readConptyMock).not.toHaveBeenCalled() + }) + + it('keeps a cached agent when a scan finds no agent but the console still has a child', async () => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readConptyMock.mockResolvedValue(new Set([12345, 999])) // child still attached + const { handle } = spawnWindowsShell() + + await readForegroundAt(handle, 0) + expect(await readForegroundAt(handle, 1_000)).toBe('claude') + expect(await readForegroundAt(handle, 2_500)).toBe('claude') + }) + + it('retires a cached agent when a scan finds no agent and the console is shell-only', async () => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readConptyMock.mockResolvedValue(new Set([12345])) + const { handle } = spawnWindowsShell() + + await readForegroundAt(handle, 0) + await readForegroundAt(handle, 1_000) // refresh clears the cache + expect(await readForegroundAt(handle, 1_100)).toBe('powershell.exe') + expect(readConptyMock).toHaveBeenCalledTimes(1) + }) + + it('keeps a cached agent when the console-membership probe is unavailable', async () => { + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readConptyMock.mockResolvedValue(null) + const { handle } = spawnWindowsShell() + + await readForegroundAt(handle, 0) + expect(await readForegroundAt(handle, 1_000)).toBe('claude') + expect(await readForegroundAt(handle, 2_500)).toBe('claude') + expect(readConptyMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 62011ccd183..b378068a4c2 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -67,6 +67,14 @@ vi.mock('../providers/agent-foreground-process', () => ({ } })) +// Console-membership reads run a real node-pty fork that never settles under +// fake timers; default to "shell-only" so the degraded-scan guard falls through +// to its existing retirement logic (the degraded-scan behavior itself is +// covered in pty-subprocess-foreground-degraded-scan.test.ts). +vi.mock('../providers/windows-conpty-process-membership', () => ({ + readWindowsConptyProcessIds: () => Promise.resolve(new Set([12345])) +})) + import { createPtySubprocess, checkPtySpawnHealth } from './pty-subprocess' import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard' @@ -2355,7 +2363,8 @@ describe('createPtySubprocess', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) const platform = Object.getOwnPropertyDescriptor(process, 'platform') - const cwd = mkdtempSync(join(tmpdir(), 'daemon-pty-wsl-distro-test-')) + // Keep the fixture Windows-shaped even when this test runs on a Linux CI host. + const cwd = 'C:\\repo' Object.defineProperty(process, 'platform', { value: 'win32' }) @@ -2372,18 +2381,11 @@ describe('createPtySubprocess', () => { if (platform) { Object.defineProperty(process, 'platform', platform) } - rmSync(cwd, { recursive: true, force: true }) } - const normalizedCwd = cwd.replace(/\\/g, '/') - const driveMatch = normalizedCwd.match(/^([A-Za-z]):\/?(.*)$/) - const expectedLinuxCwd = driveMatch - ? `/mnt/${driveMatch[1].toLowerCase()}${driveMatch[2] ? `/${driveMatch[2]}` : ''}` - : '/mnt/c' - expect(spawnMock).toHaveBeenCalledWith( 'wsl.exe', - ['-d', 'Debian', '--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)], + ['-d', 'Debian', '--', 'sh', '-c', expect.stringContaining("cd '/mnt/c/repo'")], expect.objectContaining({ cwd: expect.any(String) }) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 8e1f7024891..60a8af8ac70 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -39,7 +39,7 @@ import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env' import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../../shared/terminal-git-credential-guard' -import { getWslContextFromSessionId } from './wsl-session-context' +import { resolveWslSessionContext } from './wsl-session-context' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' import { POWERLEVEL10K_WIZARD_DISABLE_ENV, @@ -177,16 +177,6 @@ function removeInheritedDevAgentHookEndpoint( } } -/** - * Resolves a WSL launch context from a user-selected distro name. - */ -function getWslContextFromPreferredDistro( - distro: string | null | undefined -): { distro: string } | undefined { - const trimmed = distro?.trim() - return trimmed ? { distro: trimmed } : undefined -} - /** * Strips Electron's internal run-as-node flag from user shell environments. */ @@ -605,18 +595,11 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // setting, relayed by main) takes priority over env.COMSPEC — otherwise // Windows always resolves to cmd.exe (COMSPEC) or PowerShell by fallback, // no matter which shell the user actually picked. - const cwdWslInfo = process.platform === 'win32' ? parseWslPath(opts.cwd ?? '') : null - const sessionWslContext = - process.platform === 'win32' ? getWslContextFromSessionId(opts.sessionId) : undefined - const preferredWslContext = - process.platform === 'win32' - ? getWslContextFromPreferredDistro(opts.terminalWindowsWslDistro) - : undefined + const resolvedWslContext = resolveWslSessionContext(opts) // Why: WSL worktree cwd is the repo's execution environment. Older persisted // tabs can carry a PowerShell/cmd shellOverride; ignore it so reconnects and // daemon-backed terminals enter the WSL distro just like LocalPtyProvider. - let shellPath = - cwdWslInfo || sessionWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) + let shellPath = resolvedWslContext ? 'wsl.exe' : opts.shellOverride || resolvePtyShellPath(env) let shellArgs: string[] let startupCommandDeliveredInShellArgs = false let windowsFallbackAttempts: WindowsShellSpawnAttempt[] = [] @@ -672,7 +655,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellPath, cwd: spawnCwd, defaultCwd: getDefaultCwd(), - wslContext: sessionWslContext ?? preferredWslContext, + wslContext: resolvedWslContext, startupCommand: opts.command }) const primaryAttempt = windowsFallbackAttempts[0] @@ -687,7 +670,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl shellPath, spawnCwd, getDefaultCwd(), - sessionWslContext ?? preferredWslContext, + resolvedWslContext, opts.command ) shellArgs = resolved.shellArgs @@ -703,8 +686,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl const codexHomeWslInfo = env.CODEX_HOME ? parseWslPath(env.CODEX_HOME) : null if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') { if (codexHomeWslInfo) { - const launchWslDistro = - cwdWslInfo?.distro ?? sessionWslContext?.distro ?? preferredWslContext?.distro + const launchWslDistro = resolvedWslContext?.distro if (launchWslDistro && launchWslDistro !== codexHomeWslInfo.distro) { delete env.CODEX_HOME delete env.ORCA_CODEX_HOME @@ -983,38 +965,59 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // Why: daemon foreground reads are sync and run on the IPC hot path. // Refresh derived identities (shell/wrapper/helper -> codex/claude/etc.) // in the background and serve them from a short cache on later reads. + // Why: Windows may need an async membership check before this shared + // shell/wrapper retirement policy is safe to apply. + const retireStaleForegroundIdentity = (): void => { + const currentFallbackProcess = getFallbackForegroundProcess() + if ( + fallbackIsShell && + !getActiveStartupAgentForeground() && + currentFallbackProcess !== null && + isShellProcess(currentFallbackProcess) + ) { + cachedAgentForeground = null + startupAgentForeground = null + } else if ( + cachedAgentForeground !== null && + Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && + currentFallbackProcess !== null && + isAgentForegroundWrapperProcess(currentFallbackProcess) + ) { + // Why: the wrapper's tree no longer resolves to an agent — an expired + // identity must not transfer to an unrelated wrapper (e.g. npm right + // after an agent exit). Fresh identities survive one-off scan hiccups. + cachedAgentForeground = null + } + } void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, { contextPaths: agentForegroundContextPaths }) - .then(({ processName }) => { + .then(({ processName, available }) => { if (dead) { return } + // Why: a degraded scan is not exit evidence; retiring here fires false + // completion while an agent is still working under CIM load. + if (!available) { + return + } if (!processName || !recognizeAgentProcess(processName)) { - const currentFallbackProcess = getFallbackForegroundProcess() - if ( - fallbackIsShell && - !getActiveStartupAgentForeground() && - currentFallbackProcess !== null && - isShellProcess(currentFallbackProcess) - ) { - cachedAgentForeground = null - startupAgentForeground = null - } else if ( - cachedAgentForeground !== null && - Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && - currentFallbackProcess !== null && - isAgentForegroundWrapperProcess(currentFallbackProcess) - ) { - // Why: the wrapper's tree no longer resolves to an agent — an expired - // identity must not transfer to an unrelated wrapper (e.g. npm right - // after an agent exit). Fresh identities survive one-off scan hiccups. - cachedAgentForeground = null + // Why: a Windows snapshot can omit a live agent; only verified + // shell-only membership may retire its cached identity. + if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) { + return readWindowsConptyProcessIds(proc.pid).then((consoleProcessIds) => { + if (dead || consoleProcessIds === null || consoleProcessIds.size > 1) { + return + } + retireStaleForegroundIdentity() + }) } + retireStaleForegroundIdentity() return } cachedAgentForeground = { processName, refreshedAt: Date.now() } startupAgentForeground = null + return processName }) .catch(() => { // Best-effort only: foreground enrichment must never affect PTY health. @@ -1069,14 +1072,18 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return cachedAgentForeground.processName } // Why: a wrapper foreground (node/python) can never identify itself, and - // readers poll slower than the cache TTL — returning the raw wrapper here + // readers poll slower than the cache TTL — returning the raw fallback here // would hide the resolved identity forever. Serve the last resolved agent - // while the scheduled refresh revalidates; exit truth is safe because an - // exited agent's foreground falls back to the shell, not a wrapper. + // while the scheduled refresh revalidates. On Windows a shell fallback is + // also an unreliable exit signal (ConPTY lag under load surfaces the shell + // while the agent is alive), so trust the cache there too; the background + // refresh retires the identity only after a console-presence read confirms + // the agent left the console. if ( cachedAgentForeground && fallbackProcess !== null && - isAgentForegroundWrapperProcess(fallbackProcess) + (isAgentForegroundWrapperProcess(fallbackProcess) || + (process.platform === 'win32' && isShellProcess(fallbackProcess))) ) { return cachedAgentForeground.processName } diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 846c7984d2a..b5446524ebf 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -111,12 +111,14 @@ describe('Session', () => { deadlineMs: number echoProjection?: 'windows-conpty-esc-stripped' } + wslDistro?: string }): Session { session = new Session({ sessionId: 'test-session', cols: opts?.cols ?? 80, rows: opts?.rows ?? 24, ...(opts?.launchAgent ? { launchAgent: opts.launchAgent } : {}), + wslDistro: opts?.wslDistro, subprocess, shellReadySupported: opts?.shellReadySupported ?? false, ...(opts?.startupIngress ? { startupIngress: opts.startupIngress } : {}), @@ -721,6 +723,13 @@ describe('Session', () => { }) describe('snapshot', () => { + it('parses live OSC-7 output in the session WSL distro', () => { + createSession({ wslDistro: 'Ubuntu' }) + + subprocess.simulateData('\x1b]7;file://DESKTOP-ORCA/home/jin/repo\x07') + + expect(session.getCwd()).toBe('\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo') + }) it('returns a terminal snapshot', async () => { createSession() subprocess.simulateData('$ hello\r\n') diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 423aeee672f..1c414046cb3 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -95,6 +95,7 @@ export type SessionOptions = { shellReadyTimeoutMs?: number historySeed?: string scrollback?: number + wslDistro?: string // Why: fired once the session reaches a terminal state (natural exit or // kill-timeout force-dispose) so the owner (TerminalHost) can reap it — // dispose the headless emulator and drop it from its session map. Without a @@ -114,6 +115,7 @@ export class Session { readonly sessionId: string readonly terminalHandle: string | null readonly launchAgent: TuiAgent | null + readonly wslDistro: string | null private _state: SessionState = 'running' private _shellState: ShellReadyState private _exitCode: number | null = null @@ -145,13 +147,15 @@ export class Session { this.sessionId = opts.sessionId this.terminalHandle = opts.terminalHandle ?? null this.launchAgent = opts.launchAgent ?? null + this.wslDistro = opts.wslDistro ?? null this.subprocess = opts.subprocess this.onSessionExit = opts.onExit const size = normalizePtySize(opts.cols, opts.rows) this.emulator = new HeadlessEmulator({ cols: size.cols, rows: size.rows, - scrollback: opts.scrollback + scrollback: opts.scrollback, + wslDistro: opts.wslDistro // No onData wiring: the daemon-side emulator must never reply to // terminal query sequences. The renderer's xterm is the authoritative // responder; any daemon reply races ahead via in-process parsing and diff --git a/src/main/daemon/terminal-host-create-contract.ts b/src/main/daemon/terminal-host-create-contract.ts index cf7d863a8d0..47fba51b2c3 100644 --- a/src/main/daemon/terminal-host-create-contract.ts +++ b/src/main/daemon/terminal-host-create-contract.ts @@ -34,5 +34,6 @@ export type CreateOrAttachResult = { shellState: ShellReadyState historySeeded?: boolean launchAgent?: TuiAgent + wslDistro: string | null attachToken: symbol } diff --git a/src/main/daemon/terminal-host-wsl-context.test.ts b/src/main/daemon/terminal-host-wsl-context.test.ts new file mode 100644 index 00000000000..123726beae2 --- /dev/null +++ b/src/main/daemon/terminal-host-wsl-context.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalHost } from './terminal-host' +import type { SubprocessHandle } from './session' +import { resolveWslSessionContext } from './wsl-session-context' + +function createSubprocess(): SubprocessHandle { + let onExit: ((code: number) => void) | undefined + return { + pid: 123, + getForegroundProcess: () => null, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(() => onExit?.(0)), + forceKill: vi.fn(() => onExit?.(137)), + signal: vi.fn(), + onData: vi.fn(), + onExit: vi.fn((callback: (code: number) => void) => { + onExit = callback + }), + dispose: vi.fn() + } +} + +describe('TerminalHost WSL context', () => { + let host: TerminalHost | undefined + + afterEach(async () => { + await host?.dispose() + }) + + it('returns the first creator context on conflicting later attaches', async () => { + const spawnSubprocess = vi.fn(() => createSubprocess()) + host = new TerminalHost({ spawnSubprocess }) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const created = await host.createOrAttach({ + sessionId: 'session-wsl', + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin', + terminalWindowsWslDistro: 'Debian', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const attached = await host.createOrAttach({ + sessionId: 'session-wsl', + cols: 80, + rows: 24, + terminalWindowsWslDistro: 'Debian', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(created.wslDistro).toBe('Ubuntu') + expect(attached.wslDistro).toBe('Ubuntu') + expect(spawnSubprocess).toHaveBeenCalledOnce() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('returns authoritative null when a native session is attached with a WSL preference', async () => { + const spawnSubprocess = vi.fn(() => createSubprocess()) + host = new TerminalHost({ spawnSubprocess }) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const created = await host.createOrAttach({ + sessionId: 'session-native', + cols: 80, + rows: 24, + cwd: '\\\\server\\share\\repo', + shellOverride: 'powershell.exe', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + const attached = await host.createOrAttach({ + sessionId: 'session-native', + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(created.wslDistro).toBeNull() + expect(attached.wslDistro).toBeNull() + expect(spawnSubprocess).toHaveBeenCalledOnce() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('uses a remembered distro only when the selected shell is WSL', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + expect( + resolveWslSessionContext({ + cwd: 'C:\\Users\\jin\\repo', + shellOverride: 'powershell.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + ).toBeUndefined() + expect( + resolveWslSessionContext({ + cwd: '\\\\server\\share\\repo', + shellOverride: 'powershell.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + ).toBeUndefined() + expect( + resolveWslSessionContext({ + cwd: 'C:\\Users\\jin\\repo', + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: ' Ubuntu ' + }) + ).toEqual({ distro: 'Ubuntu', treatPosixCwdAsWsl: true }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) +}) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 5627c800ca5..d8458aa81ad 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -13,6 +13,8 @@ import type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-hos import type { TerminalHostOptions } from './terminal-host-options' import { shutdownTerminalHostSessions } from './terminal-host-session-shutdown' import { TerminalSessionTeardown } from './terminal-session-teardown' +import { resolveWslSessionContext } from './wsl-session-context' +import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result' export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract' export type { TerminalHostOptions } from './terminal-host-options' @@ -63,8 +65,7 @@ export class TerminalHost { snapshot, pid: existing.pid, shellState: existing.shellState, - ...(existing.launchAgent ? { launchAgent: existing.launchAgent } : {}), - ...(existing.historySeeded !== undefined ? { historySeeded: existing.historySeeded } : {}), + ...getDaemonSessionResultMetadata(existing), attachToken: token } } @@ -84,6 +85,7 @@ export class TerminalHost { // Clear tombstone if re-creating a killed session this.killedTombstones.delete(opts.sessionId) const size = normalizePtySize(opts.cols, opts.rows) + const wslDistro = resolveWslSessionContext(opts)?.distro const subprocess = this.spawnSubprocess({ sessionId: opts.sessionId, @@ -120,6 +122,7 @@ export class TerminalHost { shellReadySupported, historySeed: opts.historySeed, ...(opts.startupIngress ? { startupIngress: opts.startupIngress } : {}), + wslDistro, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's // lifetime. Nothing reads a dead session's emulator (getSnapshot/ @@ -160,8 +163,7 @@ export class TerminalHost { snapshot: null, pid: subprocess.pid, shellState: session.shellState, - ...(session.launchAgent ? { launchAgent: session.launchAgent } : {}), - ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), + ...getDaemonSessionResultMetadata(session), attachToken: token } } diff --git a/src/main/daemon/terminal-osc-cwd-title-scanner.ts b/src/main/daemon/terminal-osc-cwd-title-scanner.ts index f3736d7c009..91f592e3796 100644 --- a/src/main/daemon/terminal-osc-cwd-title-scanner.ts +++ b/src/main/daemon/terminal-osc-cwd-title-scanner.ts @@ -11,6 +11,7 @@ const OSC_SCAN_TAIL_LIMIT = 4096 export type TerminalOscCwdTitleScannerOptions = { pathFlavor?: 'posix' | 'win32' remotePosixAuthority?: boolean + wslDistro?: string } export class TerminalOscCwdTitleScanner { @@ -39,7 +40,8 @@ export class TerminalOscCwdTitleScanner { scanOsc7Uris(input, (uri) => { const parsed = parseFileUriPath(uri, { pathFlavor: this.parseOptions.pathFlavor, - remotePosixAuthority: this.parseOptions.remotePosixAuthority + remotePosixAuthority: this.parseOptions.remotePosixAuthority, + wslDistro: this.parseOptions.wslDistro }) if (parsed) { this.cwd = parsed diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts index 68ff83c349f..ddd04bf11dc 100644 --- a/src/main/daemon/terminal-view-attribute-responder.ts +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -1,6 +1,6 @@ /** - * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute - * bridge): OSC 4/10/11/12 and DSR ?996n responder handlers for the runtime + * Phase 5 slice 2 (View-attribute bridge): OSC 4/10/11/12 and DSR ?996n + * responder handlers for the runtime * headless emulator. The headless xterm core has no theme service, so these * handlers compute replies from the renderer's pushed attribute snapshot, * with per-PTY OSC SET mutations layered on top — mirroring exactly what the diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 2c2116cf097..6293cd4fe0b 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -323,14 +323,7 @@ export type RpcResponseError = { export type RpcResponse = RpcResponseOk | RpcResponseError -export type CreateOrAttachResult = { - isNew: boolean - snapshot: TerminalSnapshot | null - pid: number | null - shellState: ShellReadyState - historySeeded?: boolean - launchAgent?: TuiAgent -} +export type { DaemonCreateOrAttachResult as CreateOrAttachResult } from './daemon-create-or-attach-result' export type GetSnapshotResult = { snapshot: TerminalSnapshot | null } diff --git a/src/main/daemon/wsl-cold-restore-cwd.test.ts b/src/main/daemon/wsl-cold-restore-cwd.test.ts new file mode 100644 index 00000000000..d9c5cf54770 --- /dev/null +++ b/src/main/daemon/wsl-cold-restore-cwd.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' + +const base = { + platform: 'win32' as const, + hostname: 'DESKTOP-ORCA', + requestedCwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo', + wslDistro: 'Ubuntu' +} + +describe('normalizeWslColdRestoreCwd', () => { + it.each([ + ['C:\\work', 'C:\\work'], + ['\\\\wsl.localhost\\ubuntu\\home\\jin', '\\\\wsl.localhost\\ubuntu\\home\\jin'], + ['/home/jin', '\\\\wsl.localhost\\Ubuntu\\home\\jin'], + ['\\\\desktop-orca\\home\\jin', '\\\\wsl.localhost\\Ubuntu\\home\\jin'], + ['\\\\DESKTOP-ORCA\\mnt\\c\\work', 'C:\\work'] + ])('allows or repairs %s', (recoveredCwd, expected) => { + expect(normalizeWslColdRestoreCwd({ ...base, recoveredCwd })).toBe(expected) + }) + + it.each([ + '\\\\wsl.localhost\\Debian\\home\\jin', + '\\\\server\\share\\repo', + '//server/share/repo', + 'relative/path', + '\\\\other-host\\home\\jin' + ])('falls back instead of guessing for %s', (recoveredCwd) => { + expect(normalizeWslColdRestoreCwd({ ...base, recoveredCwd })).toBe(base.requestedCwd) + }) + + it('leaves native and missing-context restores unchanged', () => { + expect( + normalizeWslColdRestoreCwd({ ...base, platform: 'linux', recoveredCwd: '/home/jin' }) + ).toBe('/home/jin') + expect( + normalizeWslColdRestoreCwd({ ...base, wslDistro: undefined, recoveredCwd: '\\\\server\\x' }) + ).toBe('\\\\server\\x') + }) +}) diff --git a/src/main/daemon/wsl-cold-restore-cwd.ts b/src/main/daemon/wsl-cold-restore-cwd.ts new file mode 100644 index 00000000000..b3f921106dc --- /dev/null +++ b/src/main/daemon/wsl-cold-restore-cwd.ts @@ -0,0 +1,37 @@ +import { hostname as getHostname } from 'node:os' +import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' + +export function normalizeWslColdRestoreCwd(args: { + recoveredCwd: string + requestedCwd?: string + wslDistro?: string + platform?: NodeJS.Platform + hostname?: string +}): string | undefined { + if ((args.platform ?? process.platform) !== 'win32' || !args.wslDistro) { + return args.recoveredCwd + } + + if (/^[A-Za-z]:[\\/]/.test(args.recoveredCwd)) { + return args.recoveredCwd + } + + const wslPath = parseWslUncPath(args.recoveredCwd) + if (wslPath) { + return wslPath.distro.toLowerCase() === args.wslDistro.toLowerCase() + ? args.recoveredCwd + : args.requestedCwd + } + + if (/^\/(?!\/)/.test(args.recoveredCwd)) { + return toWindowsWslPath(args.recoveredCwd, args.wslDistro) + } + + const uncMatch = args.recoveredCwd.match(/^[\\/]{2}([^\\/]+)[\\/]([^\\/]+)([\\/].*)?$/) + if (uncMatch?.[1].toLowerCase() === (args.hostname ?? getHostname()).toLowerCase()) { + const linuxPath = `/${uncMatch[2]}${(uncMatch[3] ?? '').replace(/\\/g, '/')}` + return toWindowsWslPath(linuxPath, args.wslDistro) + } + + return args.requestedCwd +} diff --git a/src/main/daemon/wsl-session-context.ts b/src/main/daemon/wsl-session-context.ts index f6d568a7042..582e8a66a05 100644 --- a/src/main/daemon/wsl-session-context.ts +++ b/src/main/daemon/wsl-session-context.ts @@ -1,6 +1,8 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' +import { isWslShellName } from '../../shared/local-windows-terminal-runtime' import { parseWslPath } from '../wsl' import { parsePtySessionId } from './pty-session-id' +import { parseWslUncPath } from '../../shared/wsl-paths' export type WslSessionContext = { distro: string @@ -15,3 +17,31 @@ export function getWslContextFromSessionId(sessionId: string): WslSessionContext const wslInfo = worktreePath ? parseWslPath(worktreePath) : null return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } + +export function getWslContextFromPreferredDistro( + distro: string | null | undefined +): WslSessionContext | undefined { + const trimmed = distro?.trim() + return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined +} + +export function resolveWslSessionContext(args: { + cwd?: string + sessionId?: string + shellOverride?: string + terminalWindowsWslDistro?: string | null +}): WslSessionContext | undefined { + if (process.platform !== 'win32') { + return undefined + } + const cwdDistro = args.cwd ? parseWslUncPath(args.cwd)?.distro : undefined + if (cwdDistro) { + return { distro: cwdDistro, treatPosixCwdAsWsl: true } + } + return ( + (args.sessionId ? getWslContextFromSessionId(args.sessionId) : undefined) ?? + (isWslShellName(args.shellOverride) + ? getWslContextFromPreferredDistro(args.terminalWindowsWslDistro) + : undefined) + ) +} diff --git a/src/main/git/exec-error.ts b/src/main/git/exec-error.ts new file mode 100644 index 00000000000..fb44e54917a --- /dev/null +++ b/src/main/git/exec-error.ts @@ -0,0 +1,111 @@ +/** + * Pure helpers for reading diagnostics out of a subprocess (git/gh/glab) + * rejection. Kept in a standalone module (not `runner.ts`) so lightweight + * consumers — e.g. PR-refresh error classification — can import them without + * pulling in the runner's heavy execution machinery, and so tests that mock + * `./runner` still get the real implementations. + */ + +/** + * Extract stderr/stdout from an execFile rejection. + * + * Why: Node's execFile rejects with an Error that has `.stdout` and `.stderr` + * fields populated separately from `.message`. Reading `err.message` alone is + * unreliable — it can truncate stderr or omit it entirely depending on Node + * version and maxBuffer behavior. We prefer the explicit fields and fall + * back to `.message` only when neither is present. + */ +export function extractExecError(err: unknown): { stderr: string; stdout: string } { + if (err && typeof err === 'object') { + const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown } + const stderr = + typeof e.stderr === 'string' + ? e.stderr + : Buffer.isBuffer(e.stderr) + ? e.stderr.toString('utf-8') + : '' + const stdout = + typeof e.stdout === 'string' + ? e.stdout + : Buffer.isBuffer(e.stdout) + ? e.stdout.toString('utf-8') + : '' + if (stderr || stdout) { + return { stderr, stdout } + } + if (typeof e.message === 'string') { + return { stderr: e.message, stdout: '' } + } + } + return { stderr: String(err), stdout: '' } +} + +/** + * Detect a Retry-After hint in gh stderr and return the suggested delay in ms, + * or null when the response includes no Retry-After. + * + * Why: gh forwards response headers when verbose, and prints "Retry-After: + * " in error output for primary rate-limit 429s. When present, the + * caller is better served by propagating the error so the UI can surface the + * real wait time — retrying on our own 250ms cadence just earns another 429 + * and burns the retry budget. Also supports HTTP-date Retry-After values. + */ +export function parseRetryAfterMs(stderr: string): number | null { + const raw = findRetryAfterHeaderValue(stderr) + if (raw === null) { + return null + } + if (/^\d+$/.test(raw)) { + const seconds = Number(raw) + return Number.isFinite(seconds) ? seconds * 1000 : null + } + const ts = Date.parse(raw) + if (Number.isNaN(ts)) { + return null + } + return Math.max(0, ts - Date.now()) +} + +function findRetryAfterHeaderValue(stderr: string): string | null { + const headerIndex = indexOfAsciiIgnoreCase(stderr, 'retry-after:', 0) + if (headerIndex === -1) { + return null + } + let valueStart = headerIndex + 'retry-after:'.length + while (valueStart < stderr.length) { + const code = stderr.charCodeAt(valueStart) + if (code !== 9 && code !== 32) { + break + } + valueStart++ + } + let valueEnd = valueStart + while (valueEnd < stderr.length) { + const code = stderr.charCodeAt(valueEnd) + if (code === 10 || code === 13) { + break + } + valueEnd++ + } + const value = stderr.slice(valueStart, valueEnd).trim() + return value.length > 0 ? value : null +} + +function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number { + const lastStart = value.length - search.length + for (let index = Math.max(0, fromIndex); index <= lastStart; index++) { + let matches = true + for (let offset = 0; offset < search.length; offset++) { + const code = value.charCodeAt(index + offset) + const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code + if (normalizedCode !== search.charCodeAt(offset)) { + matches = false + break + } + } + if (matches) { + return index + } + } + return -1 +} diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index c7710858838..8b802c2f0d6 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as WslModule from '../wsl' @@ -21,11 +22,26 @@ vi.mock('../wsl', async (importOriginal) => ({ import { ghExecFileAsync, glabExecFileAsync } from './runner' +type MockChildProcess = EventEmitter & { + pid: number + kill: ReturnType + unref: ReturnType +} + +function createMockChildProcess(pid: number): MockChildProcess { + const child = new EventEmitter() as MockChildProcess + child.pid = pid + child.kill = vi.fn() + child.unref = vi.fn() + return child +} + describe('ghExecFileAsync WSL fallback', () => { const originalPlatform = process.platform beforeEach(() => { execFileMock.mockReset() + spawnMock.mockReset() getDefaultWslDistroMock.mockReset() getDefaultWslDistroMock.mockReturnValue(null) Object.defineProperty(process, 'platform', { @@ -35,6 +51,7 @@ describe('ghExecFileAsync WSL fallback', () => { }) afterEach(() => { + vi.useRealTimers() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -352,6 +369,80 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('times out the default-WSL glab fallback and waits for full tree cleanup', async () => { + vi.useFakeTimers() + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + const nativeChild = createMockChildProcess(1200) + const wslChild = createMockChildProcess(2400) + const taskkill = createMockChildProcess(3600) + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })) + return nativeChild + }) + .mockReturnValueOnce(wslChild) + spawnMock.mockReturnValue(taskkill) + + const promise = glabExecFileAsync(['auth', 'status'], { timeout: 1000 }) + const rejection = expect(promise).rejects.toThrow('wsl.exe timed out.') + let rejected = false + void promise.catch(() => { + rejected = true + }) + + await vi.advanceTimersByTimeAsync(999) + expect(spawnMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(spawnMock).toHaveBeenCalledWith( + 'taskkill', + ['/pid', '2400', '/t', '/f'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + ) + await Promise.resolve() + expect(rejected).toBe(false) + + taskkill.emit('close', 0) + await rejection + expect(wslChild.kill).not.toHaveBeenCalled() + }) + + it('aborts the default-WSL glab fallback with full process-tree cleanup', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + const nativeChild = createMockChildProcess(1200) + const wslChild = createMockChildProcess(2400) + const taskkill = createMockChildProcess(3600) + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })) + return nativeChild + }) + .mockReturnValueOnce(wslChild) + spawnMock.mockReturnValue(taskkill) + const controller = new AbortController() + + const promise = glabExecFileAsync(['auth', 'status'], { signal: controller.signal }) + const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(2)) + controller.abort() + + expect(execFileMock).toHaveBeenNthCalledWith( + 2, + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'bash', '-c', "'glab' 'auth' 'status'"], + expect.not.objectContaining({ signal: controller.signal }), + expect.any(Function) + ) + expect(spawnMock).toHaveBeenCalledWith( + 'taskkill', + ['/pid', '2400', '/t', '/f'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + ) + taskkill.emit('close', 0) + + await rejection + expect(wslChild.kill).not.toHaveBeenCalled() + }) + it('does not wake the default WSL distro for host-only GitLab diagnostics', async () => { getDefaultWslDistroMock.mockReturnValue('Ubuntu') execFileMock diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 27d1e5837b9..598ed2a9781 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -42,6 +42,10 @@ import { } from '../../shared/wsl-login-shell-command' import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' import { endSubprocessStdin } from '../../shared/subprocess-stdin-write' +// Re-exported for existing importers; lightweight consumers should import these +// from './exec-error' directly so they do not depend on this heavy module. +import { extractExecError, parseRetryAfterMs } from './exec-error' +export { extractExecError, parseRetryAfterMs } // ─── Core resolution ──────────────────────────────────────────────── @@ -319,24 +323,57 @@ function createAbortError(): Error { return error } -function killSpawnedCommandTree(child: ChildProcess): void { +const WINDOWS_TREE_KILL_WAIT_MS = 2_000 + +function killSpawnedCommandTree(child: ChildProcess): Promise { const pid = child.pid if (!pid || process.platform !== 'win32') { child.kill() - return + return Promise.resolve() } - try { - // Why: Windows package-manager CLIs are often .cmd shims. Killing only - // cmd.exe leaves the underlying node/npm/pnpm child running. - const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true - }) - killer.on('error', () => child.kill()) + return new Promise((resolve) => { + let killer: ChildProcess + try { + // Why: Windows shims and wsl.exe can own descendants; wait for /t tree + // cleanup before settling so a timed-out command cannot outlive its probe. + killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + if (!killer || typeof killer.unref !== 'function') { + child.kill() + resolve() + return + } + } catch { + child.kill() + resolve() + return + } + let settled = false + let timer: NodeJS.Timeout | null = null + const finish = (fallbackToChildKill: boolean): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + killer.removeAllListeners() + if (fallbackToChildKill) { + child.kill() + } + resolve() + } + killer.once('error', () => finish(true)) + killer.once('close', (code) => finish(code !== 0)) + timer = setTimeout(() => { + killer.kill() + finish(true) + }, WINDOWS_TREE_KILL_WAIT_MS) killer.unref() - } catch { - child.kill() - } + }) } type ExecFileCaptureOptions = Omit & { @@ -372,6 +409,7 @@ function execFileCapture( } let settled = false + let terminating = false let child: ChildProcess | null = null let timer: NodeJS.Timeout | null = null const cleanup = (): void => { @@ -401,14 +439,26 @@ function execFileCapture( resolve({ stdout, stderr }) } const onAbort = (): void => { - if (child) { - killSpawnedCommandTree(child) + if (settled || terminating) { + return } - finish(createAbortError()) + terminating = true + const abortError = createAbortError() + if (!child) { + terminating = false + finish(abortError) + return + } + void killSpawnedCommandTree(child).then(() => { + terminating = false + finish(abortError) + }) } try { const spawnStartedAt = performance.now() + // Why: the abort listener below owns tree-aware cleanup. Node's + // signal handler could kill wsl.exe before taskkill sees its children. child = execFile( command, args, @@ -416,10 +466,12 @@ function execFileCapture( cwd: options.cwd, encoding: options.encoding, maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, - env: options.env, - signal: options.signal + env: options.env }, (error, stdout, stderr) => { + if (terminating) { + return + } if (!error && stderr === undefined && isExecFileResultObject(stdout)) { finish(null, stdout.stdout, stdout.stderr) return @@ -433,21 +485,34 @@ function execFileCapture( return } - child.once('error', (error) => finish(error)) + child.once('error', (error) => { + if (!terminating) { + finish(error) + } + }) if (options.stdin !== undefined) { endSubprocessStdin(child.stdin, options.stdin) } - // Why: Node's native execFile timeout waits for the child to exit after - // signaling it. Some CLIs ignore that signal, so reject the UI operation - // on our own timer and kill the child only as best effort. + // Why: Node's native timeout can wait forever for signal-ignoring CLIs; + // enforce our own deadline and settle after bounded process-tree cleanup. if (options.timeout && options.timeout > 0) { timer = setTimeout(() => { - if (child) { - killSpawnedCommandTree(child) + if (settled || terminating) { + return } - finish(new Error(`${command} timed out.`)) + terminating = true + const timeoutError = new Error(`${command} timed out.`) + if (!child) { + terminating = false + finish(timeoutError) + return + } + void killSpawnedCommandTree(child).then(() => { + terminating = false + finish(timeoutError) + }) }, options.timeout) } options.signal?.addEventListener('abort', onAbort, { once: true }) @@ -480,7 +545,7 @@ async function spawnCommandCapture( recordSubprocessSpawn(spawnCmd, spawnArgs, performance.now() - spawnStartedAt) let timer: NodeJS.Timeout | null = null const onAbort = (): void => { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(createAbortError()) } const cleanupListeners = (): void => { @@ -508,7 +573,7 @@ async function spawnCommandCapture( } timer = options.timeout ? setTimeout(() => { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(new Error(`${command} timed out.`)) }, options.timeout) : null @@ -516,7 +581,7 @@ async function spawnCommandCapture( function onStdoutData(chunk: Buffer): void { stdoutBytes += chunk.byteLength if (options.maxBuffer && stdoutBytes > options.maxBuffer) { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(new Error(`${command} stdout exceeded maxBuffer.`)) return } @@ -525,7 +590,7 @@ async function spawnCommandCapture( function onStderrData(chunk: Buffer): void { stderrBytes += chunk.byteLength if (options.maxBuffer && stderrBytes > options.maxBuffer) { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(new Error(`${command} stderr exceeded maxBuffer.`)) return } @@ -993,7 +1058,7 @@ export async function gitStreamStdout( function onStdoutData(chunk: Buffer): void { stdoutBytes += chunk.byteLength if (stdoutBytes > maxBuffer) { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(new Error('git stdout exceeded maxBuffer.')) return } @@ -1008,7 +1073,7 @@ export async function gitStreamStdout( try { shouldStop = options.onStdout(decoded) } catch (error) { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(error instanceof Error ? error : new Error(String(error))) return } @@ -1016,14 +1081,14 @@ export async function gitStreamStdout( // Why: parser hit its limit. Kill git and resolve cleanly — the // partial output we already parsed is the intended result. stoppedEarly = true - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(null) } } function onStderrData(chunk: Buffer): void { stderrBytes += chunk.byteLength if (stderrBytes > maxBuffer) { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(new Error('git stderr exceeded maxBuffer.')) return } @@ -1040,7 +1105,7 @@ export async function gitStreamStdout( finish(new Error(`git exited with ${code}: ${stderr}`)) } function onAbort(): void { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(createAbortError()) } @@ -1233,110 +1298,6 @@ function argsLookIdempotent(args: string[]): boolean { return true } -/** - * Extract stderr from an execFile rejection. - * - * Why: Node's execFile rejects with an Error that has `.stdout` and `.stderr` - * fields populated separately from `.message`. Reading `err.message` alone is - * unreliable — it can truncate stderr or omit it entirely depending on Node - * version and maxBuffer behavior. We prefer the explicit fields and fall - * back to `.message` only when neither is present. - */ -export function extractExecError(err: unknown): { stderr: string; stdout: string } { - if (err && typeof err === 'object') { - const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown } - const stderr = - typeof e.stderr === 'string' - ? e.stderr - : Buffer.isBuffer(e.stderr) - ? e.stderr.toString('utf-8') - : '' - const stdout = - typeof e.stdout === 'string' - ? e.stdout - : Buffer.isBuffer(e.stdout) - ? e.stdout.toString('utf-8') - : '' - if (stderr || stdout) { - return { stderr, stdout } - } - if (typeof e.message === 'string') { - return { stderr: e.message, stdout: '' } - } - } - return { stderr: String(err), stdout: '' } -} - -/** - * Detect a Retry-After hint in gh stderr and return the suggested delay in ms, - * or null when the response includes no Retry-After. - * - * Why: gh forwards response headers when verbose, and prints "Retry-After: - * " in error output for primary rate-limit 429s. When present, the - * caller is better served by propagating the error so the UI can surface the - * real wait time — retrying on our own 250ms cadence just earns another 429 - * and burns the retry budget. Also supports HTTP-date Retry-After values. - */ -export function parseRetryAfterMs(stderr: string): number | null { - const raw = findRetryAfterHeaderValue(stderr) - if (raw === null) { - return null - } - if (/^\d+$/.test(raw)) { - const seconds = Number(raw) - return Number.isFinite(seconds) ? seconds * 1000 : null - } - const ts = Date.parse(raw) - if (Number.isNaN(ts)) { - return null - } - return Math.max(0, ts - Date.now()) -} - -function findRetryAfterHeaderValue(stderr: string): string | null { - const headerIndex = indexOfAsciiIgnoreCase(stderr, 'retry-after:', 0) - if (headerIndex === -1) { - return null - } - let valueStart = headerIndex + 'retry-after:'.length - while (valueStart < stderr.length) { - const code = stderr.charCodeAt(valueStart) - if (code !== 9 && code !== 32) { - break - } - valueStart++ - } - let valueEnd = valueStart - while (valueEnd < stderr.length) { - const code = stderr.charCodeAt(valueEnd) - if (code === 10 || code === 13) { - break - } - valueEnd++ - } - const value = stderr.slice(valueStart, valueEnd).trim() - return value.length > 0 ? value : null -} - -function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number { - const lastStart = value.length - search.length - for (let index = Math.max(0, fromIndex); index <= lastStart; index++) { - let matches = true - for (let offset = 0; offset < search.length; offset++) { - const code = value.charCodeAt(index + offset) - const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code - if (normalizedCode !== search.charCodeAt(offset)) { - matches = false - break - } - } - if (matches) { - return index - } - } - return -1 -} - /** * Classify whether a gh execFile rejection is worth retrying. * @@ -1568,7 +1529,8 @@ export async function glabExecFileAsync( encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, timeout: options.timeout, - env: options.env + env: options.env, + signal: options.signal }) return { stdout: stdout as string, stderr: stderr as string } } catch (err) { diff --git a/src/main/gitea/client.test.ts b/src/main/gitea/client.test.ts index a0ae0b3ce4f..60f3441263c 100644 --- a/src/main/gitea/client.test.ts +++ b/src/main/gitea/client.test.ts @@ -12,6 +12,7 @@ vi.mock('../git/runner', () => ({ import { getGiteaAuthStatus, getGiteaPullRequestForBranch, + getGiteaPullRequestForBranchOrThrow, normalizeGiteaApiBaseUrl } from './client' import { _resetGiteaRepoRefCache } from './repository-ref' @@ -172,6 +173,19 @@ describe('Gitea client', () => { } }) + it('surfaces a lookup failure via getGiteaPullRequestForBranchOrThrow instead of null (finding 4)', async () => { + const fetchMock = vi.fn(async () => Response.json({ message: 'unauthorized' }, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + + // The swallowing variant returns null — indistinguishable from "no PR". + await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toBeNull() + // The throwing variant makes the failure visible so eligibility records + // `unavailable` rather than a false "No pull request found". + await expect(getGiteaPullRequestForBranchOrThrow('/repo', 'feature/gitea')).rejects.toThrow( + /Gitea request failed/ + ) + }) + it('expires successful scans and bounds retained repository listings', async () => { vi.useFakeTimers() try { diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index 9889598b612..da6c498d8b7 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -78,7 +78,11 @@ function apiUrl(baseUrl: string, path: string, searchParams?: RequestOptions['se async function requestJsonAtBase( baseUrl: string, path: string, - options: RequestOptions = {} + options: RequestOptions = {}, + // Why: the existing-review lookup behind Create must distinguish a real + // transport/auth failure from an accepted "no PR". When true, a failed request + // throws instead of collapsing to null so callers never report false not_found. + throwOnFailure = false ): Promise { const config = getAuthConfig() try { @@ -91,10 +95,16 @@ async function requestJsonAtBase( }) if (!response.ok) { await cancelUnreadResponseBody(response) + if (throwOnFailure) { + throw new Error(`Gitea request failed: HTTP ${response.status}`) + } return null } return (await response.json()) as T - } catch { + } catch (error) { + if (throwOnFailure) { + throw error + } return null } } @@ -102,9 +112,10 @@ async function requestJsonAtBase( function requestJson( repo: GiteaRepoRef, path: string, - options: RequestOptions = {} + options: RequestOptions = {}, + throwOnFailure = false ): Promise { - return requestJsonAtBase(configuredApiBaseUrl(repo), path, options) + return requestJsonAtBase(configuredApiBaseUrl(repo), path, options, throwOnFailure) } function encodedRepoPath(repo: GiteaRepoRef): string { @@ -227,7 +238,8 @@ export async function getGiteaPullRequestForBranch( branch: string, linkedPRNumber?: number | null, connectionId?: string | null, - options: HostedReviewExecutionOptions = {} + options: HostedReviewExecutionOptions = {}, + throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { @@ -245,17 +257,25 @@ export async function getGiteaPullRequestForBranch( if (branchName) { const pullRequests = await scanGiteaPullRequests( - giteaPullRequestScanKey(repo), + // Why: throwing scans keep a separate cache namespace so they never reuse a + // prior swallowing scan's cached-empty from a failed page fetch, which would + // otherwise let a real lookup failure masquerade as "no PR". + throwOnFailure ? `${giteaPullRequestScanKey(repo)}::strict` : giteaPullRequestScanKey(repo), (page) => - requestJson(repo, `/repos/${encodedRepoPath(repo)}/pulls`, { - searchParams: { - state: 'all', - sort: 'recentupdate', - page, - limit: PULL_REQUEST_PAGE_LIMIT + requestJson( + repo, + `/repos/${encodedRepoPath(repo)}/pulls`, + { + searchParams: { + state: 'all', + sort: 'recentupdate', + page, + limit: PULL_REQUEST_PAGE_LIMIT + }, + timeoutMs: PULL_REQUEST_LIST_TIMEOUT_MS }, - timeoutMs: PULL_REQUEST_LIST_TIMEOUT_MS - }), + throwOnFailure + ), PULL_REQUEST_PAGE_LIMIT, MAX_PULL_REQUEST_PAGES ) @@ -270,11 +290,28 @@ export async function getGiteaPullRequestForBranch( } const raw = await requestJson( repo, - `/repos/${encodedRepoPath(repo)}/pulls/${encodeURIComponent(String(linkedPRNumber))}` + `/repos/${encodedRepoPath(repo)}/pulls/${encodeURIComponent(String(linkedPRNumber))}`, + {}, + throwOnFailure ) return raw ? normalizePullRequest(repo, raw) : null } +/** + * Existing-review lookup that surfaces transport/auth failures instead of + * collapsing them to null, so a failed lookup becomes + * `reviewLookupOutcome: 'unavailable'` rather than a false "No pull request found". + */ +export function getGiteaPullRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedPRNumber?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getGiteaPullRequestForBranch(repoPath, branch, linkedPRNumber, connectionId, options, true) +} + export async function getGiteaRepoSlug( repoPath: string, connectionId?: string | null, diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index d403d582fcd..07a007f7f1e 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -1268,6 +1268,34 @@ describe('getPRForBranch', () => { ) }) + it('propagates a Retry-After cooldown into the rate-limited retry schedule', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [{ owner: 'stablyai', repo: 'orca' }], + headRepo: null + }) + // gh puts the diagnostic on `.stderr`; a secondary limit carries Retry-After. + ghExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('gh exited with 1.'), { + stderr: 'HTTP 403: You have exceeded a secondary rate limit\nRetry-After: 120' + }) + ) + .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) + + const before = Date.now() + const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') + expect(outcome.kind).toBe('upstream-error') + if (outcome.kind !== 'upstream-error') { + throw new Error('expected upstream-error') + } + expect(outcome.errorType).toBe('rate_limited') + // ~120s cooldown surfaced as both the manual gate and the auto-retry time. + expect(outcome.retryDisabledUntil).toBeDefined() + expect(outcome.nextAutoRetryAt).toBe(outcome.retryDisabledUntil) + expect(outcome.retryDisabledUntil ?? 0).toBeGreaterThanOrEqual(before + 119_000) + expect(outcome.retryDisabledUntil ?? 0).toBeLessThanOrEqual(Date.now() + 121_000) + }) + it('reports no PR when fallback branch discovery cleanly misses', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 67d34bef4ba..d9d41f4f653 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -39,6 +39,10 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { sliceCheckLogTail } from './check-job-log-tail-slice' +import { + classifyPRRefreshError, + safePRRefreshErrorMessage +} from './pr-refresh-error-classification' import { getPRConflictSummary } from './conflict-summary' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' @@ -47,7 +51,6 @@ import { execFileAsync, ghExecFileAsync, gitExecFileAsync, - extractExecError, acquire, release, getOwnerRepo, @@ -63,6 +66,9 @@ import { type LocalGitExecOptions, type OwnerRepo } from './gh-utils' +// Import the pure error-parsing helpers from the lightweight module, not +// `./gh-utils`, so tests that mock gh-utils still resolve the real functions. +import { extractExecError, parseRetryAfterMs } from '../git/exec-error' import { isCommitPartOfMergedPR, type MergedPRCommitMembership @@ -181,61 +187,28 @@ async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise } } -function classifyPRRefreshError( - err: unknown -): Extract['errorType'] { - const message = err instanceof Error ? err.message : String(err) - // Why: GitHub-unreachable buckets (5xx outage / network / rate limit) share - // one detector with the Tasks work-item path so both surfaces attribute an - // outage to GitHub identically. Rate-limit responses also carry "HTTP 403", - // so this must run before the permission check below. - const unavailable = classifyGitHubUnavailable(message) - if (unavailable) { - return unavailable - } - const lower = message.toLowerCase() - if (lower.includes('http 403') || lower.includes('resource not accessible')) { - return 'permission' - } - if (lower.includes('http 404') || lower.includes('could not resolve to a repository')) { - return 'repo_unavailable' - } - return /auth|login|credential/i.test(message) ? 'auth' : 'unknown' -} - -function safePRRefreshErrorMessage( - errorType: Extract['errorType'] -): string { - switch (errorType) { - case 'rate_limited': - return 'GitHub rate limit is low. Try again after the limit resets.' - case 'auth': - return 'GitHub authentication is unavailable. Check your gh login.' - case 'network': - return 'GitHub is unreachable right now. Check your network and try again.' - case 'server_error': - return "GitHub's API is temporarily unavailable (server error). This is a GitHub-side issue." - case 'permission': - return 'GitHub did not allow access to this pull request.' - case 'repo_unavailable': - return 'The GitHub repository is unavailable or cannot be resolved.' - case 'gh_unavailable': - return 'GitHub CLI is unavailable.' - case 'unknown': - return 'GitHub pull request refresh failed.' - } -} - function prRefreshUpstreamError( err: unknown ): Extract { const errorType = classifyPRRefreshError(err) - return { + const outcome: Extract = { kind: 'upstream-error', errorType, message: safePRRefreshErrorMessage(errorType), fetchedAt: Date.now() } + // Why: a rate limit that carries a Retry-After is a real cooldown — surface it + // as the retry schedule so the renderer disables manual Retry until the reset + // and shows a truthful auto-retry time, instead of retrying into another 429. + if (errorType === 'rate_limited') { + const retryAfterMs = parseRetryAfterMs(extractExecError(err).stderr) + if (retryAfterMs !== null && retryAfterMs > 0) { + const retryAt = Date.now() + retryAfterMs + outcome.nextAutoRetryAt = retryAt + outcome.retryDisabledUntil = retryAt + } + } + return outcome } function isNoPullRequestError(err: unknown): boolean { diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 38b3d23cf30..b294e90ac1a 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -1,11 +1,14 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' -import { gitExecFileAsync, ghExecFileAsync, extractExecError } from '../git/runner' +import { gitExecFileAsync, ghExecFileAsync } from '../git/runner' +// Pure error-parsing helpers come from the lightweight module (not `runner`) so +// tests that mock `../git/runner` still resolve the real implementations. +import { extractExecError, parseRetryAfterMs } from '../git/exec-error' // Why: legacy generic execFile wrapper - only used by callers that don't need // WSL-aware routing. Repo-scoped callers should use the runner exports below. export const execFileAsync = promisify(execFile) -export { ghExecFileAsync, gitExecFileAsync, extractExecError } +export { ghExecFileAsync, gitExecFileAsync, extractExecError, parseRetryAfterMs } export { classifyGhError, classifyListIssuesError } from './gh-error-classification' export { _getOwnerRepoCacheSize, diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index bd59fc77cf2..700cc32ecd2 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -83,7 +83,30 @@ const queue = new Map() const backgroundStarts: number[] = [] const activeStartsByScope = new Map() const errorBackoff = new Map() +// Per-key manual-retry cooldown from a real secondary rate limit (Retry-After). +// refreshPRNow refuses a manual retry until this time so a stale renderer cannot +// bypass the gate the client advertised via `retryDisabledUntil`. +const manualRetryGates = new Map() let lastBackgroundStartAt = 0 + +/** + * Track (or clear) the manual-retry cooldown for a key from a broadcast outcome. + * Only a rate-limit outcome carrying `retryDisabledUntil` sets a gate; any other + * settled outcome clears it. + */ +function noteManualRetryGate(key: string, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { + manualRetryGates.set(key, outcome.retryDisabledUntil) + } else { + manualRetryGates.delete(key) + } +} + +/** Reset all per-key retry state (backoff + manual cooldown) when a key resets. */ +function resetKeyRetryState(key: string): void { + errorBackoff.delete(key) + manualRetryGates.delete(key) +} const visibleByWindow = new Map }>() let outcomeObserver: PRRefreshOutcomeObserver | null = null const diagnosticsCounters = { @@ -101,7 +124,7 @@ function removeInvisibleVisibleRefreshes(): void { for (const [key, entry] of queue) { if (entry.reason === 'visible' && !isVisibleKey(key)) { queue.delete(key) - errorBackoff.delete(key) + resetKeyRetryState(key) broadcast({ aliases: Array.from(entry.aliases.values()), reason: 'visible', @@ -177,7 +200,7 @@ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { // No aliases left means no worktree still cares about this refresh. if (entry.aliases.size === 0) { queue.delete(key) - errorBackoff.delete(key) + resetKeyRetryState(key) continue } // Keep the entry alive but ensure its representative candidate is not a @@ -385,7 +408,7 @@ function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefres const replacementAlias = existing.aliases.values().next().value if (!replacementAlias) { queue.delete(key) - errorBackoff.delete(key) + resetKeyRetryState(key) return } @@ -405,6 +428,44 @@ function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefres } } +/** + * Advances the visible-key error backoff and returns the earliest retry time. + * The renderer surfaces this as `nextAutoRetryAt`; only visible keys auto-retry, + * so callers must gate on `isVisibleKey` before computing a schedule. + */ +function nextVisibleErrorRetryAt(key: string): number { + const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 + const retryAt = + Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(failures - 1, 4)) + errorBackoff.set(key, { failures, retryAt }) + return retryAt +} + +/** + * Stamps the auto-retry time onto an error outcome before broadcast so every + * alias receives identical timing. + * + * Why: this only sets `nextAutoRetryAt` (the "Orca will retry at {time}" hint). + * It must NOT set `retryDisabledUntil` — that field disables *manual* Retry and + * is reserved for a real rate-limit gate (the paused/breaker path in + * `refreshPRNow`, which maps `pausedUntil` into it). Deriving it from ordinary + * exponential backoff would disable a manual Retry that `refreshPRNow` would in + * fact accept. + */ +function withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { + if (outcome.kind !== 'upstream-error') { + return outcome + } + // Why: preserve a real Retry-After cooldown the client already stamped on the + // outcome (`retryDisabledUntil`); auto-retry no earlier than both the backoff + // and that cooldown so we do not retry into an active secondary rate limit. + const cooldownUntil = outcome.retryDisabledUntil + return { + ...outcome, + nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt + } +} + function scheduleVisibleFollowUp( key: string, candidate: GitHubPRRefreshCandidate, @@ -412,19 +473,18 @@ function scheduleVisibleFollowUp( priority: number, aliases: GitHubPRRefreshAlias[], windowId?: number, - options?: { pendingMergeabilityDelayMs?: number } + options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } ): void { if (!isVisibleKey(key)) { // Why: manual/active refreshes can remove the queued visible retry after // its owner window is gone, leaving the retry backoff without an owner. - errorBackoff.delete(key) + resetKeyRetryState(key) return } if (outcome.kind === 'upstream-error') { - const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 - const retryAt = - Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(failures - 1, 4)) - errorBackoff.set(key, { failures, retryAt }) + // Why: reuse the retry time already computed for the broadcast schedule so + // the same failure is not counted twice against the backoff. + const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) setVisibleFollowUp({ key, candidate, @@ -440,7 +500,7 @@ function scheduleVisibleFollowUp( scheduleDrain(retryAt - Date.now()) return } - errorBackoff.delete(key) + resetKeyRetryState(key) const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() const pendingMergeabilityDueAt = @@ -693,7 +753,7 @@ async function drainQueue(): Promise { continue } if (next.reason === 'visible' && !isVisibleKey(next.key)) { - errorBackoff.delete(next.key) + resetKeyRetryState(next.key) broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' }) continue } @@ -748,15 +808,30 @@ async function drainQueue(): Promise { next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, ...hostedReviewOptionArgs(next.candidate) ) + // Why: compute the retry schedule before broadcasting so the outcome and + // its follow-up retry share one timing; only visible keys auto-retry. + let plannedRetryAt: number | undefined + let broadcastOutcome = outcome + if (outcome.kind === 'upstream-error' && isVisibleKey(next.key)) { + plannedRetryAt = nextVisibleErrorRetryAt(next.key) + broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) + } outcomeObserver?.(next.candidate, outcome) - broadcast({ aliases, reason: next.reason, outcome, requestStartedAt }, requestSequence) + noteManualRetryGate(next.key, broadcastOutcome) + broadcast( + { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, + requestSequence + ) scheduleVisibleFollowUp( next.key, next.candidate, outcome, next.priority, aliases, - next.windowId + next.windowId, + { + plannedRetryAt + } ) } } finally { @@ -893,6 +968,53 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise return outcome } + // Why: enforce the same rate-limit gate main advertises via `retryDisabledUntil` + // so a stale renderer cannot bypass it. A blocked primary bucket means gh would + // 403 anyway; a recorded secondary Retry-After cooldown means gh would 429 + // again. Refuse without spending quota until the later of the two and report + // the pause honestly. + const manualBlockedGuard = backgroundRefreshBuckets() + .map((bucket) => rateLimitGuard(bucket)) + .find((guard) => guard.blocked) + const secondaryGateUntil = manualRetryGates.get(key) + const gateUntil = Math.max( + manualBlockedGuard?.blocked ? manualBlockedGuard.resetAt * 1000 : 0, + secondaryGateUntil !== undefined ? secondaryGateUntil : 0 + ) + if (gateUntil > Date.now()) { + const retryAt = gateUntil + // Why: the paused broadcast maps `pausedUntil` into the renderer's + // `nextAutoRetryAt` ("Orca will retry at {time}"), so that auto-retry must be + // real. Requeue this candidate at the reset time and wake the drain then — + // mirroring the background paused path — instead of advertising an automatic + // retry that was never scheduled (finding 12). + queue.set(key, { + key, + candidate, + aliases: aliasMap, + reason: 'manual', + priority: 40, + dueAt: retryAt, + queuedAt: nextQueueOrder() + }) + broadcast({ + aliases, + reason: 'manual', + status: 'paused', + pausedUntil: retryAt, + skippedReason: 'rate-limit' + }) + scheduleDrain(Math.max(1_000, retryAt - Date.now())) + return { + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', + fetchedAt: Date.now(), + nextAutoRetryAt: retryAt, + retryDisabledUntil: retryAt + } + } + queue.delete(key) const requestSequence = nextSequence() const requestStartedAt = Date.now() @@ -905,12 +1027,23 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, ...hostedReviewOptionArgs(candidate) ) + let plannedRetryAt: number | undefined + let broadcastOutcome = outcome + if (outcome.kind === 'upstream-error' && isVisibleKey(key)) { + plannedRetryAt = nextVisibleErrorRetryAt(key) + broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) + } outcomeObserver?.(candidate, outcome) - broadcast({ aliases, reason: 'manual', outcome, requestStartedAt }, requestSequence) + noteManualRetryGate(key, broadcastOutcome) + broadcast( + { aliases, reason: 'manual', outcome: broadcastOutcome, requestStartedAt }, + requestSequence + ) scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { + plannedRetryAt, // Why: GitHub often reports UNKNOWN immediately after `gh pr reopen`; // do one prompt visible retry so conflicts replace the transient label. pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }) - return outcome + return broadcastOutcome } diff --git a/src/main/github/pr-refresh-error-classification.test.ts b/src/main/github/pr-refresh-error-classification.test.ts new file mode 100644 index 00000000000..549fc10fdc8 --- /dev/null +++ b/src/main/github/pr-refresh-error-classification.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { + classifyPRRefreshError, + safePRRefreshErrorMessage +} from './pr-refresh-error-classification' + +describe('classifyPRRefreshError', () => { + it('classifies an HTTP 429 as rate_limited even without a rate-limit body', () => { + expect(classifyPRRefreshError(new Error('HTTP 429 Too Many Requests'))).toBe('rate_limited') + }) + + it('classifies secondary rate limit markers as rate_limited, not permission', () => { + for (const message of [ + 'You have exceeded a secondary rate limit', + 'abuse detection mechanism triggered', + 'abuse-rate-limits', + 'you have triggered an abuse detection mechanism' + ]) { + expect(classifyPRRefreshError(new Error(message))).toBe('rate_limited') + } + }) + + it('classifies a 403 carrying Retry-After as rate_limited, not permission', () => { + expect(classifyPRRefreshError(new Error('HTTP 403 Forbidden; Retry-After: 60'))).toBe( + 'rate_limited' + ) + }) + + it('classifies the primary breaker language as rate_limited', () => { + expect(classifyPRRefreshError(new Error('API rate limit exceeded for user'))).toBe( + 'rate_limited' + ) + }) + + it('classifies a plain 403 resource denial as permission', () => { + expect( + classifyPRRefreshError(new Error('HTTP 403: Resource not accessible by integration')) + ).toBe('permission') + }) + + it('classifies network failures', () => { + for (const message of ['ETIMEDOUT', 'could not resolve host github.com', 'network is down']) { + expect(classifyPRRefreshError(new Error(message))).toBe('network') + } + }) + + it('classifies 404 / could not resolve repository as repo_unavailable', () => { + expect(classifyPRRefreshError(new Error('HTTP 404 Not Found'))).toBe('repo_unavailable') + expect( + classifyPRRefreshError(new Error('Could not resolve to a Repository with the name')) + ).toBe('repo_unavailable') + }) + + it('classifies an ENOENT spawn failure as gh_unavailable', () => { + const err = Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }) + expect(classifyPRRefreshError(err)).toBe('gh_unavailable') + expect(classifyPRRefreshError(new Error("'gh' is not recognized as an internal command"))).toBe( + 'gh_unavailable' + ) + }) + + it('classifies auth failures after rate-limit and permission checks', () => { + expect(classifyPRRefreshError(new Error('authentication failed: bad credentials'))).toBe('auth') + }) + + it('falls back to unknown', () => { + expect(classifyPRRefreshError(new Error('something unexpected happened'))).toBe('unknown') + }) + + it('does not classify a message that merely contains "author" as auth', () => { + for (const message of [ + 'PR author octocat has no write access to the fork', + 'authored 3 commits, none pushed', + 'unexpected failure from author service' + ]) { + expect(classifyPRRefreshError(new Error(message))).toBe('unknown') + } + }) + + it('does not classify a repository name containing "network" as a network failure', () => { + // A repo/branch called "network-*" is not a connectivity failure; without a + // structured code or a real connectivity phrase this must stay unknown. + expect(classifyPRRefreshError(new Error('operation failed for repo acme/network-tools'))).toBe( + 'unknown' + ) + }) + + it('classifies a 404 repository error even when the message mentions network', () => { + // 404 ranks before the network branch so the repo error is not misread. + expect( + classifyPRRefreshError( + new Error('Could not resolve to a Repository named acme/network-proxy (HTTP 404)') + ) + ).toBe('repo_unavailable') + }) + + it('classifies a 401 as auth', () => { + expect(classifyPRRefreshError(new Error('HTTP 401 Unauthorized'))).toBe('auth') + }) + + it('classifies from the real runner error shape where the signal is on .stderr', () => { + // Why: the runner rejects with a generic message and puts the gh diagnostic + // on `.stderr` (string or Buffer). Reading `.message` alone would misclassify + // these hard errors as `unknown` and treat them as transient. + const permission = Object.assign(new Error('gh exited with 1.'), { + stderr: 'HTTP 403: Resource not accessible by integration' + }) + expect(classifyPRRefreshError(permission)).toBe('permission') + + const repo = Object.assign(new Error('gh exited with 1.'), { + stderr: Buffer.from('HTTP 404: Not Found') + }) + expect(classifyPRRefreshError(repo)).toBe('repo_unavailable') + + const secondary = Object.assign(new Error('gh exited with 1.'), { + stderr: 'You have exceeded a secondary rate limit' + }) + expect(classifyPRRefreshError(secondary)).toBe('rate_limited') + + const auth = Object.assign(new Error('gh exited with 1.'), { + stdout: '', + stderr: 'gh auth login required: bad credentials' + }) + expect(classifyPRRefreshError(auth)).toBe('auth') + }) +}) + +describe('safePRRefreshErrorMessage', () => { + it('returns non-empty copy for every classified type without leaking raw errors', () => { + for (const type of [ + 'rate_limited', + 'auth', + 'network', + 'permission', + 'repo_unavailable', + 'gh_unavailable', + 'unknown' + ] as const) { + const message = safePRRefreshErrorMessage(type) + expect(message.length).toBeGreaterThan(0) + expect(message).not.toContain('ENOENT') + } + }) +}) diff --git a/src/main/github/pr-refresh-error-classification.ts b/src/main/github/pr-refresh-error-classification.ts new file mode 100644 index 00000000000..aa9e226939d --- /dev/null +++ b/src/main/github/pr-refresh-error-classification.ts @@ -0,0 +1,144 @@ +import type { PRRefreshErrorType } from '../../shared/types' +import { classifyGitHubUnavailable } from '../../shared/github-api-availability' +import { extractExecError } from '../git/exec-error' + +/** + * Sanitization boundary for GitHub PR-refresh failures. Maps a raw runner/CLI + * error to a stable {@link PRRefreshErrorType}; the renderer turns that into + * classified copy so raw stderr / env values never reach the UI. + * + * Classification order (see docs/reference/pr-panel-refresh-guidance.md): + * HTTP 429 / secondary rate limit → primary rate limit → repo_unavailable (404) → + * network → permission (403) → gh_unavailable → auth → unknown. GitHub returns + * 403 OR 429 for both primary and secondary limits, so the http-403 permission + * branch must run only after the rate-limit checks. 404 is matched before the + * network branch so a repository error whose message happens to contain a + * substring like "network" is not misread as a connectivity failure. Auth and + * network detection avoid broad substrings (e.g. "auth" inside "author", or a + * repo name containing "network") in favor of structured codes and full phrases. + */ +export function classifyPRRefreshError(err: unknown): PRRefreshErrorType { + // Why: the git/gh runner keeps diagnostics on `.stderr`/`.stdout` separate + // from `.message` (which can be just "gh exited with 1."). Reading message + // alone would misclassify a real "HTTP 403" / "HTTP 404" as `unknown`, so a + // hard error would be treated as transient. Combine all three for substring + // detection while still preferring the structured spawn `code`. + const { stderr, stdout } = extractExecError(err) + const message = err instanceof Error ? err.message : String(err) + const lower = `${message}\n${stderr}\n${stdout}`.toLowerCase() + const code = + err && typeof err === 'object' && 'code' in err + ? String((err as { code?: unknown }).code ?? '').toLowerCase() + : '' + + // Rate limits first: a 429 is always a rate-limit signal; secondary limits also + // arrive as abuse-mechanism phrasing or a 403/429 carrying Retry-After, none of + // which contain "rate limit". + const isHttp429 = lower.includes('http 429') || lower.includes('429 too many requests') + const isHttp403 = lower.includes('http 403') + const hasRetryAfter = lower.includes('retry-after') + if ( + isHttp429 || + lower.includes('secondary rate limit') || + lower.includes('abuse detection') || + lower.includes('abuse-rate-limits') || + lower.includes('you have triggered an abuse') || + ((isHttp403 || isHttp429) && hasRetryAfter) || + lower.includes('api rate limit exceeded') || + lower.includes('rate limit') + ) { + return 'rate_limited' + } + // Repository resolution failures (404) rank before the network branch: a "could + // not resolve to a Repository" message must not be captured by a connectivity + // heuristic, and matching 404 first isolates it from any incidental substring. + if (lower.includes('http 404') || lower.includes('could not resolve to a repository')) { + return 'repo_unavailable' + } + // Why: keep GitHub 5xx/network attribution aligned with other GitHub surfaces. + const unavailable = classifyGitHubUnavailable(lower) + if (unavailable) { + return unavailable + } + // Network: structured error codes and full connectivity phrases only. Never a + // bare "network" substring — a repo/branch/message containing "network" is not + // evidence of a connectivity failure. + const networkCodes = new Set([ + 'etimedout', + 'econnreset', + 'econnrefused', + 'enotfound', + 'eai_again', + 'enetunreach', + 'enetdown' + ]) + if ( + networkCodes.has(code) || + lower.includes('etimedout') || + lower.includes('econnreset') || + lower.includes('econnrefused') || + lower.includes('enotfound') || + lower.includes('eai_again') || + lower.includes('timed out') || + lower.includes('timeout') || + lower.includes('could not resolve host') || + lower.includes('no such host') || + lower.includes('network is unreachable') || + lower.includes('network is down') || + lower.includes('network error') || + lower.includes('connection refused') || + lower.includes('connection reset') + ) { + return 'network' + } + if (isHttp403 || lower.includes('resource not accessible')) { + return 'permission' + } + // gh CLI launch failure: prefer the structured spawn error code over a broad + // substring so a repo path merely containing "gh" is not misclassified. + if ( + code === 'enoent' || + lower.includes('spawn gh enoent') || + lower.includes('gh: command not found') || + lower.includes("'gh' is not recognized") + ) { + return 'gh_unavailable' + } + // Auth last: match full auth phrases and a 401, never a bare "auth" substring + // that also fires on "author"/"authored". "unauthorized"/"authorization" are + // long enough not to collide with "author". + if ( + lower.includes('http 401') || + lower.includes('unauthorized') || + lower.includes('authorization') || + lower.includes('authentication') || + lower.includes('bad credentials') || + lower.includes('gh auth') || + /\b(login|credentials?)\b/i.test(lower) + ) { + return 'auth' + } + return 'unknown' +} + +/** Stable, non-destructive fallback message for a classified refresh error. */ +export function safePRRefreshErrorMessage(errorType: PRRefreshErrorType): string { + switch (errorType) { + case 'rate_limited': + return 'GitHub rate limit is low. Try again after the limit resets.' + case 'auth': + return 'GitHub authentication is unavailable. Check your gh login.' + case 'network': + return 'GitHub is unreachable right now. Check your network and try again.' + case 'server_error': + return "GitHub's API is temporarily unavailable (server error). This is a GitHub-side issue." + case 'permission': + return 'GitHub did not allow access to this pull request.' + case 'repo_unavailable': + return 'The GitHub repository is unavailable or cannot be resolved.' + case 'gh_unavailable': + return 'GitHub CLI is unavailable.' + case 'unknown': + return 'GitHub pull request refresh failed.' + } +} diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index a090ec7963b..aac5193c1e3 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -41,6 +41,7 @@ import { addMRComment, getMergeRequest, getMergeRequestForBranch, + getMergeRequestForBranchOrThrow, getJobTrace, addMRInlineComment, closeMR, @@ -74,6 +75,19 @@ describe('gitlab client — MR operations', () => { }) }) + it('getMergeRequestForBranchOrThrow surfaces a glab failure instead of null (finding 4)', async () => { + getProjectRefMock.mockResolvedValue({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockRejectedValue(new Error('glab: connection refused')) + + // The swallowing variant collapses a real failure into a false "no MR". + await expect(getMergeRequestForBranch('/repo', 'feature/x')).resolves.toBeNull() + // The throwing variant makes the failure visible so eligibility records + // `unavailable` rather than a false "No merge request found". + await expect(getMergeRequestForBranchOrThrow('/repo', 'feature/x')).rejects.toThrow( + /connection refused/ + ) + }) + it('routes local WSL MR review-management and job actions through project resolution and glab options', async () => { const localGitOptions = { wslDistro: 'Ubuntu' } glabExecFileAsyncMock diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index b6b32dfb8c3..eef57b3aaba 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -248,13 +248,9 @@ export async function getProjectSlug( connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) - return getProjectRef( - repoPath, - knownHosts, - connectionId, - ...hostedReviewLocalGitOptionArgs(options) - ) + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const knownHosts = await getGlabKnownHosts(connectionId, localGitArgs[0]) + return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) } /** @@ -268,9 +264,9 @@ export async function getMergeRequest( connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) const localGitArgs = hostedReviewLocalGitOptionArgs(options) const localGitOptions = localGitArgs[0] ?? {} + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) await acquire() try { @@ -313,15 +309,19 @@ export async function getMergeRequestForBranch( branch: string, linkedMRIid?: number | null, connectionId?: string | null, - options: HostedReviewExecutionOptions = {} + options: HostedReviewExecutionOptions = {}, + // Why: the existing-review lookup behind Create must distinguish a real glab + // transport/auth failure from an accepted "no MR". When true, a failed lookup + // throws instead of collapsing to null so callers never report false not_found. + throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedMRIid == null) { return null } - const knownHosts = await getGlabKnownHosts(connectionId) const localGitArgs = hostedReviewLocalGitOptionArgs(options) const localGitOptions = localGitArgs[0] ?? {} + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) if (!projectRef) { return null @@ -369,13 +369,31 @@ export async function getMergeRequestForBranch( } const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) return mapMRInfo(raw, pipelineStatus) - } catch { + } catch (error) { + if (throwOnFailure) { + throw error + } return null } finally { release() } } +/** + * Existing-review lookup that surfaces glab transport/auth failures instead of + * collapsing them to null, so a failed lookup becomes + * `reviewLookupOutcome: 'unavailable'` rather than a false "No merge request found". + */ +export function getMergeRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedMRIid?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getMergeRequestForBranch(repoPath, branch, linkedMRIid, connectionId, options, true) +} + function mrListStateFlags(state: MRListState): string[] { switch (state) { case 'opened': @@ -403,7 +421,7 @@ export async function listMergeRequests( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) // Why: MRs sit on `origin` in the fork model (the user's fork is where // they push branches and submit MRs). Mirror github's `getOwnerRepo` // call site by going through the upstream/origin preference resolver @@ -601,7 +619,7 @@ export async function listWorkItems( localGitOptions: LocalGitExecOptions = {} ): Promise> { const issueState = mrStateToIssueState(state) - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, @@ -737,7 +755,7 @@ export async function listTodos( ): Promise { const projectRef = await getProjectRef( repoPath, - await getGlabKnownHosts(connectionId), + await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) @@ -816,7 +834,7 @@ async function withProjectRef( await resolveIssueSource( repoPath, preference, - await getGlabKnownHosts(connectionId), + await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts new file mode 100644 index 00000000000..1756df4ce96 --- /dev/null +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -0,0 +1,110 @@ +import { glabExecFileAsync } from '../git/runner' +import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' +import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser' + +export type LocalGitExecOptions = { + wslDistro?: string +} + +const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 +const knownHostsCacheByExecutionContext = new Map() +const knownHostsInFlightByExecutionContext = new Map>() + +function knownHostsExecutionKey( + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + if (connectionId) { + // Why: reconnecting can replace the SSH/relay execution host under the same id. + return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + } + return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'native' +} + +/** @internal - exposed for tests only */ +export function _resetKnownHostsCache(): void { + knownHostsCacheByExecutionContext.clear() + knownHostsInFlightByExecutionContext.clear() +} + +export function rememberGlabKnownHost( + host: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): void { + const normalizedHost = normalizeGitLabHost(host) + const key = knownHostsExecutionKey(connectionId, localGitOptions) + const cached = knownHostsCacheByExecutionContext.get(key) + if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) { + return + } + knownHostsCacheByExecutionContext.set(key, [...cached, normalizedHost]) +} + +export async function getGlabKnownHosts( + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const key = knownHostsExecutionKey(connectionId, localGitOptions) + const cached = knownHostsCacheByExecutionContext.get(key) + if (cached) { + return cached + } + const inFlight = knownHostsInFlightByExecutionContext.get(key) + if (inFlight) { + return inFlight + } + const probe = probeGlabKnownHosts(key, connectionId, localGitOptions) + knownHostsInFlightByExecutionContext.set(key, probe) + try { + return await probe + } finally { + if (knownHostsInFlightByExecutionContext.get(key) === probe) { + knownHostsInFlightByExecutionContext.delete(key) + } + } +} + +async function probeGlabKnownHosts( + key: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + try { + // Why: auth config belongs to the executing host; do not share native, WSL, + // or reconnected SSH/relay results, and bound an otherwise global probe. + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { + timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS, + ...(!connectionId && localGitOptions.wslDistro + ? { wslDistro: localGitOptions.wslDistro } + : {}) + }) + const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) + const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) + knownHostsCacheByExecutionContext.set(key, merged) + return merged + } catch { + // Keep failures uncached so auth or tunnel recovery is discovered later. + return [...DEFAULT_GITLAB_HOSTS] + } +} + +export function parseGlabAuthStatusHosts(output: string): string[] { + const hosts = new Set() + // Why: self-hosted GitLab can run on a non-default port; preserve it so + // services on the same hostname remain distinct downstream. + for (const match of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) { + hosts.add(match[1].toLowerCase()) + } + for (const line of output.split('\n')) { + const bareLine = line.trim() + const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine + if ( + line === bareLine && + /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine) + ) { + hosts.add(hostLine.toLowerCase()) + } + } + return Array.from(hosts) +} diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 1dba771d05e..218a41869c9 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -2,6 +2,11 @@ import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' import type { IssueSourcePreference } from '../../shared/types' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' +import { + parseGlabAuthStatusHosts, + rememberGlabKnownHost, + type LocalGitExecOptions +} from './gitlab-known-host-probe' import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost, @@ -12,26 +17,16 @@ import { export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef } export type { ProjectRef } - -export type LocalGitExecOptions = { - wslDistro?: string -} +export { + _resetKnownHostsCache, + getGlabKnownHosts, + parseGlabAuthStatusHosts +} from './gitlab-known-host-probe' +export type { LocalGitExecOptions } from './gitlab-known-host-probe' const PROJECT_REF_CACHE_MAX_ENTRIES = 512 const projectRefCache = new Map() -// Why: known hosts are cached PER connection. A repo on an SSH connection -// authenticates against a different glab context than the local one, so a -// process-global cache would leak one connection's hosts into another (and -// poison a connection that probes before its tunnel is ready). The local -// context uses the `'local'` key. -const LOCAL_CONNECTION_KEY = 'local' -const knownHostsCacheByConnection = new Map() - -function connectionCacheKey(connectionId?: string | null): string { - return connectionId ?? LOCAL_CONNECTION_KEY -} - /** @internal - exposed for tests only */ export function _resetProjectRefCache(): void { projectRefCache.clear() @@ -43,11 +38,6 @@ export function _getProjectRefCacheSize(): number { return projectRefCache.size } -/** @internal - exposed for tests only */ -export function _resetKnownHostsCache(): void { - knownHostsCacheByConnection.clear() -} - function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { projectRefCache.set(cacheKey, value) while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) { @@ -118,7 +108,7 @@ async function resolveProjectRefForRemote( localGitOptions )) ) { - rememberGlabKnownHost(remoteCandidate.host, connectionId) + rememberGlabKnownHost(remoteCandidate.host, connectionId, localGitOptions) rememberProjectRefCacheEntry(cacheKey, remoteCandidate) return remoteCandidate } @@ -230,16 +220,6 @@ export function glabHostnameArgs( return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : [] } -function rememberGlabKnownHost(host: string, connectionId?: string | null): void { - const normalizedHost = normalizeGitLabHost(host) - const key = connectionCacheKey(connectionId) - const cached = knownHostsCacheByConnection.get(key) - if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) { - return - } - knownHostsCacheByConnection.set(key, [...cached, normalizedHost]) -} - async function isGlabConfiguredForRemoteHost( repoPath: string, projectRef: Pick, @@ -262,50 +242,3 @@ async function isGlabConfiguredForRemoteHost( return hosts.includes(normalizeGitLabHost(projectRef.host)) } } - -export async function getGlabKnownHosts(connectionId?: string | null): Promise { - const key = connectionCacheKey(connectionId) - const cached = knownHostsCacheByConnection.get(key) - if (cached) { - return cached - } - try { - // Why: `glab auth status` is host-scoped, not cwd-scoped — glab reads its - // own config to list authenticated hosts. The connectionId is threaded so - // the RESULT is cached per connection (a connected repo can have a - // different set of authenticated self-hosted hosts than the local one), - // mirroring how project-ref resolution caches per connection. - const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) - const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) - const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) - knownHostsCacheByConnection.set(key, merged) - return merged - } catch { - // Auth check failed (glab not installed, no auth, tunnel not ready, - // etc.) — fall back to the canonical default for THIS call, but do NOT - // cache the fallback. A later probe (e.g. after the SSH tunnel comes - // up) must be able to discover the real self-hosted host. - return [...DEFAULT_GITLAB_HOSTS] - } -} - -export function parseGlabAuthStatusHosts(output: string): string[] { - const hosts = new Set() - // Why: self-hosted GitLab can run on a non-default port (e.g. - // `gitlab.example.com:8443`); capture the optional `:port` so two services - // on the same hostname but different ports stay distinct downstream. - for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) { - hosts.add(m[1].toLowerCase()) - } - for (const line of output.split('\n')) { - const bareLine = line.trim() - const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine - if ( - line === bareLine && - /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine) - ) { - hosts.add(hostLine.toLowerCase()) - } - } - return Array.from(hosts) -} diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 7306c5b7fd4..9ab99ddc441 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -434,6 +434,7 @@ describe('getGlabKnownHosts', () => { }) await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com']) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { timeout: 10_000 }) }) it('falls back to default when glab auth status fails', async () => { @@ -453,6 +454,55 @@ describe('getGlabKnownHosts', () => { expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) }) + it('coalesces many simultaneous callers in one execution context', async () => { + let resolveProbe!: (value: { stdout: string; stderr: string }) => void + glabExecFileAsyncMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + + const probes = Array.from({ length: 64 }, () => getGlabKnownHosts()) + + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + resolveProbe({ stdout: 'Logged in to gitlab.concurrent.test as user\n', stderr: '' }) + const results = await Promise.all(probes) + expect(results.every((result) => result === results[0])).toBe(true) + expect(results[0]).toEqual(['gitlab.com', 'gitlab.concurrent.test']) + }) + + it('keeps simultaneous native, WSL distro, and connection probes isolated', async () => { + glabExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'Logged in to ubuntu.test as user\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Logged in to debian.test as user\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' }) + + const [ubuntu, ubuntuAgain, debian, native, ssh] = await Promise.all([ + getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }), + getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }), + getGlabKnownHosts(undefined, { wslDistro: 'Debian' }), + getGlabKnownHosts(), + getGlabKnownHosts('conn-1') + ]) + + expect(ubuntuAgain).toBe(ubuntu) + expect(ubuntu).toEqual(['gitlab.com', 'ubuntu.test']) + expect(debian).toEqual(['gitlab.com', 'debian.test']) + expect(native).toEqual(['gitlab.com', 'native.test']) + expect(ssh).toEqual(['gitlab.com', 'ssh.test']) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4) + expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['auth', 'status'], { + timeout: 10_000, + wslDistro: 'Ubuntu' + }) + expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['auth', 'status'], { + timeout: 10_000, + wslDistro: 'Debian' + }) + }) + it('recognizes a self-hosted host on a non-default port', async () => { glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.example.com:8080 as user\n', @@ -500,4 +550,51 @@ describe('getGlabKnownHosts', () => { ]) expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + + it('removes a timed-out probe from in-flight state so a later call retries', async () => { + let rejectProbe!: (error: Error) => void + glabExecFileAsyncMock + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectProbe = reject + }) + ) + .mockResolvedValueOnce({ stdout: 'Logged in to recovered.test as user\n', stderr: '' }) + + const first = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }) + const concurrent = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + rejectProbe(new Error('wsl.exe timed out.')) + + await expect(Promise.all([first, concurrent])).resolves.toEqual([ + ['gitlab.com'], + ['gitlab.com'] + ]) + await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([ + 'gitlab.com', + 'recovered.test' + ]) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not reuse a successful result after an SSH provider reconnects', async () => { + const connectionId = 'conn-reconnected' + registerSshGitProvider(connectionId, {} as never) + glabExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'Logged in to old-tunnel.test as user\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Logged in to new-tunnel.test as user\n', stderr: '' }) + + await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([ + 'gitlab.com', + 'old-tunnel.test' + ]) + registerSshGitProvider(connectionId, {} as never) + await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([ + 'gitlab.com', + 'new-tunnel.test' + ]) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) + unregisterSshGitProvider(connectionId) + }) }) diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index edc6d245733..cde9d4f9e01 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -214,7 +214,7 @@ describe('gitlab issue operations', () => { await listIssues('/repo-root', 5, undefined, 'opened', undefined, 'conn-7') - expect(getGlabKnownHostsMock).toHaveBeenCalledWith('conn-7') + expect(getGlabKnownHostsMock).toHaveBeenCalledWith('conn-7', {}) }) it('creates an issue and returns its iid + web_url', async () => { diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts index ec36f68a0f8..970f9ff1f03 100644 --- a/src/main/gitlab/issues.ts +++ b/src/main/gitlab/issues.ts @@ -44,7 +44,7 @@ export async function getIssue( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions) // Why: don't fall back to a cwd-inferred `glab issue view` when the project // can't be resolved — on an SSH connection cwd is not the repo dir, so glab @@ -92,7 +92,7 @@ export async function listIssues( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, @@ -162,7 +162,7 @@ export async function createIssue( if (!trimmedTitle) { return { ok: false, error: 'Title is required' } } - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, @@ -232,7 +232,7 @@ export async function updateIssue( await resolveIssueSource( repoPath, preference, - await getGlabKnownHosts(connectionId), + await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) @@ -369,7 +369,7 @@ export async function addIssueComment( await resolveIssueSource( repoPath, preference, - await getGlabKnownHosts(connectionId), + await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) @@ -427,7 +427,7 @@ export async function listLabels( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, @@ -468,7 +468,7 @@ export async function listAssignableUsers( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index d9f744c138f..0b37206d6bb 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -361,7 +361,7 @@ export async function getWorkItemDetails( await resolveIssueSource( repoPath, preference, - await getGlabKnownHosts(connectionId), + await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) diff --git a/src/main/index.ts b/src/main/index.ts index cc9fba24144..9278eb043ec 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,6 +14,7 @@ import { getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath } from './persistence' +import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' @@ -650,6 +651,13 @@ if (hasSingleInstanceLock) { // orca-dev in dev mode) but before app.setName('Orca') inside whenReady // (which would change the resolved path on case-sensitive filesystems). initDataPath() + // Why: the parse cache file must live under the canonical userData path + // captured above — late app.getPath('userData') can resolve differently + // across restarts, silently defeating persistence (reused=0 forever). + initSessionParseCachePersistence({ + filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), + appVersion: app.getVersion() + }) initOrcaProfilePaths() // Why: same timing constraint as initDataPath — capture the userData path // before app.setName changes it. See persistence.ts:20-28. @@ -1202,11 +1210,11 @@ function openMainWindow(): BrowserWindow { } } ) - agentHookServer.setPaneStatusClearListener((paneKey) => { + agentHookServer.setPaneStatusClearListener((clear) => { if (mainWindow?.isDestroyed()) { return } - mainWindow?.webContents.send('agentStatus:clear', { paneKey }) + mainWindow?.webContents.send('agentStatus:clear', clear) }) setMigrationUnsupportedPtyListener((event) => { if (mainWindow?.isDestroyed()) { diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index 49a39686a28..ff53b23374e 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -68,7 +68,8 @@ describe('registerMobileHandlers', () => { available: true, pairingUrl: 'orca://pair#mobile', endpoint: 'ws://100.102.47.57:6768', - deviceId: 'mobile-1' + deviceId: 'mobile-1', + connectionMode: 'automatic' }) const rpcServer = { createMobilePairingOffer } @@ -78,7 +79,9 @@ describe('registerMobileHandlers', () => { available: true, pairingUrl: 'orca://pair#mobile', endpoint: 'ws://100.102.47.57:6768', - deviceId: 'mobile-1' + deviceId: 'mobile-1', + // The encoded mode passes through so the UI can flag a degraded mint. + connectionMode: 'automatic' }) expect(createMobilePairingOffer).toHaveBeenCalledWith({ @@ -97,7 +100,8 @@ describe('registerMobileHandlers', () => { available: true, pairingUrl: 'orca://pair#local', endpoint: 'ws://192.168.1.24:6768', - deviceId: 'mobile-local' + deviceId: 'mobile-local', + connectionMode: 'local-only' }) registerMobileHandlers({ createMobilePairingOffer } as never) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 4cc09bc0b51..46c83f1234a 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -125,7 +125,11 @@ export function registerMobileHandlers( qrDataUrl, pairingUrl: offer.pairingUrl, endpoint: offer.endpoint, - deviceId: offer.deviceId + deviceId: offer.deviceId, + // Why: an automatic request can degrade to a local-only offer when + // Relay provisioning fails; the UI needs the encoded mode to avoid + // labeling a LAN-only code as Relay. + connectionMode: offer.connectionMode } } ) diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts index 77230f7300c..b1f089e385e 100644 --- a/src/main/ipc/pty-hidden-delivery-gate.ts +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -8,7 +8,6 @@ * the existing seq-guarded machinery. Any renderer party that still needs raw * bytes (dispatcher sidecars) registers delivery * interest, which suppresses the gate for that PTY. - * See docs/reference/terminal-side-effect-authority.md (Open Items). */ import type { GlobalSettings } from '../../shared/types' diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index f26dac754a0..5fddd31c1f8 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -1414,14 +1414,18 @@ describe('registerPtyHandlers', () => { // OpenCode plugin dir, Pi managed extension env, Codex home, and dev-mode CLI // overrides were silently missing for daemon users (the common case). - function setupDaemonAdapter(supportsGitCredentialGuardHost = true) { + function setupDaemonAdapter( + supportsGitCredentialGuardHost = true, + reportedWslDistro?: string | null + ) { const daemonSpawn = vi.fn( async (options: { env: Record sessionId?: string isNewSession?: boolean }) => ({ - id: options.sessionId ?? 'daemon-pty' + id: options.sessionId ?? 'daemon-pty', + ...(reportedWslDistro !== undefined ? { wslDistro: reportedWslDistro } : {}) }) ) setLocalPtyProvider({ @@ -1934,6 +1938,67 @@ describe('registerPtyHandlers', () => { }) }) + it('distinguishes an attached native context from an older daemon fallback', async () => { + await withWin32Platform(async () => { + _setWslCachesForTests({ available: true, distros: ['Ubuntu'] }) + const settings = { + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, + terminalWindowsShell: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + terminalWindowsPowerShellImplementation: 'auto' + } + const cases: { + reportedWslDistro: string | null | undefined + expectedWslDistro: string | null + sessionId: string + }[] = [ + { + reportedWslDistro: null, + expectedWslDistro: null, + sessionId: 'native-session' + }, + { + reportedWslDistro: undefined, + expectedWslDistro: 'Ubuntu', + sessionId: 'older-daemon-session' + } + ] + + for (const testCase of cases) { + setupDaemonAdapter(true, testCase.reportedWslDistro) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + preAllocateHandleForPty: vi.fn(), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + preparePtyExecutionContext: vi.fn().mockReturnValue(true) + } + handlers.clear() + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + (() => settings) as never + ) + + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: testCase.sessionId, + cwd: '\\\\server\\share\\repo' + }) + + expect(runtime.preparePtyExecutionContext).toHaveBeenLastCalledWith( + testCase.sessionId, + testCase.expectedWslDistro + ) + } + }) + }) + it('blocks runtime-created daemon PTYs when project WSL runtime requires repair', async () => { await withWin32Platform(async () => { _setWslCachesForTests({ available: true, distros: ['Debian'] }) @@ -2301,13 +2366,24 @@ describe('registerPtyHandlers', () => { listProcesses: vi.fn(async () => []), getForegroundProcess: vi.fn(async () => null) } as never) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + preAllocateHandleForPty: vi.fn(), + preparePtyExecutionContext: vi.fn().mockReturnValue(true) + } handlers.clear() - registerPtyHandlers(mainWindow as never) + registerPtyHandlers(mainWindow as never, runtime as never) await expect( handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, env: {} }) ).rejects.toThrow(/spawn boom/) expect(openCodeClearPtyMock).toHaveBeenCalled() expect(piClearPtyMock).toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).toHaveBeenLastCalledWith( + expect.any(String), + null, + { resetIncarnation: true } + ) }) it('does NOT sweep per-PTY state on provider.spawn failure for CALLER-supplied sessionId', async () => { @@ -2329,8 +2405,14 @@ describe('registerPtyHandlers', () => { listProcesses: vi.fn(async () => []), getForegroundProcess: vi.fn(async () => null) } as never) + const runtime = { + setPtyController: vi.fn(), + createPreAllocatedTerminalHandle: vi.fn(() => null), + preAllocateHandleForPty: vi.fn(), + preparePtyExecutionContext: vi.fn().mockReturnValue(true) + } handlers.clear() - registerPtyHandlers(mainWindow as never) + registerPtyHandlers(mainWindow as never, runtime as never) await expect( handlers.get('pty:spawn')!(null, { cols: 80, @@ -2341,6 +2423,11 @@ describe('registerPtyHandlers', () => { ).rejects.toThrow(/spawn boom/) expect(openCodeClearPtyMock).not.toHaveBeenCalled() expect(piClearPtyMock).not.toHaveBeenCalled() + expect(runtime.preparePtyExecutionContext).toHaveBeenLastCalledWith( + 'caller-owned-session', + null, + { resetIncarnation: true } + ) }) it('does NOT inject host-local env on SSH spawns (connectionId set)', async () => { @@ -10716,7 +10803,7 @@ describe('registerPtyHandlers', () => { worktreeId: 'repo-1::/tmp' }) - expect(result).toEqual({ id: expect.any(String), pid: 12345 }) + expect(result).toEqual({ id: expect.any(String), pid: 12345, wslDistro: null }) expect(spawnMock).toHaveBeenCalledTimes(1) expect(spawnMock).toHaveBeenCalledWith( '/bin/zsh', diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index fc8353d9b72..956383b7651 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -66,6 +66,7 @@ import { import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { createPtySpawnTiming } from './pty-spawn-timing' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' +import { resolveWslSessionContext } from '../daemon/wsl-session-context' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' @@ -3051,6 +3052,14 @@ export function registerPtyHandlers( const effectiveSessionAppId = sessionId !== undefined ? getAppPtyId(args.connectionId, sessionId) : undefined const isMintedSessionId = requestedSessionId === undefined && isDaemonHostSpawn + const expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd, + sessionId, + shellOverride: terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null const shouldPersistHostSessionBinding = args.persistHostSessionBinding === true let hostSessionBinding: { store: NonNullable @@ -3206,12 +3215,20 @@ export function registerPtyHandlers( ? reservePaneSpawn(materializedPaneKey) : null let result: PtySpawnResult + let preparedProvisionalExecutionContext = false try { try { if (args.preAllocatedHandle) { trustedTerminalHandleEnv.add(args.preAllocatedHandle) } const expectedPtyId = effectiveSessionAppId ?? sessionId + if (isDaemonHostSpawn && expectedPtyId) { + preparedProvisionalExecutionContext = + runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { + resetIncarnation: isMintedSessionId, + preserveExisting: !isMintedSessionId + }) ?? false + } const sequenceBeforeProviderSpawn = expectedPtyId ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) : 0 @@ -3223,7 +3240,20 @@ export function registerPtyHandlers( sequenceBeforeProviderSpawn ) } + runtime?.preparePtyExecutionContext?.( + result.id, + args.connectionId + ? null + : result.wslDistro === undefined + ? expectedWslDistro + : result.wslDistro + ) } catch (err) { + if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) { + runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { + resetIncarnation: true + }) + } const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) const isIdentityMismatch = @@ -3872,6 +3902,14 @@ export function registerPtyHandlers( effectiveSessionId !== undefined ? getRelayPtyId(args.connectionId, effectiveSessionId) : undefined + const expectedWslDistro = !args.connectionId + ? (resolveWslSessionContext({ + cwd, + sessionId: effectiveSessionId, + shellOverride: terminalRuntimeOptions.shellOverride, + terminalWindowsWslDistro: terminalRuntimeOptions.terminalWindowsWslDistro + })?.distro ?? null) + : null const startupTerminalColorQueryReplyColors = getStartupTerminalColorQueryReplyColors(args) // Why: the renderer sets pane env for SSH too. Only forward it to the // remote when the relay hook path is enabled; otherwise a newer relay @@ -4175,6 +4213,7 @@ export function registerPtyHandlers( markHiddenRendererPty(preSpawnHiddenMarkId) } let result: PtySpawnResult + let preparedProvisionalExecutionContext = false try { try { if (preAllocatedHandle) { @@ -4182,6 +4221,13 @@ export function registerPtyHandlers( } spawnTiming.mark('options') const expectedPtyId = effectiveSessionAppId ?? effectiveSessionId + if (isDaemonHostSpawn && expectedPtyId) { + preparedProvisionalExecutionContext = + runtime?.preparePtyExecutionContext?.(expectedPtyId, expectedWslDistro, { + resetIncarnation: isMintedSessionId, + preserveExisting: !isMintedSessionId + }) ?? false + } const sequenceBeforeProviderSpawn = expectedPtyId ? (runtime?.getPtyOutputSequence?.(expectedPtyId) ?? 0) : 0 @@ -4193,8 +4239,21 @@ export function registerPtyHandlers( sequenceBeforeProviderSpawn ) } + runtime?.preparePtyExecutionContext?.( + result.id, + args.connectionId + ? null + : result.wslDistro === undefined + ? expectedWslDistro + : result.wslDistro + ) spawnTiming.mark('provider_spawn') } catch (err) { + if ((isMintedSessionId || preparedProvisionalExecutionContext) && effectiveSessionAppId) { + runtime?.preparePtyExecutionContext?.(effectiveSessionAppId, null, { + resetIncarnation: true + }) + } // Why: a failed spawn must not leave a stale hidden mark on a session // id a later visible attach may reuse. if (preSpawnHiddenMarkId !== null) { diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index 91c449f751f..4a412f2494d 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -3,15 +3,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, discoverSkillsMock, + discoverSkillsInWslMock, inventorySkillFreshnessMock, getDefaultWslDistroMock, - getWslHomeMock + getWslHomeMock, + parseWslPathMock } = vi.hoisted(() => ({ handleMock: vi.fn(), discoverSkillsMock: vi.fn(), + discoverSkillsInWslMock: vi.fn(), inventorySkillFreshnessMock: vi.fn(), getDefaultWslDistroMock: vi.fn(), - getWslHomeMock: vi.fn() + getWslHomeMock: vi.fn(), + parseWslPathMock: vi.fn() })) vi.mock('electron', () => ({ @@ -27,13 +31,27 @@ vi.mock('../skills/discovery', () => ({ discoverSkills: discoverSkillsMock })) +vi.mock('../skills/skill-discovery-wsl', () => ({ + discoverSkillsInWsl: discoverSkillsInWslMock +})) + vi.mock('../skills/skill-freshness-inventory', () => ({ inventorySkillFreshness: inventorySkillFreshnessMock })) vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock, - getWslHome: getWslHomeMock + getWslHome: getWslHomeMock, + parseWslPath: parseWslPathMock, + toLinuxPath: (pathValue: string) => { + if (pathValue === '\\\\wsl.localhost\\Ubuntu\\home\\alice') { + return '/home/alice' + } + if (pathValue === 'C:\\repo\\worktree') { + return '/mnt/c/repo/worktree' + } + return pathValue + } })) import { registerSkillsHandlers } from './skills' @@ -48,9 +66,13 @@ describe('registerSkillsHandlers', () => { beforeEach(() => { handleMock.mockReset() discoverSkillsMock.mockReset() + discoverSkillsInWslMock.mockReset() getDefaultWslDistroMock.mockReset() getWslHomeMock.mockReset() + parseWslPathMock.mockReset() + parseWslPathMock.mockReturnValue(null) discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) + discoverSkillsInWslMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) inventorySkillFreshnessMock.mockResolvedValue({ schemaVersion: 1, installations: [], @@ -139,10 +161,35 @@ describe('registerSkillsHandlers', () => { expect(getDefaultWslDistroMock).not.toHaveBeenCalled() expect(getWslHomeMock).toHaveBeenCalledWith('Ubuntu') - expect(discoverSkillsMock).toHaveBeenCalledWith({ - repos: [], - homeDir: '\\\\wsl.localhost\\Ubuntu\\home\\alice', - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice' + expect(discoverSkillsInWslMock).toHaveBeenCalledWith({ + distro: 'Ubuntu', + homeDir: '/home/alice', + cwd: '/home/alice' + }) + }) + + it('scans the requested project directory in the selected WSL runtime', async () => { + const handler = getDiscoverHandler() + + await handler(null, { + cwd: 'C:\\repo\\worktree', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } + }) + + expect(discoverSkillsInWslMock).toHaveBeenCalledWith({ + distro: 'Ubuntu', + homeDir: '/home/alice', + cwd: '/mnt/c/repo/worktree' }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index 711162927fe..c349af8b24e 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -1,60 +1,23 @@ import { app, ipcMain } from 'electron' import type { Store } from '../persistence' -import { discoverSkills } from '../skills/discovery' -import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import { + SkillDiscoveryTargetSchema, + type SkillDiscoveryResult, + type SkillDiscoveryTarget +} from '../../shared/skills' import type { SkillFreshnessInventory } from '../../shared/skill-freshness' -import { getDefaultWslDistro, getWslHome } from '../wsl' import { inventorySkillFreshness } from '../skills/skill-freshness-inventory' - -type SkillDiscoveryRuntimeTarget = - | { runtime: 'host' } - | { runtime: 'wsl'; wslDistro: string | null | undefined } - -function getSkillDiscoveryRuntimeTarget( - target: SkillDiscoveryTarget | undefined -): SkillDiscoveryRuntimeTarget { - const projectRuntime = target?.projectRuntime - if (!projectRuntime) { - return target?.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: target.wslDistro } - : { runtime: 'host' } - } - - if (projectRuntime.status === 'repair-required') { - throw new Error( - `Project runtime requires repair before skill discovery: ${projectRuntime.repair.reason}` - ) - } - - if (projectRuntime.runtime.kind === 'wsl') { - return { runtime: 'wsl', wslDistro: projectRuntime.runtime.distro } - } - - return { runtime: 'host' } -} +import { + discoverSkillsOnTarget, + resolveSkillDiscoveryTarget +} from '../skills/skill-discovery-target' export function registerSkillsHandlers(store: Store): void { ipcMain.handle( 'skills:discover', async (_event, target?: SkillDiscoveryTarget): Promise => { - const runtimeTarget = getSkillDiscoveryRuntimeTarget(target) - if (runtimeTarget.runtime === 'wsl') { - if (process.platform !== 'win32') { - throw new Error('WSL skill discovery is only available on Windows.') - } - const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - throw new Error('No WSL distribution is available for skill discovery.') - } - const homeDir = getWslHome(distro) - if (!homeDir) { - throw new Error(`Could not resolve the WSL home directory for ${distro}.`) - } - return discoverSkills({ repos: [], homeDir, cwd: homeDir }) - } - - const cwd = target?.cwd?.trim() || undefined - return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() }) + const parsedTarget = target ? SkillDiscoveryTargetSchema.parse(target) : undefined + return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(parsedTarget), store.getRepos()) } ) diff --git a/src/main/keybindings/keybinding-file.test.ts b/src/main/keybindings/keybinding-file.test.ts index 57a000c3217..4fd295d4c50 100644 --- a/src/main/keybindings/keybinding-file.test.ts +++ b/src/main/keybindings/keybinding-file.test.ts @@ -370,13 +370,17 @@ describe('keybinding-file', () => { it('throws instead of freezing the seed when a legacy binding fails normalization', () => { // A dropped pin must not be silent: the service catches the throw and keeps - // the cohort pending so a corrected build can retry the seed. + // the cohort pending so a corrected build can retry the seed. Valid pins + // from the same batch are still written so users keep the good shortcuts. expect(() => seedLegacyTabSwitchBindings(filePath, 'darwin', { - 'tab.nextSameType': ['J'] + 'tab.nextSameType': ['J'], + 'tab.previousSameType': ['Mod+Shift+BracketLeft'] }) ).toThrow(/normalize/i) - expect(readKeybindingFile(filePath, 'darwin').platformOverrides.darwin).toEqual({}) + expect(readKeybindingFile(filePath, 'darwin').platformOverrides.darwin).toEqual({ + 'tab.previousSameType': ['Mod+Shift+BracketLeft'] + }) }) it('does not replace an unreadable keybindings file while seeding', () => { diff --git a/src/main/keybindings/keybinding-file.ts b/src/main/keybindings/keybinding-file.ts index b07e8759e72..4d41a42ba87 100644 --- a/src/main/keybindings/keybinding-file.ts +++ b/src/main/keybindings/keybinding-file.ts @@ -353,26 +353,31 @@ export function seedLegacyTabSwitchBindings( return { seeded: false, snapshot: current } } - // Why: never freeze the one-shot after dropping a pin — a normalization - // failure must throw so the cohort stays pending and a fixed build retries. - const pins = toSeed.map((actionId) => { + // Why: seed every pin that normalizes, but never freeze the one-shot if any + // pin was dropped — throw after writing good pins so the cohort stays pending + // and a fixed build retries the failed action without wiping the others. + const pins: (readonly [KeybindingActionId, string[]])[] = [] + const failedActionIds: KeybindingActionId[] = [] + for (const actionId of toSeed) { const normalized = normalizeKeybindingArrayForAction(actionId, legacyBindings[actionId] ?? []) if (!Array.isArray(normalized)) { - throw new Error(`Could not normalize legacy binding for "${actionId}".`) + failedActionIds.push(actionId) + continue } - return [actionId, normalized] as const - }) - const snapshot = writeActivePlatformSection( - path, - platform, - current.commonOverrides, - (activePlatform) => { - for (const [actionId, normalized] of pins) { - activePlatform[actionId] = normalized - } - } - ) - return { seeded: true, snapshot } + pins.push([actionId, normalized]) + } + const snapshot = + pins.length > 0 + ? writeActivePlatformSection(path, platform, current.commonOverrides, (activePlatform) => { + for (const [actionId, normalized] of pins) { + activePlatform[actionId] = normalized + } + }) + : current + if (failedActionIds.length > 0) { + throw new Error(`Could not normalize legacy binding for "${failedActionIds.join('", "')}".`) + } + return { seeded: pins.length > 0, snapshot } } // Why: the one-shot seed migration and Settings writes must produce the same diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 90e5c50d8e2..3eaa8c557d6 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -12,6 +12,7 @@ const { spawnMock, prepareMacosTccLoginShellMock, resolveAgentForegroundProcessMock, + readWindowsConptyProcessIdsMock, captureDescendantSnapshotMock, terminateDescendantSnapshotMock } = vi.hoisted(() => ({ @@ -23,6 +24,7 @@ const { spawnMock: vi.fn(), prepareMacosTccLoginShellMock: vi.fn(), resolveAgentForegroundProcessMock: vi.fn(), + readWindowsConptyProcessIdsMock: vi.fn(), captureDescendantSnapshotMock: vi.fn(), terminateDescendantSnapshotMock: vi.fn() })) @@ -79,6 +81,10 @@ vi.mock('./agent-foreground-process', () => ({ resolveAgentForegroundProcessMock(...args) })) +vi.mock('./windows-conpty-process-membership', () => ({ + readWindowsConptyProcessIds: (...args: unknown[]) => readWindowsConptyProcessIdsMock(...args) +})) + vi.mock('../wsl', () => ({ parseWslPath: (path: string) => { const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/) @@ -156,6 +162,8 @@ describe('LocalPtyProvider', () => { processName: fallbackProcess }) ) + readWindowsConptyProcessIdsMock.mockReset() + readWindowsConptyProcessIdsMock.mockResolvedValue(null) exitCb = undefined mockProc = { @@ -220,11 +228,63 @@ describe('LocalPtyProvider', () => { const second = await provider.spawn({ cols: 120, rows: 40, sessionId: first.id }) - expect(second).toEqual({ id: 'serve-session-1', pid: 12345, isReattach: true }) + expect(second).toEqual({ + id: 'serve-session-1', + pid: 12345, + wslDistro: null, + isReattach: true + }) expect(mockProc.resize).toHaveBeenCalledWith(120, 40) expect(spawnMock).not.toHaveBeenCalled() }) + it('keeps a native UNC session native on a conflicting WSL reattach', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const first = await provider.spawn({ + cols: 80, + rows: 24, + sessionId: 'native-session', + cwd: '\\\\server\\share\\repo', + shellOverride: 'powershell.exe' + }) + spawnMock.mockClear() + + const second = await provider.spawn({ + cols: 120, + rows: 40, + sessionId: first.id, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu' + }) + + expect(first.wslDistro).toBeNull() + expect(second.wslDistro).toBeNull() + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('keeps the first WSL distro on a conflicting distro reattach', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const first = await provider.spawn({ + cols: 80, + rows: 24, + sessionId: 'wsl-session', + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' + }) + spawnMock.mockClear() + + const second = await provider.spawn({ + cols: 120, + rows: 40, + sessionId: first.id, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Debian' + }) + + expect(first.wslDistro).toBe('Ubuntu') + expect(second.wslDistro).toBe('Ubuntu') + expect(spawnMock).not.toHaveBeenCalled() + }) + it('does not reattach numeric caller session ids that can collide after restart', async () => { const first = await provider.spawn({ cols: 80, rows: 24 }) spawnMock.mockClear() @@ -1427,6 +1487,68 @@ describe('LocalPtyProvider', () => { await expect(foreground).resolves.toBeNull() }) + + it('confirms a still-active agent from ConPTY console presence without a whole-table scan', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mockProc.process = 'powershell.exe' + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: true, + processName: 'claude' + }) + // A child beyond the shell is still attached to this console. + readWindowsConptyProcessIdsMock.mockResolvedValue(new Set([12345, 999])) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + // First call establishes the agent identity via the scan. + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + // node-pty still only names the shell, but console presence confirms the + // agent — no second whole-table scan. + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1) + }) + + it('falls through to the scan when the ConPTY console shows only the shell', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mockProc.process = 'powershell.exe' + resolveAgentForegroundProcessMock.mockResolvedValue({ + available: true, + processName: 'claude' + }) + readWindowsConptyProcessIdsMock.mockResolvedValue(new Set([12345])) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) + }) + + it('keeps the cached agent when both the console probe and process snapshot are inconclusive', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mockProc.process = 'powershell.exe' + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readWindowsConptyProcessIdsMock.mockResolvedValue(null) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) + }) + + it('retires the cached agent after verified shell-only membership and a no-agent scan', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + mockProc.process = 'powershell.exe' + resolveAgentForegroundProcessMock + .mockResolvedValueOnce({ available: true, processName: 'claude' }) + .mockResolvedValue({ available: true, processName: null }) + readWindowsConptyProcessIdsMock.mockResolvedValue(new Set([12345])) + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + + await expect(provider.getForegroundProcess(id)).resolves.toBe('claude') + await expect(provider.getForegroundProcess(id)).resolves.toBeNull() + expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(2) + }) }) describe('confirmForegroundProcess', () => { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 5efe7aeb6c8..4cc456bd799 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -61,6 +61,7 @@ import { terminateDescendantSnapshot } from '../pty-descendant-termination' import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' +import { canConfirmAgentFromConsolePresence } from './windows-console-foreground' import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups' import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery' import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd' @@ -103,6 +104,9 @@ const ptyAgentForegroundContextPaths = new Map() const ptyLastRecognizedForeground = new Map() const ptyTerminalHandle = new Map() const ptyInitialCwd = new Map() +// Why: reattach requests carry current settings, not the live process's launch +// context. Keep the first creator's WSL/native identity for the PTY incarnation. +const ptyWslDistroById = new Map() // Why: node-pty callbacks must be disposed before environment teardown, but // onExit separately owns physical process-exit proof during termination. const ptyDisposables = new Map void }[]>() @@ -246,6 +250,7 @@ function clearPtyState(id: string): void { ptyLastRecognizedForeground.delete(id) ptyTerminalHandle.delete(id) ptyInitialCwd.delete(id) + ptyWslDistroById.delete(id) ptyLoadGeneration.delete(id) ptyTerminationMode.delete(id) ptyPhysicalExits.delete(id) @@ -531,12 +536,18 @@ export class LocalPtyProvider implements IPtyProvider { } const existing = ptyProcesses.get(reattachId) if (existing) { + const existingWslDistro = ptyWslDistroById.get(reattachId) try { existing.resize(args.cols, args.rows) } catch { /* Existing PTY may reject resize during teardown; still return the live handle. */ } - return { id: reattachId, pid: existing.pid, isReattach: true } + return { + id: reattachId, + pid: existing.pid, + ...(ptyWslDistroById.has(reattachId) ? { wslDistro: existingWslDistro ?? null } : {}), + isReattach: true + } } } const id = allocatePtyId(reattachId ?? undefined) @@ -884,9 +895,15 @@ export class LocalPtyProvider implements IPtyProvider { } const proc = spawnResult.process + const spawnedShellIsWsl = + process.platform === 'win32' && pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe' + const spawnedWslDistro = spawnedShellIsWsl ? (launchWslDistro ?? undefined) : null createPtyPhysicalExit(id) ptyProcesses.set(id, proc) ptyInitialCwd.set(id, cwd) + if (spawnedWslDistro !== undefined) { + ptyWslDistroById.set(id, spawnedWslDistro) + } // Why both signals: launchAgent is the caller's explicit intent and // survives command rewriting (e.g. auth env prefixes); recognition covers // callers that pass a bare agent command line without the flag. @@ -1065,7 +1082,11 @@ export class LocalPtyProvider implements IPtyProvider { // briefly 0/undefined if node-pty hasn't observed the forked child yet. const rawPid = proc.pid const pid = typeof rawPid === 'number' && Number.isFinite(rawPid) && rawPid > 0 ? rawPid : null - return { id, pid } + return { + id, + pid, + ...(spawnedWslDistro !== undefined ? { wslDistro: spawnedWslDistro } : {}) + } } // Local PTYs are always attached -- no-op. Remote providers use this to resubscribe. @@ -1273,10 +1294,35 @@ export class LocalPtyProvider implements IPtyProvider { ptyLastRecognizedForeground.delete(id) return null } + const fallbackProcess = resolveForegroundFallbackProcess( + proc.process || null, + ptyShellName.get(id) + ) + const cachedAgent = ptyLastRecognizedForeground.get(id) ?? null + let consoleMembershipUnavailable = false + // Why: exact console membership can preserve a live cached agent without + // trusting the whole-table scan that becomes incomplete under Windows load. + if ( + process.platform === 'win32' && + canConfirmAgentFromConsolePresence(cachedAgent, fallbackProcess) + ) { + try { + const consoleProcessIds = await readWindowsConptyProcessIds(proc.pid) + if (ptyProcesses.get(id) !== proc) { + return null + } + if (consoleProcessIds !== null && consoleProcessIds.size > 1 && cachedAgent !== null) { + return cachedAgent + } + consoleMembershipUnavailable = consoleProcessIds === null + } catch { + consoleMembershipUnavailable = true + } + } try { const resolution = await resolveAgentForegroundProcessWithAvailability( proc.pid, - resolveForegroundFallbackProcess(proc.process || null, ptyShellName.get(id)), + fallbackProcess, { contextPaths: ptyAgentForegroundContextPaths.get(id) } @@ -1290,9 +1336,17 @@ export class LocalPtyProvider implements IPtyProvider { // foreground — the completion coordinator reads that as an exit and fires // a false "agent done" while the agent is still working. Prefer the last // recognized agent across a transient failure (e.g. a Windows CIM timeout). + const lastRecognizedAgent = ptyLastRecognizedForeground.get(id) ?? null + const resolvedAgent = resolution.processName + ? recognizeAgentProcessFromCommandLine(resolution.processName) + : null + // Why: an incomplete global snapshot plus an unavailable console probe is + // not exit proof; only verified shell-only membership may clear the cache. const stable = resolveStableForegroundProcess( - resolution, - ptyLastRecognizedForeground.get(id) ?? null + consoleMembershipUnavailable && resolvedAgent === null + ? { ...resolution, available: false } + : resolution, + lastRecognizedAgent ) if (stable.lastRecognizedAgent) { ptyLastRecognizedForeground.set(id, stable.lastRecognizedAgent) diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts new file mode 100644 index 00000000000..5a126e7e77d --- /dev/null +++ b/src/main/providers/pty-spawn-result.ts @@ -0,0 +1,59 @@ +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { TuiAgent } from '../../shared/types' + +export type PtySpawnResult = { + /** App-facing PTY id. Remote providers must return globally routable ids, + * not relay-local handles, because renderer/runtime IPC routes by this key. */ + id: string + /** OS-level pid of the shell process, when available at spawn time. + * Why: the memory collector needs this to walk each PTY's process + * subtree. Daemon-backed providers return it from the RPC result; + * local providers read it from node-pty. Null when the underlying + * provider could not publish a pid (e.g., race during spawn). */ + pid?: number | null + /** Minimal allowlisted launch ownership returned by daemon reattach. */ + launchAgent?: TuiAgent + /** Local WSL context: null is native; undefined is unavailable/legacy. */ + wslDistro?: string | null + /** ANSI snapshot of the terminal screen, present when reattaching to an + * existing daemon session. Write this to xterm.js to restore visual state. */ + snapshot?: string + /** Dimensions the snapshot was captured at. Resize xterm.js to these before + * writing the snapshot so ANSI cursor positions land correctly. */ + snapshotCols?: number + snapshotRows?: number + /** Provider sequence at the attach boundary. `reset` starts a new provider + * generation; `continued` resumes the existing absolute domain. */ + providerSequence?: { + value: number + generation: 'continued' | 'reset' + } + /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the + * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags + * (terminal-query-authority.md §kitty). Never replayed into a renderer + * xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */ + snapshotKittyKeyboardFlags?: number + /** True when the spawn reattached to an existing daemon session. */ + isReattach?: boolean + /** True when the reattached session uses the alternate screen buffer + * (e.g., Codex CLI, vim). Normal-screen TUIs like Claude Code are false. */ + isAlternateScreen?: boolean + /** Buffered output returned by relay pty.attach. Unlike snapshot, this is + * incremental scrollback and must not clear the terminal before replay. */ + replay?: string + /** True when the caller requested reattach (sessionId was provided) but the + * relay PTY was gone (grace window elapsed). The renderer uses this to show + * a brief "Session expired — new shell started" message. */ + sessionExpired?: boolean + /** Present when cold-restoring from disk history after a daemon crash. + * Contains the saved scrollback and CWD. The new shell spawns in the + * saved CWD; the scrollback is written to xterm.js as read-only history. */ + coldRestore?: { + scrollback: string + cwd: string + /** Optional for compatibility with restore payloads from older app code. */ + cols?: number + rows?: number + oscLinks?: TerminalOscLinkRange[] + } +} diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index ac6169d0bd4..ea2ea2c57dc 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -25,6 +25,7 @@ import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { GitProviderStatusOptions } from './git-provider-status-options' import type { PtyBackgroundStreamEvent, PtyDataEvent } from './pty-provider-events' +import type { PtySpawnResult } from './pty-spawn-result' export type { PtyBackgroundStreamEvent, @@ -95,60 +96,7 @@ export type PtySpawnOptions = { startupIngress?: PtyStartupIngressIntent } -export type PtySpawnResult = { - /** App-facing PTY id. Remote providers must return globally routable ids, - * not relay-local handles, because renderer/runtime IPC routes by this key. */ - id: string - /** OS-level pid of the shell process, when available at spawn time. - * Why: the memory collector needs this to walk each PTY's process - * subtree. Daemon-backed providers return it from the RPC result; - * local providers read it from node-pty. Null when the underlying - * provider could not publish a pid (e.g., race during spawn). */ - pid?: number | null - /** Minimal allowlisted launch ownership returned by daemon reattach. */ - launchAgent?: TuiAgent - /** ANSI snapshot of the terminal screen, present when reattaching to an - * existing daemon session. Write this to xterm.js to restore visual state. */ - snapshot?: string - /** Dimensions the snapshot was captured at. Resize xterm.js to these before - * writing the snapshot so ANSI cursor positions land correctly. */ - snapshotCols?: number - snapshotRows?: number - /** Provider sequence at the attach boundary. `reset` starts a new provider - * generation; `continued` resumes the existing absolute domain. */ - providerSequence?: { - value: number - generation: 'continued' | 'reset' - } - /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the - * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags - * (terminal-query-authority.md §kitty). Never replayed into a renderer - * xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */ - snapshotKittyKeyboardFlags?: number - /** True when the spawn reattached to an existing daemon session. */ - isReattach?: boolean - /** True when the reattached session uses the alternate screen buffer - * (e.g., Codex CLI, vim). Normal-screen TUIs like Claude Code are false. */ - isAlternateScreen?: boolean - /** Buffered output returned by relay pty.attach. Unlike snapshot, this is - * incremental scrollback and must not clear the terminal before replay. */ - replay?: string - /** True when the caller requested reattach (sessionId was provided) but the - * relay PTY was gone (grace window elapsed). The renderer uses this to show - * a brief "Session expired — new shell started" message. */ - sessionExpired?: boolean - /** Present when cold-restoring from disk history after a daemon crash. - * Contains the saved scrollback and CWD. The new shell spawns in the - * saved CWD; the scrollback is written to xterm.js as read-only history. */ - coldRestore?: { - scrollback: string - cwd: string - /** Optional for compatibility with restore payloads from older app code. */ - cols?: number - rows?: number - oscLinks?: TerminalOscLinkRange[] - } -} +export type { PtySpawnResult } export type PtyProcessInfo = { id: string diff --git a/src/main/providers/windows-conpty-process-membership.test.ts b/src/main/providers/windows-conpty-process-membership.test.ts index e9e2ef09fdd..9ae23b6f379 100644 --- a/src/main/providers/windows-conpty-process-membership.test.ts +++ b/src/main/providers/windows-conpty-process-membership.test.ts @@ -2,9 +2,15 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { readWindowsConptyProcessIds } from './windows-conpty-process-membership' -function forkWith(event: 'message' | 'error' | 'none', value?: unknown) { - const child = new EventEmitter() as EventEmitter & { kill: ReturnType } +function forkWith(event: 'message' | 'error' | 'none', value?: unknown, pid?: number) { + const child = new EventEmitter() as EventEmitter & { + kill: ReturnType + pid?: number + } child.kill = vi.fn() + if (pid !== undefined) { + child.pid = pid + } const forkProcess = vi.fn(() => { queueMicrotask(() => { if (event === 'message') { @@ -20,7 +26,7 @@ function forkWith(event: 'message' | 'error' | 'none', value?: unknown) { describe('readWindowsConptyProcessIds', () => { it('returns exact console membership from the fixed node-pty helper', async () => { - const { forkProcess } = forkWith('message', [101, 202, 303]) + const { forkProcess } = forkWith('message', [999, 101, 202, 303], 999) await expect( readWindowsConptyProcessIds(101, { @@ -36,14 +42,28 @@ describe('readWindowsConptyProcessIds', () => { }) it.each([ - ['root-only failure fallback', [101]], - ['malformed response', [101, '202']], - ['missing PTY root', [202, 303]] - ])('fails closed for %s', async (_label, processIds) => { - const { forkProcess } = forkWith('message', processIds) + ['root-only failure fallback', [101], 999], + ['malformed response', [999, 101, '202'], 999], + ['missing PTY root', [999, 202, 303], 999], + ['missing helper pid', [101, 202], 999], + ['unavailable helper pid', [101, 202], undefined] + ])('fails closed for %s', async (_label, processIds, helperPid) => { + const { forkProcess } = forkWith('message', processIds, helperPid) await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toBeNull() }) + it('returns root-only membership when only the helper and shell are attached', async () => { + const { forkProcess } = forkWith('message', [999, 101], 999) + await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toEqual(new Set([101])) + }) + + it('reports membership excluding the helper when a real child is attached', async () => { + const { forkProcess } = forkWith('message', [999, 101, 202], 999) + await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toEqual( + new Set([101, 202]) + ) + }) + it('handles helper spawn errors without an unhandled child error', async () => { const { forkProcess } = forkWith('error') await expect(readWindowsConptyProcessIds(101, { forkProcess })).resolves.toBeNull() @@ -61,4 +81,20 @@ describe('readWindowsConptyProcessIds', () => { vi.useRealTimers() } }) + + it('absorbs an asynchronous kill error after timeout settlement', async () => { + vi.useFakeTimers() + try { + const { child, forkProcess } = forkWith('none') + child.kill.mockImplementation(() => { + queueMicrotask(() => child.emit('error', new Error('kill failed'))) + }) + const result = readWindowsConptyProcessIds(101, { forkProcess, timeoutMs: 10 }) + await vi.advanceTimersByTimeAsync(10) + await expect(result).resolves.toBeNull() + expect(child.listenerCount('error')).toBe(0) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/providers/windows-conpty-process-membership.ts b/src/main/providers/windows-conpty-process-membership.ts index 759a234c63f..28e29ebbd97 100644 --- a/src/main/providers/windows-conpty-process-membership.ts +++ b/src/main/providers/windows-conpty-process-membership.ts @@ -15,8 +15,8 @@ function resolveNodePtyConsoleListAgent(): string { } /** - * Runs node-pty's fixed native console-list helper with bounded error/exit - * handling. A root-only result is its failure fallback, not shell proof. + * Returns normalized console membership, or null when the probe is unavailable. + * A root-only set proves the shell is alone because successful raw results include the helper. */ export function readWindowsConptyProcessIds( rootPid: number, @@ -45,23 +45,33 @@ export function readWindowsConptyProcessIds( settled = true clearTimeout(timeout) child.removeListener('message', onMessage) - child.removeListener('error', onFailure) - child.removeListener('exit', onFailure) + // Why: kill failures can emit asynchronously after timeout settlement; + // teardown listeners stay until exit so they cannot crash the daemon. resolve(value) } const onFailure = (): void => finish(null) + const onExit = (): void => { + child.removeListener('error', onFailure) + finish(null) + } const onMessage = (message: ProcessListMessage): void => { const value = message?.consoleProcessList + const helperPid = child.pid if ( !Array.isArray(value) || - value.length <= 1 || + helperPid === undefined || !value.includes(rootPid) || + !value.includes(helperPid) || value.some((pid) => !Number.isSafeInteger(pid) || pid <= 0) ) { finish(null) return } - finish(new Set(value)) + // Why: GetConsoleProcessList includes this helper; removing it makes a + // root-only set authoritative shell-only evidence instead of a false child. + const consoleProcessIds = new Set(value) + consoleProcessIds.delete(helperPid) + finish(consoleProcessIds) } const timeout = setTimeout(() => { child.kill() @@ -69,6 +79,6 @@ export function readWindowsConptyProcessIds( }, deps.timeoutMs ?? CONPTY_PROCESS_LIST_TIMEOUT_MS) child.once('message', onMessage) child.once('error', onFailure) - child.once('exit', onFailure) + child.once('exit', onExit) }) } diff --git a/src/main/providers/windows-console-foreground.test.ts b/src/main/providers/windows-console-foreground.test.ts new file mode 100644 index 00000000000..f13ef2ae556 --- /dev/null +++ b/src/main/providers/windows-console-foreground.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { canConfirmAgentFromConsolePresence } from './windows-console-foreground' + +describe('canConfirmAgentFromConsolePresence', () => { + it('is true for a cached agent when node-pty only names the shell (a scan would run)', () => { + expect(canConfirmAgentFromConsolePresence('claude', 'powershell.exe')).toBe(true) + expect(canConfirmAgentFromConsolePresence('codex', 'cmd.exe')).toBe(true) + }) + + it('is false when node-pty already names a recognized agent (no scan needed)', () => { + // Nothing to save here — the fast no-scan path already returns the agent. + expect(canConfirmAgentFromConsolePresence('claude', 'claude')).toBe(false) + }) + + it('is false for a generic wrapper that may outlive the cached agent', () => { + expect(canConfirmAgentFromConsolePresence('claude', 'node.exe')).toBe(false) + }) + + it('is false when no agent has been recognized yet (identity must be established first)', () => { + expect(canConfirmAgentFromConsolePresence(null, 'powershell.exe')).toBe(false) + }) + + it('is false when there is no fallback process name', () => { + expect(canConfirmAgentFromConsolePresence('claude', null)).toBe(false) + }) +}) diff --git a/src/main/providers/windows-console-foreground.ts b/src/main/providers/windows-console-foreground.ts new file mode 100644 index 00000000000..77b5bfe4f6d --- /dev/null +++ b/src/main/providers/windows-console-foreground.ts @@ -0,0 +1,15 @@ +import { isShellProcess } from '../../shared/shell-process-detection' + +/** Whether ConPTY membership can revalidate a cached agent without a process scan. */ +export function canConfirmAgentFromConsolePresence( + cachedAgentName: string | null, + fallbackProcess: string | null +): boolean { + return ( + cachedAgentName !== null && + fallbackProcess !== null && + // Why: a generic wrapper may outlive the agent; only the shell fallback is + // the known unreliable Windows exit signal this cache is allowed to bridge. + isShellProcess(fallbackProcess) + ) +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 44bb1117c43..a3f6e0b2cc0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -193,6 +193,7 @@ const { getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, getPRForBranchMock, + getPRForBranchOutcomeMock, getRepoSlugMock, getRepoUpstreamMock, getGitHubWorkItemMock, @@ -298,6 +299,7 @@ const { getHostedReviewCreationEligibilityMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), getPRForBranchMock: vi.fn().mockResolvedValue(null), + getPRForBranchOutcomeMock: vi.fn().mockResolvedValue({ kind: 'no-pr', fetchedAt: 0 }), getRepoSlugMock: vi.fn().mockResolvedValue(null), getRepoUpstreamMock: vi.fn().mockResolvedValue(null), getGitHubWorkItemMock: vi.fn(), @@ -463,6 +465,7 @@ vi.mock('../github/client', async (importOriginal) => { return { ...actual, getPRForBranch: getPRForBranchMock, + getPRForBranchOutcome: getPRForBranchOutcomeMock, getRepoSlug: getRepoSlugMock, getRepoUpstream: getRepoUpstreamMock, getWorkItem: getGitHubWorkItemMock, @@ -671,6 +674,8 @@ function resetRuntimeTestMocks(): void { getHostedReviewForBranchMock.mockResolvedValue(null) getPRForBranchMock.mockReset() getPRForBranchMock.mockResolvedValue(null) + getPRForBranchOutcomeMock.mockReset() + getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: 0 }) getRepoSlugMock.mockReset() getRepoSlugMock.mockResolvedValue(null) getRepoUpstreamMock.mockReset() @@ -5572,9 +5577,16 @@ describe('OrcaRuntimeService', () => { prRepo }) - expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/wsl', 42, null, null, { - localGitExecOptions: localGitOptions - }) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'feature/wsl', + 42, + null, + null, + { + localGitExecOptions: localGitOptions + } + ) expect(getGitHubWorkItemMock).toHaveBeenCalledWith( TEST_REPO_PATH, 42, @@ -8648,6 +8660,7 @@ describe('OrcaRuntimeService', () => { it('keeps SSH OSC7 cwd POSIX when the desktop runtime is on Windows', async () => { setPlatform('win32') const runtime = new OrcaRuntimeService(store) + runtime.preparePtyExecutionContext('pty-ssh', 'Ubuntu', { resetIncarnation: true }) runtime.registerPty('pty-ssh', TEST_WORKTREE_ID, 'ssh-conn-1') runtime.onPtyData('pty-ssh', '\x1b]7;file://remote-host/home/me/repo/src\x07', 123) @@ -8655,9 +8668,164 @@ describe('OrcaRuntimeService', () => { const internals = runtime as unknown as { terminalCwdByPtyId: Map terminalFileUriHostnameByPtyId: Map + wslDistroByPtyId: Map } expect(internals.terminalCwdByPtyId.get('pty-ssh')).toBe('/home/me/repo/src') expect(internals.terminalFileUriHostnameByPtyId.get('pty-ssh')).toBe('remote-host') + expect(internals.wslDistroByPtyId.has('pty-ssh')).toBe(false) + }) + + it('uses per-incarnation WSL context before registration and across simultaneous distros', () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + runtime.preparePtyExecutionContext('pty-ubuntu', 'Ubuntu', { resetIncarnation: true }) + runtime.preparePtyExecutionContext('pty-debian', 'Debian', { resetIncarnation: true }) + runtime.registerPty('pty-ubuntu', TEST_WORKTREE_ID) + runtime.registerPty('pty-debian', TEST_WORKTREE_ID) + + runtime.onPtyData('pty-ubuntu', '\x1b]7;file://DESKTOP/home/me/repo\x07', 1) + runtime.onPtyData('pty-debian', '\x1b]7;file://DESKTOP/home/me/repo\x07', 1) + + const cwds = (runtime as unknown as { terminalCwdByPtyId: Map }) + .terminalCwdByPtyId + expect(cwds.get('pty-ubuntu')).toBe('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo') + expect(cwds.get('pty-debian')).toBe('\\\\wsl.localhost\\Debian\\home\\me\\repo') + }) + + it('does not retain WSL context when a PTY id is reused', () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + runtime.preparePtyExecutionContext('pty-reused', 'Ubuntu', { resetIncarnation: true }) + runtime.registerPty('pty-reused', TEST_WORKTREE_ID) + runtime.onPtyExit('pty-reused', 0) + + runtime.preparePtyExecutionContext('pty-reused', null, { resetIncarnation: true }) + runtime.registerPty('pty-reused', TEST_WORKTREE_ID) + runtime.onPtyData('pty-reused', '\x1b]7;file://server/share/repo\x07', 1) + + const cwds = (runtime as unknown as { terminalCwdByPtyId: Map }) + .terminalCwdByPtyId + expect(cwds.get('pty-reused')).toBe('\\\\server\\share\\repo') + }) + + it('preserves immutable context while a live daemon attach is unresolved', () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + runtime.preparePtyExecutionContext('pty-attached', 'Ubuntu', { resetIncarnation: true }) + runtime.registerPty('pty-attached', TEST_WORKTREE_ID) + + const changed = runtime.preparePtyExecutionContext('pty-attached', 'Debian', { + preserveExisting: true + }) + runtime.onPtyData('pty-attached', '\x1b]7;file://DESKTOP/home/me/repo\x07', 1) + + const cwd = ( + runtime as unknown as { terminalCwdByPtyId: Map } + ).terminalCwdByPtyId.get('pty-attached') + expect(changed).toBe(false) + expect(cwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo') + }) + + it('replaces a cwd parsed before late WSL context with the provider cwd', async () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + const ptyId = 'pty-late-wsl-context' + const providerCwd = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo' + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => ({ cols: 80, rows: 24 }), + serializeProviderBuffer: vi.fn().mockResolvedValue({ + data: 'restored screen', + cols: 80, + rows: 24, + cwd: providerCwd, + seq: 1, + source: 'headless' + }) + }) + runtime.registerPty(ptyId, TEST_WORKTREE_ID) + runtime.seedHeadlessTerminal(ptyId, '\x1b]7;file://DESKTOP/home/me/repo\x07') + + const internals = runtime as unknown as { + headlessTerminals: Map }> + terminalCwdByPtyId: Map + } + await internals.headlessTerminals.get(ptyId)?.writeChain + expect(internals.terminalCwdByPtyId.get(ptyId)).toBe('\\\\desktop\\home\\me\\repo') + + runtime.preparePtyExecutionContext(ptyId, 'Ubuntu') + await internals.headlessTerminals.get(ptyId)?.writeChain + + expect(internals.terminalCwdByPtyId.get(ptyId)).toBe(providerCwd) + }) + + it('keeps a live WSL cwd that arrives during late-context snapshot recovery', async () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + const ptyId = 'pty-late-wsl-context-race' + type ProviderSnapshot = { + data: string + cols: number + rows: number + cwd: string + seq: number + source: 'headless' + } + let resolveProviderSnapshot: ((snapshot: ProviderSnapshot) => void) | undefined + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => ({ cols: 80, rows: 24 }), + serializeProviderBuffer: vi.fn( + () => + new Promise((resolve) => { + resolveProviderSnapshot = resolve + }) + ) + }) + runtime.registerPty(ptyId, TEST_WORKTREE_ID) + runtime.seedHeadlessTerminal(ptyId, '\x1b]7;file://DESKTOP/home/me/old\x07') + const internals = runtime as unknown as { + headlessTerminals: Map }> + terminalCwdByPtyId: Map + } + await internals.headlessTerminals.get(ptyId)?.writeChain + + runtime.preparePtyExecutionContext(ptyId, 'Ubuntu') + await vi.waitFor(() => expect(resolveProviderSnapshot).toBeDefined()) + runtime.onPtyData(ptyId, '\x1b]7;file://DESKTOP/home/me/live\x07', 1) + resolveProviderSnapshot?.({ + data: 'older restored screen', + cols: 80, + rows: 24, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\old', + seq: 1, + source: 'headless' + }) + await internals.headlessTerminals.get(ptyId)?.writeChain + + expect(internals.terminalCwdByPtyId.get(ptyId)).toBe( + '\\\\wsl.localhost\\Ubuntu\\home\\me\\live' + ) + }) + + it('infers local reconstructed WSL context from a WSL UNC worktree', () => { + setPlatform('win32') + const runtime = new OrcaRuntimeService(store) + runtime.registerPty( + 'pty-reconstructed', + `${TEST_REPO_ID}::\\\\wsl.localhost\\Ubuntu\\home\\me\\repo` + ) + + runtime.onPtyData('pty-reconstructed', '\x1b]7;file://DESKTOP/home/me/repo/src\x07', 1) + + const cwd = ( + runtime as unknown as { terminalCwdByPtyId: Map } + ).terminalCwdByPtyId.get('pty-reconstructed') + expect(cwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo\\src') }) it('clears stale terminal file URI hostnames after empty-host OSC7 cwd updates', () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2d817037b49..b5e2f0df995 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -245,7 +245,7 @@ import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' -import { isWslUncPath } from '../../shared/wsl-paths' +import { isWslUncPath, parseWslUncPath } from '../../shared/wsl-paths' import { folderWorkspaceKey, isWorkspaceKey, @@ -378,6 +378,7 @@ import type { BrowserBackend } from '../browser/browser-backend' import { BrowserError } from '../browser/cdp-bridge' import { getPRForBranch, + getPRForBranchOutcome, getRepoSlug, getRepoUpstream, getWorkItem, @@ -456,7 +457,8 @@ import type { GitLabProjectRef, GitLabWorkItem, ListWorkItemsResult, - MRListState + MRListState, + PRRefreshOutcome } from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import type { @@ -1014,6 +1016,7 @@ type RuntimePtyWorktreeRecord = { // Why: a Windows host can own both native and WSL panes; preamble command // selection must follow the pane that executes it, not process.platform. isWsl: boolean | null + wslDistro: string | null // Why: background CLI PTYs can outlive a failed renderer reveal. Preserve the // spawn-time tab/pane identity so later reveals can adopt under the env key. tabId: string | null @@ -1246,7 +1249,7 @@ type RuntimePtyController = { leafId?: string sessionId?: string persistHostSessionBinding?: boolean - }): Promise<{ id: string }> + }): Promise<{ id: string; wslDistro?: string }> write(ptyId: string, data: string): boolean kill(ptyId: string): boolean stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise @@ -2328,6 +2331,7 @@ export class OrcaRuntimeService { // iterates them all. Listeners are cleaned up via subscriptionCleanups. private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() private ptysById = new Map() + private wslDistroByPtyId = new Map() private titleObservationSequence = 0 private headlessTerminals = new Map() private ptyOutputSequenceById = new Map() @@ -6012,6 +6016,45 @@ export class OrcaRuntimeService { } } + preparePtyExecutionContext( + ptyId: string, + wslDistro: string | null, + options: { resetIncarnation?: boolean; preserveExisting?: boolean } = {} + ): boolean { + const pty = this.ptysById.get(ptyId) + const hadExistingContext = this.wslDistroByPtyId.has(ptyId) || pty !== undefined + if (options.preserveExisting && hadExistingContext) { + // Why: attach-time settings are only a fallback; a live PTY's recorded + // execution namespace remains authoritative until its provider replies. + return false + } + + if (options.resetIncarnation) { + this.disposeHeadlessTerminal(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) + } + + const previous = this.wslDistroByPtyId.get(ptyId) ?? null + if (wslDistro) { + this.wslDistroByPtyId.set(ptyId, wslDistro) + } else { + this.wslDistroByPtyId.delete(ptyId) + } + if (pty) { + pty.wslDistro = wslDistro + } + if (!options.resetIncarnation && previous !== wslDistro && this.headlessTerminals.has(ptyId)) { + // Why: bytes parsed with two distro namespaces would leave an internally + // inconsistent CWD; rebuild from the provider's authoritative snapshot. + this.terminalCwdByPtyId.delete(ptyId) + this.replaceHeadlessTerminalAfterExecutionContextChange(ptyId) + } + return options.resetIncarnation === true || !hadExistingContext || previous !== wslDistro + } + /** Record the spawn launch command so the per-PTY Command Code detector can * arm from it (renderer startupCommand parity). Best-effort: a chunk that * beats this call falls back to the detector's banner arming. */ @@ -6840,7 +6883,10 @@ export class OrcaRuntimeService { return uri ? parseFileUriPathParts(uri, { pathFlavor, - remotePosixAuthority: !!pty?.connectionId && pathFlavor !== 'win32' + remotePosixAuthority: !!pty?.connectionId && pathFlavor !== 'win32', + wslDistro: pty?.connectionId + ? undefined + : (this.wslDistroByPtyId.get(ptyId) ?? pty?.wslDistro ?? undefined) }) : null } @@ -7048,6 +7094,13 @@ export class OrcaRuntimeService { this.disposeHeadlessTerminal(ptyId) } this.providerModeTrackersByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) + this.terminalCwdByPtyId.delete(ptyId) + this.terminalFileUriHostnameByPtyId.delete(ptyId) + const pty = this.ptysById.get(ptyId) + if (pty) { + pty.wslDistro = null + } if (replacesExistingRuntimeGeneration && postSpawnSequence === 0) { this.resetTrackedTerminalStateForProviderGeneration(ptyId) } @@ -7536,6 +7589,9 @@ export class OrcaRuntimeService { pathFlavor, remotePosixFileUriAuthority: !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32', + wslDistro: this.ptysById.get(ptyId)?.connectionId + ? undefined + : (this.wslDistroByPtyId.get(ptyId) ?? this.ptysById.get(ptyId)?.wslDistro ?? undefined), // Why: replies take the provider input path (same entry as pty:write — // daemon shell-ready gating and the SSH relay write apply unchanged), // NOT writePtyInput, so renderer interactive-output metering never @@ -7588,6 +7644,44 @@ export class OrcaRuntimeService { return state } + private replaceHeadlessTerminalAfterExecutionContextChange(ptyId: string): void { + this.disposeHeadlessTerminal(ptyId) + this.providerSnapshotPreferredPtys.add(ptyId) + const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } + const state = this.createPtyHeadlessTerminalState(ptyId, dims) + this.headlessTerminals.set(ptyId, state) + state.writeChain = state.writeChain + .then(async () => { + const snapshot = await this.serializeProviderTerminalBuffer(ptyId) + if (!snapshot) { + return + } + const data = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}` + // Why: a newer live OSC 7 can arrive while the snapshot is in flight; + // only seed metadata while no post-correction CWD has won the race. + if (!this.terminalCwdByPtyId.has(ptyId)) { + this.recordOsc7MetadataForPty(ptyId, data) + } + await state.emulator.write(data) + if (snapshot.cwd !== undefined) { + state.emulator.setCwd(snapshot.cwd) + if (!this.terminalCwdByPtyId.has(ptyId) && snapshot.cwd?.trim()) { + this.terminalCwdByPtyId.set(ptyId, snapshot.cwd) + } + } + if (snapshot.oscLinks !== undefined) { + state.emulator.setRestoredOscLinks(snapshot.oscLinks) + } + state.outputSequence = snapshot.seq + }) + .catch(() => { + // Best-effort: live bytes already chain behind this replacement state. + }) + .finally(() => { + this.providerSnapshotPreferredPtys.delete(ptyId) + }) + } + private resizeHeadlessTerminal(ptyId: string, cols: number, rows: number): void { const state = this.headlessTerminals.get(ptyId) if (!state) { @@ -8154,6 +8248,16 @@ export class OrcaRuntimeService { return pty ? { worktreeId: pty.worktreeId, connectionId: pty.connectionId } : null } + // Why: remote clients cannot resolve this runtime's WSL project preference, + // so host-affecting RPCs (skill discovery) resolve it from the owning store. + resolveProjectRuntimeForWorktree( + worktreeId: string | null | undefined + ): ProjectExecutionRuntimeResolution | undefined { + return this.store && worktreeId + ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), worktreeId) + : undefined + } + getTerminalOrchestrationCliCommand(handle: string): 'orca' | 'orca-ide' { let pty: RuntimePtyWorktreeRecord | null = null try { @@ -9132,6 +9236,7 @@ export class OrcaRuntimeService { this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) // Why: a Claude agent-team leader whose PTY exits naturally (agent finished, // process died, renderer reload) must release its team + nested panes map. @@ -13899,7 +14004,7 @@ export class OrcaRuntimeService { fallbackPRNumber?: number | null, acceptMergedFallbackPR?: boolean, currentHeadOid?: string | null - ): Promise>> { + ): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options: GitHubPRBranchLookupOptions = this.getHostedReviewExecutionOptions(repo) ?? {} const lookupOptions = { ...options } @@ -13911,7 +14016,11 @@ export class OrcaRuntimeService { } const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = Object.keys(lookupOptions).length > 0 ? [lookupOptions] : [] - return getPRForBranch( + // Why: return the full classified outcome (not PRInfo|null) so a runtime gh + // auth/network failure crosses the RPC as `upstream-error` instead of + // collapsing to `null`, which the renderer would otherwise cache as a false + // accepted "no PR found" (design success criterion 1). + return getPRForBranchOutcome( repo.path, branch, linkedPRNumber ?? null, @@ -17811,7 +17920,7 @@ export class OrcaRuntimeService { } catch (error) { return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } } - const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null) + const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null, localWorktreeGitOptions) const projectRef = await getGitLabProjectRefForRemote( repo.path, remote, @@ -17936,7 +18045,7 @@ export class OrcaRuntimeService { connectionId?: string | null, localGitOptions: { wslDistro?: string } = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const localGitOptionArgs = Object.keys(localGitOptions).length > 0 ? ([localGitOptions] as const) : [] if (preference === 'origin') { @@ -18933,6 +19042,9 @@ export class OrcaRuntimeService { : {}) }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + if (result.wslDistro) { + this.preparePtyExecutionContext(result.id, result.wslDistro) + } this.registerPty(result.id, workspace.id, workspace.connectionId) const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { @@ -20106,6 +20218,9 @@ export class OrcaRuntimeService { preAllocatedHandle }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) + if (result.wslDistro) { + this.preparePtyExecutionContext(result.id, result.wslDistro) + } this.registerPty(result.id, workspace.id, workspace.connectionId) const createdPty = this.getOrCreatePtyWorktreeRecord(result.id) if (createdPty) { @@ -21597,17 +21712,29 @@ export class OrcaRuntimeService { | 'title' | 'connectionId' | 'isWsl' + | 'wslDistro' > > = {} ): RuntimePtyWorktreeRecord { let pty = this.ptysById.get(ptyId) if (!pty) { const titleObservedAt = state.title ? this.nextTitleObservationSequence() : null + const connectionId = state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null + const worktreePath = splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + const fallbackWslDistro = + process.platform === 'win32' && connectionId === null && worktreePath + ? parseWslUncPath(worktreePath)?.distro + : undefined + const wslDistro = + connectionId === null + ? (state.wslDistro ?? this.wslDistroByPtyId.get(ptyId) ?? fallbackWslDistro ?? null) + : null pty = { ptyId, worktreeId, - connectionId: state.connectionId ?? parseAppSshPtyId(ptyId)?.connectionId ?? null, + connectionId, isWsl: state.isWsl ?? null, + wslDistro, tabId: state.tabId ?? null, paneKey: state.paneKey ?? null, launchConfig: null, @@ -21640,6 +21767,13 @@ export class OrcaRuntimeService { this.setPtyManagementTitleFromObservedTitle(pty, state.title, titleObservedAt ?? 0) } this.ptysById.set(ptyId, pty) + if (wslDistro) { + this.wslDistroByPtyId.set(ptyId, wslDistro) + } else if (connectionId !== null) { + // Why: restored SSH IDs can collide with stale local parser state; + // connection ownership must win before their first output is parsed. + this.wslDistroByPtyId.delete(ptyId) + } // Why: restored/controller-discovered PTYs learn their worktree here // without registerPty(), so URL enrichment must bind at this source. advertisedUrlWatcher.bindPty(ptyId, worktreeId) @@ -21649,10 +21783,22 @@ export class OrcaRuntimeService { pty.worktreeId = worktreeId if (state.connectionId !== undefined) { pty.connectionId = state.connectionId + if (state.connectionId !== null) { + pty.wslDistro = null + this.wslDistroByPtyId.delete(ptyId) + } } if (state.isWsl !== undefined) { pty.isWsl = state.isWsl } + if (state.wslDistro !== undefined) { + pty.wslDistro = state.wslDistro + if (state.wslDistro) { + this.wslDistroByPtyId.set(ptyId, state.wslDistro) + } else { + this.wslDistroByPtyId.delete(ptyId) + } + } if (state.tabId !== undefined) { pty.tabId = state.tabId } @@ -21814,6 +21960,7 @@ export class OrcaRuntimeService { this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) this.terminalFileUriHostnameByPtyId.delete(ptyId) + this.wslDistroByPtyId.delete(ptyId) this.clearAgentRowSnapshotsForPty(ptyId) const handle = this.handleByPtyId.get(ptyId) if (handle) { diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index 5c3c683b617..7c2e3cb7bb9 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -58,6 +58,7 @@ describe('hosted review RPC methods', () => { canCreate: true, blockedReason: null, nextAction: null, + reviewLookupOutcome: 'not_found', defaultBaseRef: 'main', head: 'feature/create-pr', title: 'Create PR' diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts new file mode 100644 index 00000000000..a4335ec2e62 --- /dev/null +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../core' + +vi.mock('../../../skills/skill-discovery-target', () => ({ + resolveSkillDiscoveryTarget: vi.fn((target) => ({ kind: 'native-host', cwd: target?.cwd })), + discoverSkillsOnTarget: vi.fn(async () => ({ skills: [], sources: [], scannedAt: 1 })) +})) + +import { SKILL_METHODS } from './skills' +import { resolveSkillDiscoveryTarget } from '../../../skills/skill-discovery-target' + +const WSL_RUNTIME = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'wsl:Ubuntu' + } +} as const + +function makeContext(overrides: { + resolveProjectRuntimeForWorktree?: (worktreeId: string | null | undefined) => unknown +}): RpcContext { + return { + runtime: { + listRepos: () => [], + resolveProjectRuntimeForWorktree: + overrides.resolveProjectRuntimeForWorktree ?? (() => undefined) + } + } as unknown as RpcContext +} + +function discoverMethod() { + const method = SKILL_METHODS.find((entry) => entry.name === 'skills.discover') + if (!method) { + throw new Error('skills.discover method not registered') + } + return method +} + +describe('skills.discover RPC', () => { + it('resolves the project runtime from the owning runtime store when the caller omits it', async () => { + const resolveProjectRuntimeForWorktree = vi.fn(() => WSL_RUNTIME) + await discoverMethod().handler( + { cwd: 'C:\\repo', worktreeId: 'worktree-1' }, + makeContext({ resolveProjectRuntimeForWorktree }) + ) + expect(resolveProjectRuntimeForWorktree).toHaveBeenCalledWith('worktree-1') + expect(vi.mocked(resolveSkillDiscoveryTarget)).toHaveBeenLastCalledWith( + expect.objectContaining({ projectRuntime: WSL_RUNTIME }) + ) + }) + + it('prefers a caller-supplied project runtime over store resolution', async () => { + const resolveProjectRuntimeForWorktree = vi.fn() + await discoverMethod().handler( + { cwd: '/repo', worktreeId: 'worktree-1', projectRuntime: WSL_RUNTIME }, + makeContext({ resolveProjectRuntimeForWorktree }) + ) + expect(resolveProjectRuntimeForWorktree).not.toHaveBeenCalled() + expect(vi.mocked(resolveSkillDiscoveryTarget)).toHaveBeenLastCalledWith( + expect.objectContaining({ projectRuntime: WSL_RUNTIME }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 8719cc528ab..346198aeffc 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -1,20 +1,25 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { discoverSkills } from '../../../skills/discovery' - -const SkillDiscoveryParams = z.object({ - cwd: z.string().optional().nullable() -}) +import { SkillDiscoveryTargetSchema } from '../../../../shared/skills' +import { + discoverSkillsOnTarget, + resolveSkillDiscoveryTarget +} from '../../../skills/skill-discovery-target' export const SKILL_METHODS: RpcMethod[] = [ defineMethod({ name: 'skills.discover', - params: SkillDiscoveryParams, + params: SkillDiscoveryTargetSchema.default({}), handler: async (params, { runtime }) => { - const cwd = params.cwd?.trim() || undefined - return cwd - ? discoverSkills({ repos: [], cwd }) - : discoverSkills({ repos: runtime.listRepos() }) + // Why: the executing runtime owns WSL project preferences. Remote callers + // send worktree identity only; trusting their projectRuntime absence + // would scan this host's native filesystem for a WSL-configured project. + const target = params.projectRuntime + ? params + : { + ...params, + projectRuntime: runtime.resolveProjectRuntimeForWorktree(params.worktreeId) + } + return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(target), runtime.listRepos()) } }) ] diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 1b0a31dd2b0..7744bbe67eb 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -548,6 +548,7 @@ describe('OrcaRuntimeRpcServer', () => { expect.objectContaining({ endpoint: offer.endpoint, scope: 'mobile', relay }) ) expect(parsed).not.toHaveProperty('endpoints') + expect(offer.connectionMode).toBe('automatic') expect(server.getDeviceRegistry()?.getDevice(offer.deviceId)?.relayBinding).toEqual({ relayHostId: relay.relayHostId, relayDeviceId: offer.deviceId, @@ -585,6 +586,9 @@ describe('OrcaRuntimeRpcServer', () => { scope: 'mobile' }) expect(parsePairingCode(offer.pairingUrl)).not.toHaveProperty('relay') + // Why: the result reports what the offer actually encodes so the UI can + // flag the degraded mint instead of labeling it as Relay. + expect(offer.connectionMode).toBe('local-only') } finally { await server.stop() } @@ -618,6 +622,7 @@ describe('OrcaRuntimeRpcServer', () => { } expect(parsePairingCode(offer.pairingUrl)).not.toHaveProperty('relay') expect(createPairingRelay).not.toHaveBeenCalled() + expect(offer.connectionMode).toBe('local-only') expect(server.getDeviceRegistry()?.getMobilePairingConnectionMode(offer.deviceId)).toBe( 'local-only' ) @@ -715,6 +720,116 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('rotates a pending local-only code when switching it back to Anywhere', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued: vi.fn(), + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const local = await server.createMobilePairingOffer({ + address: '100.64.1.20', + connectionMode: 'local-only' + }) + expect(local.available).toBe(true) + if (!local.available) { + throw new Error('WebSocket pairing unavailable') + } + const anywhere = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(anywhere.available).toBe(true) + if (!anywhere.available) { + throw new Error('WebSocket pairing unavailable') + } + // Why: a QR displayed under the local-only pledge must not become an + // anywhere-capable credential; the policy switch mints a fresh token. + expect(anywhere.deviceId).not.toBe(local.deviceId) + expect(server.getDeviceRegistry()?.getDevice(local.deviceId)).toBeNull() + expect(anywhere.connectionMode).toBe('automatic') + expect(parsePairingCode(anywhere.pairingUrl)).toHaveProperty('relay') + } finally { + await server.stop() + } + }) + + it('reuses the pending token when the requested mode is unchanged', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + const onDeviceRevokeQueued = vi.fn() + server.setMobileRelayPairingProvider({ + createPairingRelay: async (relayDeviceId) => ({ + relay: { + v: 1, + directorUrl: 'https://relay.example.com', + cellUrl: 'https://cell.example.com', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'A'.repeat(43), + inviteExpiresAt: Date.now() + 60_000, + e2eeFraming: 2 + }, + binding: { + relayHostId: 'AbCdEf0123_-xyZ9', + relayDeviceId, + ownerIdentityKey: 'user\0profile\0org' + } + }), + onDeviceRevokeQueued, + getEndpoints: vi.fn(), + provisionRelay: vi.fn() + }) + + await server.start() + try { + const first = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(first.available).toBe(true) + if (!first.available) { + throw new Error('WebSocket pairing unavailable') + } + // Why: a same-mode remint (e.g. two windows converging after a + // preference sync) must not race rotations off each other's token. + const second = await server.createMobilePairingOffer({ address: '100.64.1.20' }) + expect(second.available).toBe(true) + if (!second.available) { + throw new Error('WebSocket pairing unavailable') + } + expect(second.deviceId).toBe(first.deviceId) + expect(onDeviceRevokeQueued).not.toHaveBeenCalled() + } finally { + await server.stop() + } + }) + it('records cloud cleanup before rotating or deleting the local mobile credential', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const server = new OrcaRuntimeRpcServer({ diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 5c7c44574d4..da0b9a0f626 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -636,14 +636,31 @@ export class OrcaRuntimeRpcServer { connectionMode?: MobilePairingConnectionMode name?: string rotate?: boolean - }): Promise> { + }): Promise< + | { available: false } + | { + available: true + pairingUrl: string + endpoint: string + deviceId: string + webClientUrl: string | null + /** Mode the returned offer actually encodes — 'local-only' when an + * automatic request degraded because Relay could not be attached. */ + connectionMode: MobilePairingConnectionMode + } + > { // Why: the renderer is outside the trust boundary; only the explicit // local-only value may suppress Relay provisioning. const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic' const pending = this.deviceRegistry?.getPendingDevice('mobile') - const switchingPendingToLocal = - connectionMode === 'local-only' && pending?.relayBinding !== undefined - if (args.rotate || switchingPendingToLocal) { + // Why: an offer's connection policy is part of its credential. Rotating on + // any policy switch here (not in the renderer) means a QR displayed under + // the old policy cannot pair under the new one, and windows reminting after + // a preference sync converge on one token instead of racing rotations. + const switchingPendingMode = + pending != null && + this.deviceRegistry?.getMobilePairingConnectionMode(pending.deviceId) !== connectionMode + if (args.rotate || switchingPendingMode) { if (pending?.relayBinding) { // Why: the durable cloud revoke is recorded before rotating the local // token, so a previously displayed relay invite cannot outlive the QR. @@ -652,7 +669,7 @@ export class OrcaRuntimeRpcServer { } const direct = this.createPairingOffer({ ...args, - rotate: args.rotate || switchingPendingToLocal, + rotate: args.rotate || switchingPendingMode, scope: 'mobile' }) if (!direct.available) { @@ -660,21 +677,22 @@ export class OrcaRuntimeRpcServer { } this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) if (connectionMode === 'local-only' || !this.mobileRelayPairingProvider) { - return direct + return { ...direct, connectionMode: 'local-only' } } const device = this.deviceRegistry?.getDevice(direct.deviceId) const publicKeyB64 = this.getE2EEPublicKey() if (!device || !publicKeyB64) { - return direct + return { ...direct, connectionMode: 'local-only' } } try { const relayPairing = await this.mobileRelayPairingProvider.createPairingRelay(device.deviceId) if (!this.deviceRegistry?.setRelayBinding(device.deviceId, relayPairing.binding)) { - return direct + return { ...direct, connectionMode: 'local-only' } } this.mobileRelayPairingProvider.onDemandStateChanged?.() return { ...direct, + connectionMode: 'automatic', pairingUrl: encodePairingOffer({ v: PAIRING_OFFER_VERSION, endpoint: direct.endpoint, @@ -687,7 +705,7 @@ export class OrcaRuntimeRpcServer { } catch { // Why: relay is additive. A transient auth/director/control outage must // still yield the valid LAN/Tailscale pairing offer. - return direct + return { ...direct, connectionMode: 'local-only' } } } diff --git a/src/main/runtime/terminal-model-query-authority.ts b/src/main/runtime/terminal-model-query-authority.ts index e1e0f9c28ef..df8f05798a4 100644 --- a/src/main/runtime/terminal-model-query-authority.ts +++ b/src/main/runtime/terminal-model-query-authority.ts @@ -1,6 +1,6 @@ /** * Phase 5 of the terminal model/view architecture: main-side terminal query - * authority (docs/reference/terminal-query-authority.md). + * authority. * * The delivery decision is the reply decision: main answers a query iff the * hidden-delivery gate dropped the chunk that carried it. This module owns diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index b6988e06731..896fa59b060 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -1,5 +1,5 @@ /** - * Phase 5 model query responder (docs/reference/terminal-query-authority.md): + * Phase 5 model query responder: * reply parity through the runtime emulator, the per-chunk ownership matrix, * the main-side replay guard, and the ingestion-time capture race. */ diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts index ee55581bfe9..2743dd70e3b 100644 --- a/src/main/runtime/terminal-view-attribute-store.ts +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -1,6 +1,6 @@ /** - * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute - * bridge): main-side cache of the renderer's `pty:terminalViewAttributes` + * Phase 5 slice 2 (View-attribute bridge): main-side cache of the renderer's + * `pty:terminalViewAttributes` * push. One app-global snapshot, not per-PTY — per-pane font zoom never * affects these attributes and the color/cursor settings are global. * diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.test.ts b/src/main/skills/claude-plugin-skill-sources-wsl.test.ts new file mode 100644 index 00000000000..4d4f1add82c --- /dev/null +++ b/src/main/skills/claude-plugin-skill-sources-wsl.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + buildWslClaudePluginMetadataCommand, + parseWslClaudePluginMetadataOutput +} from './claude-plugin-skill-sources-wsl' + +function record(...fields: string[]): string { + return `${fields.join('\0')}\0` +} + +describe('WSL Claude plugin metadata', () => { + it('reads metadata paths inside the distro without exposing them to Windows shell parsing', () => { + const command = buildWslClaudePluginMetadataCommand(["/home/alice's/.claude/settings.json"]) + const encoded = /printf %s '([^']+)'/.exec(command)?.[1] + expect(encoded).toBeTruthy() + const script = Buffer.from(encoded!, 'base64').toString('utf8') + + expect(script).toContain(`'/home/alice'\\''s/.claude/settings.json'`) + expect(script).toContain('base64 < "$metadata_path"') + expect(command).not.toContain('/home/alice') + }) + + it('decodes present files and retains missing files as null', () => { + const settings = JSON.stringify({ enabledPlugins: { 'plugin@market': true } }) + const output = [ + record('F', '0', '1', Buffer.from(settings).toString('base64')), + record('F', '1', '0', '') + ].join('') + + expect(parseWslClaudePluginMetadataOutput(output, 2)).toEqual([settings, null]) + }) + + it('rejects malformed metadata responses', () => { + expect(() => parseWslClaudePluginMetadataOutput(record('F', '4', '1', ''), 1)).toThrow( + 'invalid response' + ) + }) +}) diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.ts b/src/main/skills/claude-plugin-skill-sources-wsl.ts new file mode 100644 index 00000000000..d3adddfde55 --- /dev/null +++ b/src/main/skills/claude-plugin-skill-sources-wsl.ts @@ -0,0 +1,100 @@ +import { execFile } from 'node:child_process' +import { posix as pathPosix } from 'node:path' +import { buildEncodedWslBashCommand, quoteBashString } from '../wsl-bash-command' +import { + getClaudePluginMetadataPaths, + resolveClaudePluginSkillSources, + type ClaudePluginMetadata +} from './claude-plugin-skill-sources' +import type { SkillScanRoot } from './skill-discovery-sources' + +const MAX_PLUGIN_METADATA_BYTES = 4 * 1024 * 1024 +const WSL_METADATA_TIMEOUT_MS = 5_000 +const WSL_METADATA_MAX_BUFFER_BYTES = 32 * 1024 * 1024 + +export function buildWslClaudePluginMetadataCommand(paths: readonly string[]): string { + const lines = [ + 'set -u', + 'set -o pipefail', + 'read_metadata() {', + ' metadata_index=$1', + ' metadata_path=$2', + ` metadata_size=$(stat -c '%s' -- "$metadata_path" 2>/dev/null || true)`, + ` if [ -z "$metadata_size" ] || [ "$metadata_size" -gt ${MAX_PLUGIN_METADATA_BYTES} ]; then`, + ` printf '%s\\0%s\\0%s\\0\\0' F "$metadata_index" 0`, + ' return', + ' fi', + ` encoded_metadata=$(base64 < "$metadata_path" | tr -d '\\n') || { printf '%s\\0%s\\0%s\\0\\0' F "$metadata_index" 0; return; }`, + ` printf '%s\\0%s\\0%s\\0%s\\0' F "$metadata_index" 1 "$encoded_metadata"`, + '}' + ] + paths.forEach((pathValue, index) => { + lines.push(`read_metadata ${index} ${quoteBashString(pathValue)}`) + }) + return buildEncodedWslBashCommand(lines.join('\n')) +} + +export function parseWslClaudePluginMetadataOutput( + output: string, + fileCount: number +): (string | null)[] { + const contents = Array(fileCount).fill(null) + const fields = output.split('\0') + let index = 0 + while (index < fields.length && fields[index]) { + const kind = fields[index++] + const fileIndex = Number.parseInt(fields[index++] ?? '', 10) + const exists = fields[index++] === '1' + const encoded = fields[index++] + if (kind !== 'F' || !Number.isInteger(fileIndex) || fileIndex < 0 || fileIndex >= fileCount) { + throw new Error('WSL Claude plugin metadata returned an invalid response.') + } + if (exists && encoded !== undefined) { + contents[fileIndex] = Buffer.from(encoded, 'base64').toString('utf8') + } + } + return contents +} + +function executeWslMetadataRead(distro: string, command: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'wsl.exe', + ['-d', distro, '--', 'bash', '-c', command], + { + encoding: 'utf8', + maxBuffer: WSL_METADATA_MAX_BUFFER_BYTES, + timeout: WSL_METADATA_TIMEOUT_MS, + windowsHide: true + }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} + +export async function discoverClaudePluginSkillSourcesInWsl(args: { + distro: string + homeDir: string + cwd: string +}): Promise { + const paths = getClaudePluginMetadataPaths(args.homeDir, args.cwd, pathPosix) + const orderedPaths = [paths.installedPlugins, ...paths.settings] + // Why: plugin enablement and install paths belong to the distro just like + // SKILL.md identity; reading them through UNC could apply Windows semantics. + const output = await executeWslMetadataRead( + args.distro, + buildWslClaudePluginMetadataCommand(orderedPaths) + ) + const [installedPlugins, ...settings] = parseWslClaudePluginMetadataOutput( + output, + orderedPaths.length + ) + const metadata: ClaudePluginMetadata = { installedPlugins, settings } + return resolveClaudePluginSkillSources({ metadata, cwd: args.cwd, pathApi: pathPosix }) +} diff --git a/src/main/skills/claude-plugin-skill-sources.test.ts b/src/main/skills/claude-plugin-skill-sources.test.ts new file mode 100644 index 00000000000..c12f2e3415c --- /dev/null +++ b/src/main/skills/claude-plugin-skill-sources.test.ts @@ -0,0 +1,122 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { resolveClaudePluginSkillSources } from './claude-plugin-skill-sources' + +function installedPlugins(installs: Record[]>): string { + return JSON.stringify({ version: 2, plugins: installs }) +} + +function settings(enabledPlugins: Record): string { + return JSON.stringify({ enabledPlugins }) +} + +describe('Claude plugin skill sources', () => { + const pluginId = 'compound-engineering@compound-engineering-plugin' + const projectPath = join('/workspace', 'orca') + const cwd = join(projectPath, 'worktree') + + it('selects the applicable project install over a stale user version', () => { + const roots = resolveClaudePluginSkillSources({ + cwd, + metadata: { + installedPlugins: installedPlugins({ + [pluginId]: [ + { + scope: 'user', + installPath: '/home/alice/.claude/plugins/cache/compound/3.13.1', + lastUpdated: '2026-06-23T00:00:00.000Z' + }, + { + scope: 'project', + projectPath, + installPath: '/home/alice/.claude/plugins/cache/compound/3.14.3', + lastUpdated: '2026-06-25T00:00:00.000Z' + } + ] + }), + settings: [settings({ [pluginId]: true })] + } + }) + + expect(roots).toEqual([ + expect.objectContaining({ + label: 'Claude plugin compound-engineering', + path: join('/home/alice/.claude/plugins/cache/compound/3.14.3', 'skills'), + sourceKind: 'plugin', + providers: ['claude'], + owner: 'claude' + }) + ]) + }) + + it('falls back to the user install outside the project scope', () => { + const roots = resolveClaudePluginSkillSources({ + cwd: '/workspace/another-project', + metadata: { + installedPlugins: installedPlugins({ + [pluginId]: [ + { + scope: 'user', + installPath: '/home/alice/.claude/plugins/cache/compound/user' + }, + { + scope: 'project', + projectPath, + installPath: '/home/alice/.claude/plugins/cache/compound/project' + } + ] + }), + settings: [settings({ [pluginId]: true })] + } + }) + + expect(roots[0]?.path).toBe(join('/home/alice/.claude/plugins/cache/compound/user', 'skills')) + }) + + it('honors project and local settings overrides for disabled plugins', () => { + const roots = resolveClaudePluginSkillSources({ + cwd, + metadata: { + installedPlugins: installedPlugins({ + [pluginId]: [ + { + scope: 'project', + projectPath, + installPath: '/home/alice/.claude/plugins/cache/compound/3.14.3' + } + ] + }), + settings: [settings({ [pluginId]: true }), settings({ [pluginId]: false })] + } + }) + + expect(roots).toEqual([]) + }) + + it('chooses the newest applicable install within the same scope', () => { + const roots = resolveClaudePluginSkillSources({ + cwd, + metadata: { + installedPlugins: installedPlugins({ + [pluginId]: [ + { + scope: 'project', + projectPath, + installPath: '/home/alice/.claude/plugins/cache/compound/old', + lastUpdated: '2026-01-01T00:00:00.000Z' + }, + { + scope: 'project', + projectPath, + installPath: '/home/alice/.claude/plugins/cache/compound/new', + lastUpdated: '2026-07-01T00:00:00.000Z' + } + ] + }), + settings: [settings({ [pluginId]: true })] + } + }) + + expect(roots[0]?.path).toBe(join('/home/alice/.claude/plugins/cache/compound/new', 'skills')) + }) +}) diff --git a/src/main/skills/claude-plugin-skill-sources.ts b/src/main/skills/claude-plugin-skill-sources.ts new file mode 100644 index 00000000000..1726cfdc9db --- /dev/null +++ b/src/main/skills/claude-plugin-skill-sources.ts @@ -0,0 +1,219 @@ +import { open, stat } from 'node:fs/promises' +import { basename, isAbsolute, join, relative, sep, type posix } from 'node:path' +import { stablePathId, type SkillScanRoot } from './skill-discovery-sources' +import { stripUnsafeDisplayCharacters } from '../../shared/skill-display-text' + +const MAX_PLUGIN_METADATA_BYTES = 4 * 1024 * 1024 + +export type SkillDiscoveryPathApi = Pick< + typeof posix, + 'basename' | 'isAbsolute' | 'join' | 'relative' | 'sep' +> + +export type ClaudePluginMetadata = { + installedPlugins: string | null + settings: (string | null)[] +} + +type ClaudePluginInstall = { + scope: 'user' | 'project' | 'local' + installPath: string + projectPath?: string + installedAt?: string + lastUpdated?: string +} + +const defaultPathApi: SkillDiscoveryPathApi = { basename, isAbsolute, join, relative, sep } + +export function getClaudePluginMetadataPaths( + homeDir: string, + cwd: string, + pathApi: SkillDiscoveryPathApi = defaultPathApi +): { installedPlugins: string; settings: string[] } { + return { + installedPlugins: pathApi.join(homeDir, '.claude', 'plugins', 'installed_plugins.json'), + // Claude merges user, project, then project-local settings in this order. + settings: [ + pathApi.join(homeDir, '.claude', 'settings.json'), + pathApi.join(cwd, '.claude', 'settings.json'), + pathApi.join(cwd, '.claude', 'settings.local.json') + ] + } +} + +function parseJsonObject(content: string | null): Record | null { + if (!content) { + return null + } + try { + const parsed: unknown = JSON.parse(content) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +function readEnabledPlugins(settingsContents: readonly (string | null)[]): Map { + const enabled = new Map() + for (const content of settingsContents) { + const settings = parseJsonObject(content) + const configured = settings?.enabledPlugins + if (!configured || typeof configured !== 'object' || Array.isArray(configured)) { + continue + } + for (const [pluginId, value] of Object.entries(configured)) { + if (typeof value === 'boolean') { + enabled.set(pluginId, value) + } + } + } + return enabled +} + +function parseInstall(value: unknown): ClaudePluginInstall | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const record = value as Record + if ( + (record.scope !== 'user' && record.scope !== 'project' && record.scope !== 'local') || + typeof record.installPath !== 'string' + ) { + return null + } + return { + scope: record.scope, + installPath: record.installPath, + ...(typeof record.projectPath === 'string' ? { projectPath: record.projectPath } : {}), + ...(typeof record.installedAt === 'string' ? { installedAt: record.installedAt } : {}), + ...(typeof record.lastUpdated === 'string' ? { lastUpdated: record.lastUpdated } : {}) + } +} + +function isProjectInstallApplicable( + install: ClaudePluginInstall, + cwd: string, + pathApi: SkillDiscoveryPathApi +): boolean { + if (install.scope === 'user') { + return true + } + if (!install.projectPath || !pathApi.isAbsolute(install.projectPath)) { + return false + } + const relativePath = pathApi.relative(install.projectPath, cwd) + return ( + relativePath === '' || + (relativePath !== '..' && + !relativePath.startsWith(`..${pathApi.sep}`) && + !pathApi.isAbsolute(relativePath)) + ) +} + +function installPriority(install: ClaudePluginInstall): number { + return install.scope === 'local' ? 2 : install.scope === 'project' ? 1 : 0 +} + +function installTimestamp(install: ClaudePluginInstall): number { + const timestamp = Date.parse(install.lastUpdated ?? install.installedAt ?? '') + return Number.isFinite(timestamp) ? timestamp : 0 +} + +function selectActiveInstall( + values: unknown[], + cwd: string, + pathApi: SkillDiscoveryPathApi +): ClaudePluginInstall | null { + return ( + values + .map(parseInstall) + .filter((install): install is ClaudePluginInstall => install !== null) + .filter( + (install) => + pathApi.isAbsolute(install.installPath) && + isProjectInstallApplicable(install, cwd, pathApi) + ) + .sort( + (a, b) => + installPriority(b) - installPriority(a) || + (b.projectPath?.length ?? 0) - (a.projectPath?.length ?? 0) || + installTimestamp(b) - installTimestamp(a) + )[0] ?? null + ) +} + +function safePluginLabel(pluginId: string, pathApi: SkillDiscoveryPathApi): string { + const packageName = pluginId.split('@')[0] || pathApi.basename(pluginId) + const safeLabel = stripUnsafeDisplayCharacters(packageName).slice(0, 80) + return safeLabel || 'plugin' +} + +export function resolveClaudePluginSkillSources(args: { + metadata: ClaudePluginMetadata + cwd: string + pathApi?: SkillDiscoveryPathApi +}): SkillScanRoot[] { + const pathApi = args.pathApi ?? defaultPathApi + const installed = parseJsonObject(args.metadata.installedPlugins)?.plugins + if (!installed || typeof installed !== 'object' || Array.isArray(installed)) { + return [] + } + const enabled = readEnabledPlugins(args.metadata.settings) + const roots = new Map() + for (const [pluginId, rawInstalls] of Object.entries(installed)) { + if (enabled.get(pluginId) !== true || !Array.isArray(rawInstalls)) { + continue + } + const install = selectActiveInstall(rawInstalls, args.cwd, pathApi) + if (!install) { + continue + } + const skillsPath = pathApi.join(install.installPath, 'skills') + if (!roots.has(skillsPath)) { + roots.set(skillsPath, { + id: `claude-plugin-${stablePathId(skillsPath)}`, + label: `Claude plugin ${safePluginLabel(pluginId, pathApi)}`, + path: skillsPath, + sourceKind: 'plugin', + providers: ['claude'], + owner: 'claude' + }) + } + } + return [...roots.values()] +} + +async function readMetadataFile(pathValue: string): Promise { + try { + const fileStat = await stat(pathValue) + if (!fileStat.isFile() || fileStat.size > MAX_PLUGIN_METADATA_BYTES) { + return null + } + const file = await open(pathValue, 'r') + try { + const buffer = Buffer.alloc(fileStat.size) + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) + return buffer.toString('utf8', 0, bytesRead) + } finally { + await file.close() + } + } catch { + return null + } +} + +export async function discoverClaudePluginSkillSources(args: { + homeDir: string + cwd: string +}): Promise { + const paths = getClaudePluginMetadataPaths(args.homeDir, args.cwd) + const [installedPlugins, ...settings] = await Promise.all( + [paths.installedPlugins, ...paths.settings].map(readMetadataFile) + ) + return resolveClaudePluginSkillSources({ + metadata: { installedPlugins, settings }, + cwd: args.cwd + }) +} diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index d65f30dc391..a5d113aebe9 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -45,6 +45,90 @@ describe('skill discovery', () => { expect(result.skills.find((skill) => skill.name === 'Docs')?.providers).toEqual(['claude']) }) + it('discovers the enabled Claude plugin version applicable to the project cwd', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const project = join(root, 'project') + const cwd = join(project, 'worktree') + const userInstall = join(home, '.claude', 'plugins', 'cache', 'compound', '3.13.1') + const projectInstall = join(home, '.claude', 'plugins', 'cache', 'compound', '3.14.3') + const pluginId = 'compound-engineering@compound-engineering-plugin' + await mkdir(join(userInstall, 'skills', 'old-plan'), { recursive: true }) + await mkdir(join(projectInstall, 'skills', 'ce-plan'), { recursive: true }) + await mkdir(join(home, '.claude', 'plugins'), { recursive: true }) + await mkdir(cwd, { recursive: true }) + await writeFile(join(userInstall, 'skills', 'old-plan', 'SKILL.md'), '# old-plan') + await writeFile( + join(projectInstall, 'skills', 'ce-plan', 'SKILL.md'), + '---\nname: ce-plan\ndescription: Create structured plans.\n---\n' + ) + await writeFile( + join(home, '.claude', 'plugins', 'installed_plugins.json'), + JSON.stringify({ + plugins: { + [pluginId]: [ + { scope: 'user', installPath: userInstall }, + { scope: 'project', projectPath: project, installPath: projectInstall } + ] + } + }) + ) + await writeFile( + join(home, '.claude', 'settings.json'), + JSON.stringify({ enabledPlugins: { [pluginId]: true } }) + ) + + const result = await discoverSkills({ homeDir: home, cwd, repos: [] }) + + expect(result.skills.map((skill) => skill.name)).toContain('ce-plan') + expect(result.skills.map((skill) => skill.name)).not.toContain('old-plan') + const pluginSource = result.sources.find( + (source) => source.path === join(projectInstall, 'skills') + ) + expect(pluginSource).toMatchObject({ sourceKind: 'plugin', owner: 'claude', exists: true }) + }) + + it('skips Claude plugin discovery when no explicit cwd targets the scan (Settings shape)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const install = join(home, '.claude', 'plugins', 'cache', 'compound', '3.14.3') + const pluginId = 'compound-engineering@compound-engineering-plugin' + await mkdir(join(install, 'skills', 'ce-plan'), { recursive: true }) + await writeFile(join(install, 'skills', 'ce-plan', 'SKILL.md'), '# ce-plan') + await writeFile( + join(home, '.claude', 'plugins', 'installed_plugins.json'), + JSON.stringify({ plugins: { [pluginId]: [{ scope: 'user', installPath: install }] } }) + ) + await writeFile( + join(home, '.claude', 'settings.json'), + JSON.stringify({ enabledPlugins: { [pluginId]: true } }) + ) + + const result = await discoverSkills({ homeDir: home, repos: [], includeCwd: false }) + + expect(result.skills.map((skill) => skill.name)).not.toContain('ce-plan') + expect(result.sources.some((source) => source.id.startsWith('claude-plugin'))).toBe(false) + }) + + it('records every contributing root when symlinked roots dedup to one skill', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const codexSkills = join(home, '.codex', 'skills') + await mkdir(join(codexSkills, 'review'), { recursive: true }) + await writeFile(join(codexSkills, 'review', 'SKILL.md'), '# review') + await mkdir(join(home, '.agents'), { recursive: true }) + // Shared root is a symlink onto the Codex root: one canonical file, two roots. + await symlink(codexSkills, join(home, '.agents', 'skills'), 'dir') + + const result = await discoverSkills({ homeDir: home, repos: [], includeCwd: false }) + + const reviews = result.skills.filter((skill) => skill.name === 'review') + expect(reviews).toHaveLength(1) + expect(reviews[0].rootPaths?.slice().sort()).toEqual( + [codexSkills, join(home, '.agents', 'skills')].sort() + ) + }) + it('does not add SSH-backed repository paths to local scan roots', () => { const roots = buildSkillDiscoverySources({ homeDir: '/home/test', diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index ed74ef3ffa6..00edfc8e705 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -1,19 +1,23 @@ import type { Dirent } from 'node:fs' import { open, readdir, realpath, stat } from 'node:fs/promises' +import { homedir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { summarizeSkillMarkdown } from '../../shared/skill-metadata' import type { Repo } from '../../shared/types' import type { DiscoveredSkill, SkillDiscoveryResult, - SkillDiscoverySource, - SkillSourceKind + SkillDiscoverySource } from '../../shared/skills' import { buildSkillDiscoverySources, + compareSkills, + sourceKindForSkill, + sourceLabelForSkill, stablePathId, type SkillScanRoot } from './skill-discovery-sources' +import { discoverClaudePluginSkillSources } from './claude-plugin-skill-sources' export { buildSkillDiscoverySources } from './skill-discovery-sources' @@ -30,14 +34,6 @@ async function pathExists(pathValue: string): Promise { } } -function compareSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { - return ( - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || - a.sourceLabel.localeCompare(b.sourceLabel, undefined, { sensitivity: 'base' }) || - a.skillFilePath.localeCompare(b.skillFilePath) - ) -} - function isWithinDepth(rootPath: string, childPath: string, maxDepth: number): boolean { const rel = relative(rootPath, childPath) if (!rel) { @@ -50,23 +46,6 @@ function isWithinDepth(rootPath: string, childPath: string, maxDepth: number): b return rel.split(sep).length <= maxDepth } -function sourceKindForSkill(root: SkillScanRoot, skillFilePath: string): SkillSourceKind { - if ( - root.sourceKind === 'home' && - relative(root.path, skillFilePath).split(sep)[0] === '.system' - ) { - return 'bundled' - } - return root.sourceKind -} - -function sourceLabelForSkill(root: SkillScanRoot, sourceKind: SkillSourceKind): string { - if (sourceKind === 'bundled') { - return `${root.label} bundled` - } - return root.label -} - async function findSkillFiles(rootPath: string, maxDepth: number): Promise { const out: string[] = [] const visitedDirectoryPaths = new Set() @@ -182,7 +161,7 @@ async function readSkillSummary(skillFilePath: string): Promise<{ name: string | null description: string | null updatedAt: number | null -}> { +} | null> { try { const fileStat = await stat(skillFilePath) const file = await open(skillFilePath, 'r') @@ -199,23 +178,33 @@ async function readSkillSummary(skillFilePath: string): Promise<{ updatedAt: fileStat.mtimeMs } } catch { - return { name: null, description: null, updatedAt: null } + return null } } -async function scanRoot(root: SkillScanRoot): Promise { +type ScannedSkill = DiscoveredSkill & { canonicalSkillFilePath: string } + +async function scanRoot(root: SkillScanRoot): Promise { const maxDepth = root.sourceKind === 'plugin' ? 9 : 4 const skillFiles = await findSkillFiles(root.path, maxDepth) const skills = await Promise.all( - skillFiles.map(async (skillFilePath) => { + skillFiles.map(async (skillFilePath): Promise => { + // Why: path identity belongs to the scanning host; canonicalizing before + // returning prevents symlinked roots from becoming duplicate picker rows. + const canonicalSkillFilePath = await realpath(skillFilePath).catch(() => skillFilePath) const directoryPath = dirname(skillFilePath) const summary = await readSkillSummary(skillFilePath) - const sourceKind = sourceKindForSkill(root, skillFilePath) + if (!summary) { + return null + } + const sourceKind = sourceKindForSkill(root, skillFilePath, { relative, sep }) return { - id: stablePathId(skillFilePath), + id: stablePathId(canonicalSkillFilePath), name: summary.name ?? basename(directoryPath), description: summary.description, - providers: root.providers, + // Copy: `root.providers` is shared across every skill/source from this + // root, so the dedup merge below must not mutate the aliased array. + providers: [...root.providers], sourceKind, sourceLabel: sourceLabelForSkill(root, sourceKind), rootPath: root.path, @@ -223,11 +212,12 @@ async function scanRoot(root: SkillScanRoot): Promise { skillFilePath, installed: true, fileCount: await countFiles(directoryPath), - updatedAt: summary.updatedAt - } satisfies DiscoveredSkill + updatedAt: summary.updatedAt, + canonicalSkillFilePath + } satisfies ScannedSkill }) ) - return skills + return skills.filter((skill): skill is ScannedSkill => skill !== null) } export async function discoverSkills(args: { @@ -236,12 +226,25 @@ export async function discoverSkills(args: { cwd?: string includeCwd?: boolean }): Promise { - const roots = buildSkillDiscoverySources(args) + const homeDir = args.homeDir ?? homedir() + const roots = [ + ...buildSkillDiscoverySources({ ...args, homeDir }), + // Why: plugin discovery is native-chat data keyed to an explicit workspace. + // Untargeted scans (Settings) keep their pre-picker inventory and cost. + ...(args.cwd && args.includeCwd !== false + ? await discoverClaudePluginSkillSources({ homeDir, cwd: args.cwd }) + : []) + ] const sources: SkillDiscoverySource[] = [] const skillGroups = await Promise.all( roots.map(async (root) => { const exists = await pathExists(root.path) - sources.push({ ...root, exists, skippedReason: exists ? undefined : 'missing' }) + sources.push({ + ...root, + providers: [...root.providers], + exists, + skippedReason: exists ? undefined : 'missing' + }) if (!exists) { return [] } @@ -250,11 +253,30 @@ export async function discoverSkills(args: { ) const seen = new Map() for (const skill of skillGroups.flat()) { - // Why: WSL discovery sets cwd to the WSL home, so home skills can also be - // reached through the synthetic repo root. Keep the global home identity. - if (!seen.has(skill.skillFilePath)) { - seen.set(skill.skillFilePath, skill) + // Why: overlapping repo/cwd roots and symlinked provider homes can reach + // the same file. Keep the first source's higher-level scope identity, but + // record every contributing root so per-agent visibility survives dedup. + const existing = seen.get(skill.canonicalSkillFilePath) + if (existing) { + if (existing.rootPaths && !existing.rootPaths.includes(skill.rootPath)) { + existing.rootPaths.push(skill.rootPath) + } + // Why: providers is per-agent visibility just like rootPaths; keeping only + // the first root's tags makes a shared/symlinked skill under-report which + // agents can see it on the Settings provider badges/filter. Reassign a + // fresh array — `providers` aliases the scan root's array, so pushing in + // place would mutate the root and every sibling skill/source sharing it. + const mergedProviders = [...existing.providers] + for (const provider of skill.providers) { + if (!mergedProviders.includes(provider)) { + mergedProviders.push(provider) + } + } + existing.providers = mergedProviders + continue } + const { canonicalSkillFilePath, ...publicSkill } = skill + seen.set(canonicalSkillFilePath, { ...publicSkill, rootPaths: [skill.rootPath] }) } return { skills: Array.from(seen.values()).sort(compareSkills), diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index ba3726f43e0..f17d15e1ef2 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -1,24 +1,63 @@ import { createHash } from 'node:crypto' import { homedir } from 'node:os' -import { basename, join } from 'node:path' -import type { SkillDiscoverySource, SkillProvider, SkillSourceKind } from '../../shared/skills' +import { basename, join, type posix } from 'node:path' +import type { + DiscoveredSkill, + SkillDiscoverySource, + SkillProvider, + SkillSourceKind +} from '../../shared/skills' +import type { AgentType } from '../../shared/agent-status-types' import type { Repo } from '../../shared/types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' export type SkillScanRoot = Omit +type SkillDiscoveryPathApi = Pick export function stablePathId(pathValue: string): string { return createHash('sha1').update(pathValue).digest('hex').slice(0, 16) } +// Skill classification and ordering are identical for native and WSL discovery; +// only the path arithmetic differs (node:path vs pathPosix), so both callers +// share these and pass the matching path adapter. +type SkillRelativePathApi = { relative: (from: string, to: string) => string; sep: string } + +export function sourceKindForSkill( + root: SkillScanRoot, + skillFilePath: string, + pathApi: SkillRelativePathApi +): SkillSourceKind { + if ( + root.sourceKind === 'home' && + pathApi.relative(root.path, skillFilePath).split(pathApi.sep)[0] === '.system' + ) { + return 'bundled' + } + return root.sourceKind +} + +export function sourceLabelForSkill(root: SkillScanRoot, sourceKind: SkillSourceKind): string { + return sourceKind === 'bundled' ? `${root.label} bundled` : root.label +} + +export function compareSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { + return ( + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || + a.sourceLabel.localeCompare(b.sourceLabel, undefined, { sensitivity: 'base' }) || + a.skillFilePath.localeCompare(b.skillFilePath) + ) +} + function source( id: string, label: string, path: string, sourceKind: SkillSourceKind, - providers: SkillProvider[] + providers: SkillProvider[], + owner: AgentType | null ): SkillScanRoot { - return { id, label, path, sourceKind, providers } + return { id, label, path, sourceKind, providers, owner } } export function buildSkillDiscoverySources( @@ -27,39 +66,95 @@ export function buildSkillDiscoverySources( cwd?: string repos?: Repo[] includeCwd?: boolean + pathApi?: SkillDiscoveryPathApi } = {} ): SkillScanRoot[] { + const pathApi = args.pathApi ?? { basename, join } const home = args.homeDir ?? homedir() const cwd = args.cwd ?? process.cwd() const roots: SkillScanRoot[] = [ - source('home-codex', 'Codex home', join(home, '.codex', 'skills'), 'home', ['codex']), - source('home-agents', 'Agent skills home', join(home, '.agents', 'skills'), 'home', [ - 'agent-skills' - ]), - source('home-claude', 'Claude home', join(home, '.claude', 'skills'), 'home', ['claude']), + source( + 'home-codex', + 'Codex home', + pathApi.join(home, '.codex', 'skills'), + 'home', + ['codex'], + 'codex' + ), + source( + 'home-agents', + 'Agent skills home', + pathApi.join(home, '.agents', 'skills'), + 'home', + ['agent-skills'], + null + ), + source( + 'home-claude', + 'Claude home', + pathApi.join(home, '.claude', 'skills'), + 'home', + ['claude'], + 'claude' + ), source( 'codex-plugin-cache', 'Codex plugin cache', - join(home, '.codex', 'plugins', 'cache'), + pathApi.join(home, '.codex', 'plugins', 'cache'), 'plugin', - ['codex', 'agent-skills'] + ['codex', 'agent-skills'], + 'codex' ), // Why: `npx skills add --global` writes into each agent's own home skills // directory, so coverage misses them unless we scan every provider root. - source('home-grok', 'Grok home', join(home, '.grok', 'skills'), 'home', ['agent-skills']), - source('home-opencode', 'OpenCode home', join(home, '.config', 'opencode', 'skills'), 'home', [ - 'agent-skills' - ]), - source('home-pi', 'Pi home', join(home, '.pi', 'agent', 'skills'), 'home', ['agent-skills']), - source('home-gemini', 'Gemini home', join(home, '.gemini', 'skills'), 'home', ['agent-skills']), + source( + 'home-grok', + 'Grok home', + pathApi.join(home, '.grok', 'skills'), + 'home', + ['agent-skills'], + 'grok' + ), + source( + 'home-opencode', + 'OpenCode home', + pathApi.join(home, '.config', 'opencode', 'skills'), + 'home', + ['agent-skills'], + 'opencode' + ), + source( + 'home-pi', + 'Pi home', + pathApi.join(home, '.pi', 'agent', 'skills'), + 'home', + ['agent-skills'], + 'pi' + ), + source( + 'home-gemini', + 'Gemini home', + pathApi.join(home, '.gemini', 'skills'), + 'home', + ['agent-skills'], + 'gemini' + ), source( 'home-antigravity', 'Antigravity home', - join(home, '.gemini', 'antigravity', 'skills'), + pathApi.join(home, '.gemini', 'antigravity', 'skills'), 'home', - ['agent-skills'] + ['agent-skills'], + 'antigravity' ), - source('home-cursor', 'Cursor home', join(home, '.cursor', 'skills'), 'home', ['agent-skills']) + source( + 'home-cursor', + 'Cursor home', + pathApi.join(home, '.cursor', 'skills'), + 'home', + ['agent-skills'], + 'cursor' + ) ] const projectPaths = new Set() @@ -76,21 +171,23 @@ export function buildSkillDiscoverySources( } for (const repoPath of projectPaths) { - const label = `Repo ${basename(repoPath)}` + const label = `Repo ${pathApi.basename(repoPath)}` roots.push( source( `repo-agents-${stablePathId(repoPath)}`, `${label} .agents`, - join(repoPath, '.agents', 'skills'), + pathApi.join(repoPath, '.agents', 'skills'), 'repo', - ['agent-skills'] + ['agent-skills'], + null ), source( `repo-claude-${stablePathId(repoPath)}`, `${label} .claude`, - join(repoPath, '.claude', 'skills'), + pathApi.join(repoPath, '.claude', 'skills'), 'repo', - ['claude'] + ['claude'], + 'claude' ) ) } diff --git a/src/main/skills/skill-discovery-target.ts b/src/main/skills/skill-discovery-target.ts new file mode 100644 index 00000000000..2be10232c8c --- /dev/null +++ b/src/main/skills/skill-discovery-target.ts @@ -0,0 +1,70 @@ +import type { Repo } from '../../shared/types' +import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import { getDefaultWslDistro, getWslHome, parseWslPath, toLinuxPath } from '../wsl' +import { discoverSkills } from './discovery' +import { discoverSkillsInWsl } from './skill-discovery-wsl' + +export type ResolvedSkillDiscoveryTarget = + | { kind: 'native-host'; cwd: string | undefined } + | { kind: 'wsl'; distro: string; homeDir: string; cwd: string } + +export function resolveSkillDiscoveryTarget( + target: SkillDiscoveryTarget | undefined +): ResolvedSkillDiscoveryTarget { + const projectRuntime = target?.projectRuntime + if (projectRuntime?.status === 'repair-required') { + throw new Error( + `Project runtime requires repair before skill discovery: ${projectRuntime.repair.reason}` + ) + } + + const wslRequested = + (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') || + (!projectRuntime && target?.runtime === 'wsl') + const wslDistro = + projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl' + ? projectRuntime.runtime.distro + : !projectRuntime && target?.runtime === 'wsl' + ? target.wslDistro?.trim() || getDefaultWslDistro() + : null + if (wslRequested && !wslDistro) { + throw new Error('No WSL distribution is available for skill discovery.') + } + if (!wslDistro) { + return { kind: 'native-host', cwd: target?.cwd?.trim() || undefined } + } + if (process.platform !== 'win32') { + throw new Error('WSL skill discovery is only available on Windows.') + } + const homeDir = getWslHome(wslDistro) + if (!homeDir) { + throw new Error(`Could not resolve the WSL home directory for ${wslDistro}.`) + } + + const requestedCwd = target?.cwd?.trim() + const parsedCwd = requestedCwd ? parseWslPath(requestedCwd) : null + if (parsedCwd && parsedCwd.distro.toLowerCase() !== wslDistro.toLowerCase()) { + throw new Error( + `The workspace belongs to WSL distribution ${parsedCwd.distro}, not ${wslDistro}.` + ) + } + const linuxHomeDir = toLinuxPath(homeDir) + const cwd = parsedCwd?.linuxPath ?? (requestedCwd ? toLinuxPath(requestedCwd) : linuxHomeDir) + return { kind: 'wsl', distro: wslDistro, homeDir: linuxHomeDir, cwd } +} + +export async function discoverSkillsOnTarget( + target: ResolvedSkillDiscoveryTarget, + repos: readonly Repo[] +): Promise { + if (target.kind === 'wsl') { + return discoverSkillsInWsl({ + distro: target.distro, + homeDir: target.homeDir, + cwd: target.cwd + }) + } + return target.cwd + ? discoverSkills({ repos: [], cwd: target.cwd }) + : discoverSkills({ repos: [...repos] }) +} diff --git a/src/main/skills/skill-discovery-wsl-plugins.test.ts b/src/main/skills/skill-discovery-wsl-plugins.test.ts new file mode 100644 index 00000000000..fcb48befa33 --- /dev/null +++ b/src/main/skills/skill-discovery-wsl-plugins.test.ts @@ -0,0 +1,83 @@ +import { posix as pathPosix } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + +import { buildSkillDiscoverySources } from './skill-discovery-sources' +import { discoverSkillsInWsl } from './skill-discovery-wsl' + +function record(...fields: string[]): string { + return `${fields.join('\0')}\0` +} + +function completeExecFileCall(callIndex: number, stdout: string): void { + const callback = execFileMock.mock.calls[callIndex]?.[3] as + | ((error: Error | null, stdout: string) => void) + | undefined + callback?.(null, stdout) +} + +describe('WSL Claude plugin skill discovery', () => { + beforeEach(() => execFileMock.mockReset()) + + it('reads enabled plugin metadata and scans the selected install inside the distro', async () => { + const homeDir = '/home/alice' + const cwd = '/work/orca' + const pluginId = 'compound-engineering@compound-engineering-plugin' + const installPath = '/home/alice/.claude/plugins/cache/compound/3.14.3' + const installed = JSON.stringify({ + plugins: { + [pluginId]: [{ scope: 'project', projectPath: cwd, installPath }] + } + }) + const settings = JSON.stringify({ enabledPlugins: { [pluginId]: true } }) + const metadataOutput = [ + record('F', '0', '1', Buffer.from(installed).toString('base64')), + record('F', '1', '1', Buffer.from(settings).toString('base64')), + record('F', '2', '0', ''), + record('F', '3', '0', '') + ].join('') + const baseRootCount = buildSkillDiscoverySources({ + homeDir, + cwd, + repos: [], + pathApi: pathPosix + }).length + const skillPath = `${installPath}/skills/ce-plan/SKILL.md` + const markdown = Buffer.from('---\nname: ce-plan\ndescription: Plan work.\n---\n').toString( + 'base64' + ) + const scanOutput = [ + record('R', String(baseRootCount), '1'), + record('S', String(baseRootCount), skillPath, skillPath, '1700000000', '2', markdown) + ].join('') + execFileMock.mockImplementationOnce((..._args: unknown[]) => { + queueMicrotask(() => completeExecFileCall(0, metadataOutput)) + }) + execFileMock.mockImplementationOnce((..._args: unknown[]) => { + queueMicrotask(() => completeExecFileCall(1, scanOutput)) + }) + + const result = await discoverSkillsInWsl({ distro: 'Ubuntu', homeDir, cwd }) + + expect(execFileMock).toHaveBeenCalledTimes(2) + const scanArgs = execFileMock.mock.calls[1]?.[1] as string[] + const encoded = /printf %s '([^']+)'/.exec(scanArgs[5] ?? '')?.[1] + const scanScript = Buffer.from(encoded ?? '', 'base64').toString('utf8') + expect(scanScript).toContain(`${installPath}/skills`) + expect(result.skills).toEqual([ + expect.objectContaining({ + name: 'ce-plan', + sourceKind: 'plugin', + rootPath: `${installPath}/skills` + }) + ]) + expect(result.sources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: `${installPath}/skills`, owner: 'claude', exists: true }) + ]) + ) + }) +}) diff --git a/src/main/skills/skill-discovery-wsl.test.ts b/src/main/skills/skill-discovery-wsl.test.ts new file mode 100644 index 00000000000..621b9f4c0a3 --- /dev/null +++ b/src/main/skills/skill-discovery-wsl.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import type { SkillScanRoot } from './skill-discovery-sources' +import { buildWslSkillDiscoveryCommand, parseWslSkillDiscoveryOutput } from './skill-discovery-wsl' + +const homeRoot: SkillScanRoot = { + id: 'home-codex', + label: 'Codex home', + path: '/home/alice/.codex/skills', + sourceKind: 'home', + providers: ['codex'], + owner: 'codex' +} +const repoRoot: SkillScanRoot = { + id: 'repo-agents', + label: 'Repo project .agents', + path: '/work/project/.agents/skills', + sourceKind: 'repo', + providers: ['agent-skills'], + owner: null +} + +function record(...fields: string[]): string { + return `${fields.join('\0')}\0` +} + +describe('WSL skill discovery', () => { + it('parses distro-native metadata and deduplicates canonical skill paths', () => { + const markdown = Buffer.from( + '---\nname: Review\ndescription: Review this change\n---\n', + 'utf8' + ).toString('base64') + const output = [ + record('R', '0', '1'), + record('R', '1', '0'), + record( + 'S', + '0', + '/home/alice/.codex/skills/.system/review/SKILL.md', + '/opt/orca/review/SKILL.md', + '1700000000', + '7', + markdown + ), + record( + 'S', + '1', + '/work/project/.agents/skills/review/SKILL.md', + '/opt/orca/review/SKILL.md', + '1700000001', + '9', + markdown + ) + ].join('') + + const result = parseWslSkillDiscoveryOutput(output, [homeRoot, repoRoot], 42) + + expect(result.scannedAt).toBe(42) + expect(result.skills).toEqual([ + expect.objectContaining({ + name: 'Review', + description: 'Review this change', + sourceKind: 'bundled', + rootPath: homeRoot.path, + skillFilePath: '/home/alice/.codex/skills/.system/review/SKILL.md', + fileCount: 7, + updatedAt: 1_700_000_000_000 + }) + ]) + expect(result.sources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'home-codex', exists: true }), + expect.objectContaining({ id: 'repo-agents', exists: false, skippedReason: 'missing' }) + ]) + ) + }) + + it('builds a distro-side scan for enumeration, reads, and canonical identity', () => { + const command = buildWslSkillDiscoveryCommand([ + { ...repoRoot, path: "/work/alice's project/.agents/skills" } + ]) + const encoded = /printf %s '([^']+)'/.exec(command)?.[1] + expect(encoded).toBeTruthy() + const script = Buffer.from(encoded!, 'base64').toString('utf8') + + expect(script).toContain('find -L "$root_path"') + expect(script).toContain('realpath -- "$skill_file"') + expect(script).toContain('head -c 262144 -- "$skill_file"') + expect(script).toContain(`'/work/alice'\\''s project/.agents/skills'`) + }) + + it('rejects malformed host responses instead of reporting an empty scan', () => { + expect(() => parseWslSkillDiscoveryOutput(record('S', '9'), [homeRoot])).toThrow( + 'unknown source' + ) + }) +}) diff --git a/src/main/skills/skill-discovery-wsl.ts b/src/main/skills/skill-discovery-wsl.ts new file mode 100644 index 00000000000..c4f553225ae --- /dev/null +++ b/src/main/skills/skill-discovery-wsl.ts @@ -0,0 +1,212 @@ +import { execFile } from 'node:child_process' +import { posix as pathPosix } from 'node:path' +import { summarizeSkillMarkdown } from '../../shared/skill-metadata' +import type { + DiscoveredSkill, + SkillDiscoveryResult, + SkillDiscoverySource +} from '../../shared/skills' +import { buildEncodedWslBashCommand, quoteBashString } from '../wsl-bash-command' +import { + buildSkillDiscoverySources, + compareSkills, + sourceKindForSkill, + sourceLabelForSkill, + stablePathId, + type SkillScanRoot +} from './skill-discovery-sources' +import { discoverClaudePluginSkillSourcesInWsl } from './claude-plugin-skill-sources-wsl' + +const MAX_MARKDOWN_BYTES = 256 * 1024 +const MAX_PACKAGE_FILES = 200 +const WSL_SCAN_TIMEOUT_MS = 10_000 +const WSL_SCAN_MAX_BUFFER_BYTES = 128 * 1024 * 1024 + +export function buildWslSkillDiscoveryCommand(roots: readonly SkillScanRoot[]): string { + const lines = [ + 'set -u', + 'set -o pipefail', + 'scan_root() {', + ' root_index=$1', + ' root_path=$2', + ' max_depth=$3', + ' if [ ! -d "$root_path" ]; then', + ` printf '%s\\0%s\\0%s\\0' R "$root_index" 0`, + ' return', + ' fi', + ` printf '%s\\0%s\\0%s\\0' R "$root_index" 1`, + ` while IFS= read -r -d '' skill_file; do`, + ` canonical_path=$(realpath -- "$skill_file" 2>/dev/null || printf '%s' "$skill_file")`, + ` directory_path=\${skill_file%/*}`, + ` updated_at=$(stat -c '%Y' -- "$skill_file" 2>/dev/null || true)`, + ` encoded_markdown=$(head -c ${MAX_MARKDOWN_BYTES} -- "$skill_file" 2>/dev/null | base64 | tr -d '\\n') || continue`, + ' file_count=0', + ` while IFS= read -r -d '' package_file; do`, + ' file_count=$((file_count + 1))', + ` [ "$file_count" -ge ${MAX_PACKAGE_FILES} ] && break`, + ` done < <(find -L "$directory_path" -type f -print0 2>/dev/null)`, + ` printf '%s\\0%s\\0%s\\0%s\\0%s\\0%s\\0' S "$root_index" "$skill_file" "$canonical_path" "$updated_at" "$file_count"`, + ` printf '%s' "$encoded_markdown"`, + ` printf '\\0'`, + ` done < <(find -L "$root_path" -mindepth 1 -maxdepth "$max_depth" -type f -name 'SKILL.md' -print0 2>/dev/null)`, + '}' + ] + roots.forEach((root, index) => { + const maxDepth = root.sourceKind === 'plugin' ? 10 : 5 + lines.push(`scan_root ${index} ${quoteBashString(root.path)} ${maxDepth}`) + }) + return buildEncodedWslBashCommand(lines.join('\n')) +} + +function executeWslSkillDiscovery(distro: string, command: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'wsl.exe', + ['-d', distro, '--', 'bash', '-c', command], + { + encoding: 'utf8', + maxBuffer: WSL_SCAN_MAX_BUFFER_BYTES, + timeout: WSL_SCAN_TIMEOUT_MS, + windowsHide: true + }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} + +function readProtocolField(fields: string[], index: number): string { + const value = fields[index] + if (value === undefined) { + throw new Error('WSL skill discovery returned an incomplete response.') + } + return value +} + +export function parseWslSkillDiscoveryOutput( + output: string, + roots: readonly SkillScanRoot[], + scannedAt = Date.now() +): SkillDiscoveryResult { + const fields = output.split('\0') + const rootExists = new Map() + const skillsByCanonicalPath = new Map() + let index = 0 + while (index < fields.length && fields[index]) { + const recordKind = fields[index++] + const rootIndex = Number.parseInt(readProtocolField(fields, index++), 10) + const root = roots[rootIndex] + if (!root) { + throw new Error('WSL skill discovery returned an unknown source.') + } + if (recordKind === 'R') { + rootExists.set(rootIndex, readProtocolField(fields, index++) === '1') + continue + } + if (recordKind !== 'S') { + throw new Error('WSL skill discovery returned an invalid response.') + } + + const skillFilePath = readProtocolField(fields, index++) + const canonicalSkillFilePath = readProtocolField(fields, index++) + const updatedAtSeconds = Number.parseInt(readProtocolField(fields, index++), 10) + const fileCount = Number.parseInt(readProtocolField(fields, index++), 10) + const markdown = Buffer.from(readProtocolField(fields, index++), 'base64').toString('utf8') + const existing = skillsByCanonicalPath.get(canonicalSkillFilePath) + if (existing) { + // Why: dedup keeps one row, but every contributing root must survive so + // per-agent visibility does not depend on root scan order. providers is + // per-agent visibility too, so union it rather than keeping only the first. + if (existing.rootPaths && !existing.rootPaths.includes(root.path)) { + existing.rootPaths.push(root.path) + } + // Reassign a fresh array — `providers` aliases the scan root's array, so + // pushing in place would mutate the root and sibling skills/sources. + const mergedProviders = [...existing.providers] + for (const provider of root.providers) { + if (!mergedProviders.includes(provider)) { + mergedProviders.push(provider) + } + } + existing.providers = mergedProviders + continue + } + const directoryPath = pathPosix.dirname(skillFilePath) + const summary = summarizeSkillMarkdown(markdown) + const sourceKind = sourceKindForSkill(root, skillFilePath, pathPosix) + skillsByCanonicalPath.set(canonicalSkillFilePath, { + id: stablePathId(canonicalSkillFilePath), + name: summary.name ?? pathPosix.basename(directoryPath), + description: summary.description, + // Copy: `root.providers` is shared across every skill/source from this + // root, so a later in-place merge must not mutate the aliased array. + providers: [...root.providers], + sourceKind, + sourceLabel: sourceLabelForSkill(root, sourceKind), + rootPath: root.path, + rootPaths: [root.path], + directoryPath, + skillFilePath, + installed: true, + fileCount: Number.isFinite(fileCount) ? fileCount : 0, + updatedAt: Number.isFinite(updatedAtSeconds) ? updatedAtSeconds * 1000 : null + }) + } + + const sources: SkillDiscoverySource[] = roots.map((root, rootIndex) => { + const exists = rootExists.get(rootIndex) ?? false + return { + ...root, + providers: [...root.providers], + exists, + skippedReason: exists ? undefined : 'missing' + } + }) + return { + skills: [...skillsByCanonicalPath.values()].sort(compareSkills), + sources: sources.sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + ), + scannedAt + } +} + +export async function discoverSkillsInWsl(args: { + distro: string + homeDir: string + cwd: string +}): Promise { + // Plugin roots are resolved (in JS) from metadata this first wsl.exe call + // reads, then fed to the scan's own wsl.exe call below — two sequential + // process boots. That is a deliberate one-time-per-pane cost (the renderer + // caches per pane); folding both into one invocation would require porting + // the plugin-install resolution into bash, which is not worth the risk. + // + // Why: plugin-metadata enrichment is optional. A failed/timed-out read must + // degrade to zero plugin roots (matching the native readMetadataFile path), + // not abort the mandatory native/home/repo/bundled scan. + let pluginRoots: SkillScanRoot[] = [] + try { + pluginRoots = await discoverClaudePluginSkillSourcesInWsl(args) + } catch { + pluginRoots = [] + } + const roots = [ + ...buildSkillDiscoverySources({ + homeDir: args.homeDir, + cwd: args.cwd, + repos: [], + pathApi: pathPosix + }), + ...pluginRoots + ] + // Why: UNC traversal applies Windows casing and symlink rules. The distro + // must own enumeration, metadata reads, and canonical path identity. + const output = await executeWslSkillDiscovery(args.distro, buildWslSkillDiscoveryCommand(roots)) + return parseWslSkillDiscoveryOutput(output, roots) +} diff --git a/src/main/skills/skill-freshness-inventory-limits.test.ts b/src/main/skills/skill-freshness-inventory-limits.test.ts index c43d137fcd0..b818f968238 100644 --- a/src/main/skills/skill-freshness-inventory-limits.test.ts +++ b/src/main/skills/skill-freshness-inventory-limits.test.ts @@ -14,7 +14,8 @@ describe('skill freshness inventory limits', () => { label: `Repo ${index}`, path: `/repo-${index}/.agents/skills`, sourceKind: 'repo', - providers: ['agent-skills'] + providers: ['agent-skills'], + owner: null }) ) diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index a7d22ad48ae..1a6ea4a4368 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -6,24 +6,24 @@ import type { } from '../../shared/hosted-review' import { getAzureDevOpsPullRequest, - getAzureDevOpsPullRequestForBranch, + getAzureDevOpsPullRequestForBranchOrThrow, getAzureDevOpsRepoSlug } from '../azure-devops/client' import { createAzureDevOpsPullRequest } from '../azure-devops/pull-request-creation' import { getBitbucketPullRequest, - getBitbucketPullRequestForBranch, + getBitbucketPullRequestForBranchOrThrow, getBitbucketRepoSlug } from '../bitbucket/client' import { getGiteaPullRequest, - getGiteaPullRequestForBranch, + getGiteaPullRequestForBranchOrThrow, getGiteaRepoSlug } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client' import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' -import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' +import { getMergeRequest, getMergeRequestForBranchOrThrow, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { mapAzureDevOpsReview, @@ -86,7 +86,10 @@ const gitLabForgeProvider = { resolveRepository: (context) => getProjectSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { - const mr = await getMergeRequestForBranch( + // Why: throw (not null) on a real lookup failure so eligibility records + // `unavailable`, never a false "No merge request found" — same contract the + // GitHub adapter uses so hosted-review callers preserve last-known state. + const mr = await getMergeRequestForBranchOrThrow( input.repoPath, input.branch, input.linkedReviewNumber ?? null, @@ -188,7 +191,9 @@ const bitbucketForgeProvider = { ...hostedReviewExecutionArgs(context) ), async getReviewForBranch(input) { - const pr = await getBitbucketPullRequestForBranch( + // Why: surface a real lookup failure so eligibility records `unavailable` + // instead of a false "No pull request found". + const pr = await getBitbucketPullRequestForBranchOrThrow( input.repoPath, input.branch, input.linkedReviewNumber ?? null, @@ -218,7 +223,9 @@ const azureDevOpsForgeProvider = { ...hostedReviewExecutionArgs(context) ), async getReviewForBranch(input) { - const pr = await getAzureDevOpsPullRequestForBranch( + // Why: surface a real lookup failure so eligibility records `unavailable` + // instead of a false "No pull request found". + const pr = await getAzureDevOpsPullRequestForBranchOrThrow( input.repoPath, input.branch, input.linkedReviewNumber ?? null, @@ -245,7 +252,9 @@ const giteaForgeProvider = { resolveRepository: (context) => getGiteaRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { - const pr = await getGiteaPullRequestForBranch( + // Why: surface a real lookup failure so eligibility records `unavailable` + // instead of a false "No pull request found". + const pr = await getGiteaPullRequestForBranchOrThrow( input.repoPath, input.branch, input.linkedReviewNumber ?? null, diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts index 010d950d50f..484ede6cbff 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -115,7 +115,7 @@ vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) -import { getHostedReviewCreationEligibility } from './hosted-review-creation' +import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' function resetMocks(): void { for (const mock of [ @@ -232,6 +232,96 @@ describe('getHostedReviewCreationEligibility', () => { }) }) + it('reports reviewLookupOutcome: found when an existing review is returned', async () => { + getHostedReviewForBranchMock.mockResolvedValue({ number: 7, url: 'https://x/pull/7' }) + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/x', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ blockedReason: 'existing_review', reviewLookupOutcome: 'found' }) + }) + + it('reports reviewLookupOutcome: not_found when the lookup accepts no review', async () => { + getHostedReviewForBranchMock.mockResolvedValue(null) + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/x', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ reviewLookupOutcome: 'not_found' }) + }) + + it('reports reviewLookupOutcome: unavailable when a swallowed lookup failure yields a local blocker', async () => { + getHostedReviewForBranchMock.mockRejectedValue(new Error('ssh: connection refused')) + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/x', + hasUncommittedChanges: false, + hasUpstream: false, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ blockedReason: 'no_upstream', reviewLookupOutcome: 'unavailable' }) + }) + + it('never marks a clean, in-sync branch creatable when the review lookup fails', async () => { + // A failed existing-review lookup leaves review existence unproven, so the + // happy path must not claim canCreate — that would offer Create against a + // branch that might already have a review (finding 4). + getHostedReviewForBranchMock.mockRejectedValue(new Error('gh: could not connect to github.com')) + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/x', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: null, + reviewLookupOutcome: 'unavailable' + }) + }) + + it('refuses to create when the existing-review lookup is unavailable (finding 5)', async () => { + getHostedReviewForBranchMock.mockRejectedValue(new Error('glab: connection refused')) + getUpstreamStatusMock.mockResolvedValue({ hasUpstream: true, ahead: 0, behind: 0 }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'feature/x\n', stderr: '' } + } + if (args[0] === 'status') { + return { stdout: '', stderr: '' } + } + // symbolic-ref / for-each-ref resolve the base on the remote. + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + }) + + const result = await createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature/x', + title: 'Add feature' + }) + + expect(result).toMatchObject({ ok: false, code: 'validation' }) + // The provider create API must never run on an inconclusive lookup. + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + // Stacked-worktree base resolution (Change 1/2). `stackedArgs` defaults to a // bare local-only parent; `mockRefs` controls the remote-tracking snapshot. const stackedArgs = ( diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 1e19f1d4fa8..a0271d0ea3d 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -6,6 +6,7 @@ import type { HostedReviewCreationBlockedReason, HostedReviewCreationEligibility, HostedReviewCreationEligibilityArgs, + HostedReviewLookupOutcome, HostedReviewProvider } from '../../shared/hosted-review' import { @@ -435,6 +436,18 @@ async function validateCurrentBranchCanCreateReview( enforceBaseOnRemote: true, ...options }) + // Why: never call the provider create API when the existing-review lookup + // could not prove the branch has no review. An `unavailable` outcome means a + // real MR/PR might exist that a failed lookup could not see; refuse + // inconclusively (design invariant 8 / submission boundary) so we never + // create a duplicate. The composed title/body are preserved by the caller. + if (eligibility.reviewLookupOutcome === 'unavailable') { + return { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: Orca could not confirm whether this branch already has a ${copy.reviewLabel}. Retry once the ${copy.providerName} lookup succeeds.` + } + } // Why: renderer eligibility can be stale by submit time; the main process // is the last chance to avoid creating a PR from an out-of-date remote head. return blockedEligibilityToCreateResult(eligibility, submittedBase) @@ -475,6 +488,10 @@ export async function getHostedReviewCreationEligibility( } const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null let review: Awaited> = null + // Why: a swallowed lookup failure must not masquerade as authoritative + // no-review evidence. Track it so the renderer can distinguish an accepted + // no-PR result (`not_found`) from a local-blocker fallback (`unavailable`). + let lookupFailed = false try { review = await getHostedReviewForBranch({ repoPath: args.repoPath, @@ -489,23 +506,25 @@ export async function getHostedReviewCreationEligibility( ...hostedReviewExecutionContext(args) }) } catch (error) { - const canReturnLocalBlocker = - branch && - branch !== 'HEAD' && - supportsHostedReviewCreation(provider) && - (!baseBranch || branch.toLowerCase() !== baseBranch.toLowerCase()) && - (args.hasUncommittedChanges || args.hasUpstream !== true || (args.behind ?? 0) > 0) - if (!canReturnLocalBlocker) { - throw error - } - // Why: local blockers still let the UI offer Create PR preparation; a - // flaky existing-review lookup should not hide the affordance entirely. - console.warn('Hosted review lookup failed while resolving local review blocker:', error) + // Why: a failed existing-review lookup can never authorize Create — it might + // be hiding a real PR. Record `unavailable` and fall through so any local + // blocker (dirty/no-upstream/behind) still supplies primary guidance, but the + // clean-branch happy path below is forced to `canCreate: false`. Never rethrow + // here: a thrown lookup must not collapse into a false `not_found` and must + // stay a structured, honest empty state for the renderer. + lookupFailed = true + console.warn('Hosted review lookup failed; treating existing-review as unavailable:', error) } + const reviewLookupOutcome: HostedReviewLookupOutcome = review + ? 'found' + : lookupFailed + ? 'unavailable' + : 'not_found' const baseResult = { provider, review: review ? { number: review.number, url: review.url } : null, + reviewLookupOutcome, defaultBaseRef, head: branch || null } @@ -575,7 +594,15 @@ export async function getHostedReviewCreationEligibility( nextAction: null } } - return { ...baseResult, canCreate: Boolean(baseBranch), blockedReason: null, nextAction: null } + // Why: a lookup that failed leaves review existence unproven, so the clean, + // in-sync happy path must not claim `canCreate` — that would open Create + // against a branch that may already have a review. Stays `unavailable`. + return { + ...baseResult, + canCreate: lookupFailed ? false : Boolean(baseBranch), + blockedReason: null, + nextAction: null + } } export async function createHostedReview( diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts index 51c5ee8d927..102ebfebdbb 100644 --- a/src/main/source-control/hosted-review.test.ts +++ b/src/main/source-control/hosted-review.test.ts @@ -27,6 +27,9 @@ const { vi.mock('../gitlab/client', () => ({ getProjectSlug: getProjectSlugMock, getMergeRequestForBranch: getMergeRequestForBranchMock, + // Why: forge-provider resolves branch reviews via the OrThrow variant so + // lookup failures surface as unavailable instead of "no PR found". + getMergeRequestForBranchOrThrow: getMergeRequestForBranchMock, getMergeRequest: vi.fn() })) @@ -39,18 +42,27 @@ vi.mock('../github/client', () => ({ vi.mock('../bitbucket/client', () => ({ getBitbucketRepoSlug: getBitbucketRepoSlugMock, getBitbucketPullRequestForBranch: getBitbucketPullRequestForBranchMock, + // Why: forge-provider resolves branch reviews via the OrThrow variant so + // lookup failures surface as unavailable instead of "no PR found". + getBitbucketPullRequestForBranchOrThrow: getBitbucketPullRequestForBranchMock, getBitbucketPullRequest: vi.fn() })) vi.mock('../azure-devops/client', () => ({ getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, getAzureDevOpsPullRequestForBranch: getAzureDevOpsPullRequestForBranchMock, + // Why: forge-provider resolves branch reviews via the OrThrow variant so + // lookup failures surface as unavailable instead of "no PR found". + getAzureDevOpsPullRequestForBranchOrThrow: getAzureDevOpsPullRequestForBranchMock, getAzureDevOpsPullRequest: vi.fn() })) vi.mock('../gitea/client', () => ({ getGiteaRepoSlug: getGiteaRepoSlugMock, getGiteaPullRequestForBranch: getGiteaPullRequestForBranchMock, + // Why: forge-provider resolves branch reviews via the OrThrow variant so + // lookup failures surface as unavailable instead of "no PR found". + getGiteaPullRequestForBranchOrThrow: getGiteaPullRequestForBranchMock, getGiteaPullRequest: vi.fn() })) diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 91ee554dff4..d6c98bf03a0 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -210,6 +210,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { session = null relay = null agentHookServer.setListener(null) + agentHookServer.setPaneStatusClearListener(null) agentHookInternals.resetCachesForTests() warnSpy.mockRestore() if (previousRemoteHooksFlag === undefined) { @@ -272,6 +273,34 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { }) }) + it('clears stamped status on reconnect loss but not final shutdown', async () => { + const initialRelay = createFakeRelay() + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay) + .mockResolvedValueOnce({ transport: initialRelay.transport, platform: 'linux-x64' }) + .mockResolvedValueOnce({ transport: relay.transport, platform: 'linux-x64' }) + const clearListener = vi.fn() + agentHookServer.setPaneStatusClearListener(clearListener) + session = createSession('conn-clear') + await session.establish({} as SshConnection) + initialRelay.notifyAgentHook(makeEnvelope()) + await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1)) + + await session.reconnect({} as SshConnection) + initialRelay.dispose() + + expect(agentHookServer.getStatusSnapshot()).toEqual([]) + expect(clearListener).toHaveBeenCalledOnce() + expect(clearListener).toHaveBeenCalledWith({ + transient: true, + connectionId: 'conn-clear', + clearedAt: expect.any(Number) + }) + session.dispose() + session = null + expect(clearListener).toHaveBeenCalledOnce() + }) + it('asks the fake relay for cached hook replay after the session wires its listener', async () => { relay = createFakeRelay() relay.replayEnvelopes.push( diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index a03be4d2b6a..b9c41c84c1d 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1019,6 +1019,10 @@ export class SshRelaySession { if (reason === 'shutdown') { clearPtyOwnershipForConnection(this.targetId) + } else { + // Why: handlers are detached above, so no late event can recreate a + // stamped status between this clear and reconnect replay. + agentHookServer.clearStatusEntriesForConnection(this.targetId) } const ptyProvider = getSshPtyProvider(this.targetId) diff --git a/src/main/telemetry/validator.test.ts b/src/main/telemetry/validator.test.ts index 031df8d208f..15ddfe9d638 100644 --- a/src/main/telemetry/validator.test.ts +++ b/src/main/telemetry/validator.test.ts @@ -165,7 +165,7 @@ describe('validate', () => { expect(result.ok).toBe(true) }) - // ── repo_added.is_git_repo (docs/reference/telemetry-availability.md) + // ── repo_added.is_git_repo // The git-vs-folder signal moved here from onboarding_completed once project // selection left onboarding. Optional so SSH/remote edges can omit it. diff --git a/src/main/wsl-bash-command.ts b/src/main/wsl-bash-command.ts index ff7426ae6ec..1b561a1182f 100644 --- a/src/main/wsl-bash-command.ts +++ b/src/main/wsl-bash-command.ts @@ -1,4 +1,5 @@ -function quoteShell(value: string): string { +/** Single-quote a value for safe interpolation into a Bash command line. */ +export function quoteBashString(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } @@ -6,5 +7,5 @@ export function buildEncodedWslBashCommand(command: string): string { // Why: wsl.exe preprocesses `$local_shell_vars` in command arguments before // Bash sees them. Base64 keeps validation scripts intact across that boundary. const encoded = Buffer.from(command, 'utf8').toString('base64') - return `set -o pipefail; printf %s ${quoteShell(encoded)} | base64 -d | bash` + return `set -o pipefail; printf %s ${quoteBashString(encoded)} | base64 -d | bash` } diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 1d27b694d2c..504ac981010 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -1,5 +1,7 @@ import { execFile, execFileSync } from 'node:child_process' -import { parseWslUncPath } from '../shared/wsl-paths' +import { parseWslUncPath, toWindowsWslPath } from '../shared/wsl-paths' + +export { toWindowsWslPath } from '../shared/wsl-paths' export type WslPathInfo = { distro: string @@ -89,26 +91,6 @@ export function toLinuxPath(windowsPath: string): string { return `/mnt/${driveLetter}/${rest}` } -/** - * Convert a Linux path inside a WSL distro to a Windows path. - * - * Why two forms: paths under /mnt//... are Windows-native filesystem - * paths that WSL exposes via the DrvFs mount. These map back to their native - * Windows form (e.g. /mnt/c/Users → C:\Users). All other paths live on the - * WSL virtual filesystem and use the UNC form (\\wsl.localhost\Distro\...). - */ -export function toWindowsWslPath(linuxPath: string, distro: string): string { - // /mnt/c/Users/... → C:\Users\... - const mntMatch = linuxPath.match(/^\/mnt\/([a-z])(\/.*)?$/) - if (mntMatch) { - const driveLetter = mntMatch[1].toUpperCase() - const rest = (mntMatch[2] || '').replace(/\//g, '\\') - return `${driveLetter}:${rest || '\\'}` - } - - return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` -} - // ─── WSL home directory resolution ────────────────────────────────── const wslHomeCache = new Map() diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 7d149226aa2..487f7b2e0dd 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -304,6 +304,7 @@ import type { CliInstallStatus } from '../shared/cli-install-types' import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { + AgentStatusClearIpcPayload, AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' @@ -1449,7 +1450,7 @@ export type PreloadApi = { * the model snapshot. Never delivered in-band on pty:data. */ onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void /** Batched derived side-effect facts for PTYs whose bytes transit local - * main; see docs/reference/terminal-side-effect-authority.md. */ + * main. */ onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void /** Title-only replay snapshot for (re)attach; attention facts never replay. */ getSideEffectSnapshot: (id: string) => Promise @@ -3212,8 +3213,8 @@ export type PreloadApi = { agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void - /** Listen for main-process pane teardown that evicted a cached hook status. */ - onClear: (callback: (data: { paneKey: string }) => void) => () => void + /** Listen for main-process cleanup that evicted cached hook status. */ + onClear: (callback: (data: AgentStatusClearIpcPayload) => void) => () => void /** Return the current main-process hook cache after renderer hydration. */ getSnapshot: () => Promise inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise @@ -3256,6 +3257,9 @@ export type PreloadApi = { pairingUrl: string endpoint: string deviceId: string + /** Mode the QR actually encodes; 'local-only' when an automatic + * request degraded because Relay could not be attached. */ + connectionMode: MobilePairingConnectionMode } > getWindowsFirewallStatus: (args?: { address?: string }) => Promise< diff --git a/src/preload/index.ts b/src/preload/index.ts index 65985ef0afb..5571f9349b9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -142,6 +142,7 @@ import type { EnrichedDetectedPort } from '../shared/ssh-types' import type { + AgentStatusClearIpcPayload, AgentStatusIpcPayload, MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' @@ -4344,6 +4345,7 @@ const api = { pairingUrl: string endpoint: string deviceId: string + connectionMode: MobilePairingConnectionMode } > => ipcRenderer.invoke('mobile:getPairingQR', args), @@ -4402,8 +4404,8 @@ const api = { ipcRenderer.on('agentStatus:set', listener) return () => ipcRenderer.removeListener('agentStatus:set', listener) }, - onClear: (callback: (data: { paneKey: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { paneKey: string }) => + onClear: (callback: (data: AgentStatusClearIpcPayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: AgentStatusClearIpcPayload) => callback(data) ipcRenderer.on('agentStatus:clear', listener) return () => ipcRenderer.removeListener('agentStatus:clear', listener) diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts index d464a64f894..b20565d823f 100644 --- a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts +++ b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts @@ -2,7 +2,7 @@ import type { CliInstallStatus } from '../../../../shared/cli-install-types' import type { StepState } from '../settings/BrowserUseStepBadge' export function getMobileEmulatorCliPathNeedsAttention(status: CliInstallStatus | null): boolean { - return status?.state === 'installed' && !status.pathConfigured + return status?.state === 'installed' && status.pathConfigured === false } export function getMobileEmulatorCliStepBadgeState(input: { diff --git a/src/renderer/src/components/feature-tips/feature-tip-cli-install-action.ts b/src/renderer/src/components/feature-tips/feature-tip-cli-install-action.ts index e78e5695cdc..dbf4364808a 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-cli-install-action.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-cli-install-action.ts @@ -8,7 +8,7 @@ export async function installCliFromFeatureTip( installCli: () => Promise ): Promise { const status = await installCli() - if (status.state === 'installed' && status.pathConfigured) { + if (status.state === 'installed' && status.pathConfigured === true) { return { kind: 'installed', status } } return { kind: 'needs-attention', status } diff --git a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts index ea426225130..a7eef0b8ead 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts @@ -16,7 +16,7 @@ export type FeatureTipsAppOpenDecision = export function isCliFeatureTipCompleted(status: CliInstallStatus): boolean { // Why: unsupported launch modes cannot complete setup, but an installed // launcher still needs attention until it is reachable on PATH. - return !status.supported || (status.state === 'installed' && status.pathConfigured) + return !status.supported || (status.state === 'installed' && status.pathConfigured === true) } export function getFeatureTipsAppOpenDecision(args: { diff --git a/src/renderer/src/components/mobile/MobileHero.test.tsx b/src/renderer/src/components/mobile/MobileHero.test.tsx index 4026b03b3b3..04fff8c9092 100644 --- a/src/renderer/src/components/mobile/MobileHero.test.tsx +++ b/src/renderer/src/components/mobile/MobileHero.test.tsx @@ -57,7 +57,10 @@ describe('HeroFlow height', () => { } }) - function renderFlow(stepIdx: StepIndex) { + function renderFlow( + stepIdx: StepIndex, + overrides: Partial> = {} + ) { return render( { onCopyInstallUrl={vi.fn()} pairQrDataUrl={null} pairingUrl={null} + relayDegraded={false} pairLoading={false} connectionMode="automatic" onConnectionModeChange={vi.fn()} onRegeneratePairing={vi.fn()} + canGeneratePairing onCopyPairingCode={vi.fn()} networkInterfaces={[]} selectedAddress={undefined} @@ -83,6 +88,7 @@ describe('HeroFlow height', () => { refreshingNetworkInterfaces={false} onBack={vi.fn()} onContinue={vi.fn()} + {...overrides} /> ) } @@ -106,10 +112,12 @@ describe('HeroFlow height', () => { onCopyInstallUrl={vi.fn()} pairQrDataUrl={null} pairingUrl={null} + relayDegraded={false} pairLoading={false} connectionMode="automatic" onConnectionModeChange={vi.fn()} onRegeneratePairing={vi.fn()} + canGeneratePairing onCopyPairingCode={vi.fn()} networkInterfaces={[]} selectedAddress={undefined} @@ -124,4 +132,20 @@ describe('HeroFlow height', () => { expect(viewport).toHaveStyle({ height: '520px' }) expect(screen.getByText('Step 1 of 2').closest('.mp-flow-screen')).toHaveAttribute('inert') }) + + it('flags a degraded Anywhere code and always shows the Relay beta note', () => { + renderFlow(1, { + pairQrDataUrl: 'data:image/png;base64,qr', + relayDegraded: true + }) + expect(screen.getByTestId('relay-degraded-notice')).toHaveTextContent( + 'only works on your local network' + ) + expect(screen.getByText('Orca Relay is in beta.')).toBeInTheDocument() + }) + + it('hides the degradation notice when the code encodes what was selected', () => { + renderFlow(1, { pairQrDataUrl: 'data:image/png;base64,qr' }) + expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() + }) }) diff --git a/src/renderer/src/components/mobile/MobileHero.tsx b/src/renderer/src/components/mobile/MobileHero.tsx index aace116e811..335a691a020 100644 --- a/src/renderer/src/components/mobile/MobileHero.tsx +++ b/src/renderer/src/components/mobile/MobileHero.tsx @@ -1,10 +1,11 @@ import { useLayoutEffect, useRef, useState } from 'react' -import { ArrowLeft, ArrowRight, Copy, RefreshCw } from 'lucide-react' +import { ArrowLeft, ArrowRight, CircleAlert, Copy, RefreshCw } from 'lucide-react' import { cn } from '../../lib/utils' import type { MobileNetworkInterface } from '../settings/mobile-network-interface-selection' import { AndroidLogo, IosBrandIcon } from './MobileBrandIcons' import { NetworkInterfacePicker } from './NetworkInterfacePicker' import { MobilePairingConnectionOptions } from '../settings/MobilePairingConnectionOptions' +import { MobileRelayBetaNotice } from '../settings/MobileRelayBetaNotice' import { getChannelTagline, type InstallCopy, type IosChannel } from './mobile-platform-copy' import { WindowsFirewallNotice } from './WindowsFirewallNotice' import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' @@ -39,10 +40,13 @@ type HeroFlowProps = { onCopyInstallUrl: () => void pairQrDataUrl: string | null pairingUrl: string | null + /** True when the shown QR degraded to local-only under an Anywhere selection. */ + relayDegraded: boolean pairLoading: boolean connectionMode: MobilePairingConnectionMode onConnectionModeChange: (mode: MobilePairingConnectionMode) => void onRegeneratePairing: () => void + canGeneratePairing: boolean onCopyPairingCode: () => void networkInterfaces: readonly MobileNetworkInterface[] selectedAddress: string | undefined @@ -66,10 +70,12 @@ export function HeroFlow({ onCopyInstallUrl, pairQrDataUrl, pairingUrl, + relayDegraded, pairLoading, connectionMode, onConnectionModeChange, onRegeneratePairing, + canGeneratePairing, onCopyPairingCode, networkInterfaces, selectedAddress, @@ -243,6 +249,7 @@ export function HeroFlow({ onChange={onConnectionModeChange} compact /> +
{pairLoading ? translate('auto.components.mobile.MobileHero.65b3f2e8bc', 'Generating…') @@ -278,6 +288,18 @@ export function HeroFlow({ ? translate('auto.components.mobile.MobileHero.e59a252eca', 'Regenerate code') : translate('auto.components.mobile.MobileHero.a6cffbbb0b', 'Generate code')} + {relayDegraded ? ( +

+ + {translate( + 'auto.components.mobile.MobileHero.relayDegradedNotice', + 'Relay couldn’t be reached — this code only works on your local network.' + )} +

+ ) : null}
diff --git a/src/renderer/src/components/mobile/MobilePage.test.tsx b/src/renderer/src/components/mobile/MobilePage.test.tsx index 2f56e664cb3..23d9feb286b 100644 --- a/src/renderer/src/components/mobile/MobilePage.test.tsx +++ b/src/renderer/src/components/mobile/MobilePage.test.tsx @@ -10,7 +10,7 @@ import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pair type StoreState = { closeMobilePage: () => void orcaProfileAuthStatus: { state: 'connected' | 'local' } - settings: { showMobileButton: boolean } + settings: { showMobileButton: boolean; mobilePairingConnectionMode?: MobilePairingConnectionMode } updateSettings: () => Promise } @@ -39,8 +39,10 @@ vi.mock('../settings/mobile-pairing-device-polling', () => ({ vi.mock('./MobilePageContent', () => ({ MobilePageContent: (props: { connectionMode: MobilePairingConnectionMode + canGeneratePairing: boolean enterFlow: () => void handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void + handleAddressChange: (address: string) => void handleContinue: () => void pairQrDataUrl: string | null pairingUrl: string | null @@ -51,6 +53,7 @@ vi.mock('./MobilePageContent', () => ({ {props.stage ?? 'loading'} {props.stepIdx} {props.connectionMode} + {String(props.canGeneratePairing)} {props.pairQrDataUrl ?? 'none'} {props.pairingUrl ?? 'none'} +
) @@ -110,14 +116,130 @@ describe('MobilePage pairing connection mode', () => { await user.click(screen.getByRole('button', { name: 'Continue' })) } - it('defaults signed-in pairing to local-only and rotates when Relay is selected', async () => { + it('defaults signed-in pairing to Anywhere and remints when same-network is selected', async () => { const user = userEvent.setup() await openPairingStep() + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' })) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,qr')) + expect(screen.getByTestId('mode')).toHaveTextContent('automatic') + + let resolveRotatedLocalQr: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRotatedLocalQr = resolve + }) + ) + await user.click(screen.getByRole('button', { name: 'Local network' })) + // No rotate flag: the main process rotates exactly once on the policy + // mismatch, so concurrent windows converge on the same fresh token. + await waitFor(() => + expect(getPairingQR).toHaveBeenLastCalledWith({ + connectionMode: 'local-only' + }) + ) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + // The prior Relay QR clears immediately so the old policy's code is never + // shown while the reminted local-only offer is still pending. + expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none') + expect(screen.getByTestId('pairing-url')).toHaveTextContent('none') + expect(mocks.storeState.updateSettings).toHaveBeenCalledWith({ + mobilePairingConnectionMode: 'local-only' + }) + + resolveRotatedLocalQr?.({ + available: true, + qrDataUrl: 'data:image/png;base64,local-qr', + pairingUrl: 'orca://pair#local' + }) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('local-qr')) + }) + + it('restores a saved local-only preference without user interaction', async () => { + mocks.storeState.settings = { + showMobileButton: true, + mobilePairingConnectionMode: 'local-only' + } + await openPairingStep() + + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'local-only' })) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + }) + + it('does not auto-mint any QR when signed out with Anywhere selected', async () => { + mocks.storeState.orcaProfileAuthStatus = { state: 'local' } + await openPairingStep() + + // Aligned with Settings: signed-out Anywhere cannot serve Relay, so we mint + // nothing rather than a scannable local-only QR under the Relay label. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(getPairingQR).not.toHaveBeenCalled() + expect(screen.getByTestId('mode')).toHaveTextContent('automatic') + expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none') + expect(screen.getByTestId('can-generate')).toHaveTextContent('false') + }) + + it('mints a local-only QR when switching to Local network while signed out', async () => { + mocks.storeState.orcaProfileAuthStatus = { state: 'local' } + const user = userEvent.setup() + await openPairingStep() + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(getPairingQR).not.toHaveBeenCalled() + + // Picking Local network is an honest local-only path, so a QR mints. + await user.click(screen.getByRole('button', { name: 'Local network' })) await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'local-only' })) await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,qr')) expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + }) + it('does not remint when switching from Local to Anywhere while signed out', async () => { + mocks.storeState.orcaProfileAuthStatus = { state: 'local' } + const user = userEvent.setup() + await openPairingStep() + + await user.click(screen.getByRole('button', { name: 'Local network' })) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,qr')) + getPairingQR.mockClear() + + // Switching back to Orca Relay must clear the local QR, not remint a + // local-only code under the Relay label. + await user.click(screen.getByRole('button', { name: 'Orca Relay' })) + await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('automatic')) + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none')) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(getPairingQR).not.toHaveBeenCalled() + expect(screen.getByTestId('can-generate')).toHaveTextContent('false') + }) + + it('does not mint on address change while signed out with Anywhere selected', async () => { + mocks.storeState.orcaProfileAuthStatus = { state: 'local' } + const user = userEvent.setup() + await openPairingStep() + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(getPairingQR).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'Change address' })) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(getPairingQR).not.toHaveBeenCalled() + expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none') + }) + + it('mints a Relay QR when signing in with Anywhere selected', async () => { + mocks.storeState.orcaProfileAuthStatus = { state: 'local' } + const user = userEvent.setup() + const { rerender } = render() + await waitFor(() => expect(screen.getByTestId('stage')).toHaveTextContent('intro')) + await user.click(screen.getByRole('button', { name: 'Enter flow' })) + await user.click(screen.getByRole('button', { name: 'Continue' })) + + // Signed-out Anywhere shows no QR at all. + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(getPairingQR).not.toHaveBeenCalled() + expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none') + + // Hold the sign-in mint pending so we can inspect the upgrade window. let resolveRelayQr: ((value: Record) => void) | undefined getPairingQR.mockImplementationOnce( () => @@ -125,31 +247,23 @@ describe('MobilePage pairing connection mode', () => { resolveRelayQr = resolve }) ) - await user.click(screen.getByRole('button', { name: 'Anywhere' })) - await waitFor(() => - expect(getPairingQR).toHaveBeenLastCalledWith({ - connectionMode: 'automatic', - rotate: true - }) - ) - expect(screen.getByTestId('mode')).toHaveTextContent('automatic') - expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,qr') + + // Signing in unlocks Relay, so Step 2 mints an honest Relay QR. + mocks.storeState.orcaProfileAuthStatus = { state: 'connected' } + rerender() + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' })) + // Between the auth flip and the mint resolving, no code may be shown — the + // QR must not flash a stale/optimistic value while the Relay offer is pending. + expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none') expect(screen.getByTestId('pairing-url')).toHaveTextContent('none') resolveRelayQr?.({ available: true, - qrDataUrl: 'data:image/png;base64,relay-qr', - pairingUrl: 'orca://pair#relay' + qrDataUrl: 'data:image/png;base64,qr', + pairingUrl: 'orca://pair#automatic' }) - await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('relay-qr')) - }) - - it('defaults signed-out pairing to local-only', async () => { - mocks.storeState.orcaProfileAuthStatus = { state: 'local' } - await openPairingStep() - - await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'local-only' })) - expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,qr')) + expect(screen.getByTestId('mode')).toHaveTextContent('automatic') }) it('removes the old QR if policy rotation fails', async () => { @@ -158,7 +272,7 @@ describe('MobilePage pairing connection mode', () => { await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('base64,qr')) getPairingQR.mockRejectedValueOnce(new Error('rotation failed')) - await user.click(screen.getByRole('button', { name: 'Anywhere' })) + await user.click(screen.getByRole('button', { name: 'Local network' })) await waitFor(() => expect(screen.getByTestId('pairing-qr')).toHaveTextContent('none')) expect(screen.getByTestId('pairing-url')).toHaveTextContent('none') diff --git a/src/renderer/src/components/mobile/MobilePage.tsx b/src/renderer/src/components/mobile/MobilePage.tsx index 4ed7bfc22ed..f50ceb070ce 100644 --- a/src/renderer/src/components/mobile/MobilePage.tsx +++ b/src/renderer/src/components/mobile/MobilePage.tsx @@ -12,7 +12,13 @@ import { translate } from '@/i18n/i18n' import { useMobilePageEscape } from './use-mobile-page-escape' import { MobilePageContent } from './MobilePageContent' import { useMobileInstallQr } from './use-mobile-install-qr' -import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import { + canMintMobilePairingOffer, + type MobilePairingConnectionMode +} from '../../../../shared/mobile-pairing-connection-mode' +import { useMobilePairingConnectionMode } from './use-mobile-pairing-connection-mode' +import { useMobilePairingGeneration } from './use-mobile-pairing-generation' +import { useMobilePairingQrInvalidation } from './use-mobile-pairing-qr-invalidation' import { useMobileInstallActions } from './use-mobile-install-actions' import { useMobilePagePairedDevices } from './use-mobile-page-paired-devices' @@ -26,11 +32,13 @@ export default function MobilePage(): React.JSX.Element { const [pairQrDataUrl, setPairQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) + // Mode the displayed QR actually encodes; can be 'local-only' under an + // Anywhere selection when Relay provisioning degraded server-side. + const [encodedConnectionMode, setEncodedConnectionMode] = + useState(null) const [pairLoading, setPairLoading] = useState(false) const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') - // Why: Relay is opt-in while compatible mobile builds are limited to the - // TestFlight preview and Android APK. - const [connectionMode, setConnectionMode] = useState('local-only') + const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode() const [networkInterfaces, setNetworkInterfaces] = useState([]) const [selectedAddress, setSelectedAddress] = useState(undefined) // Why: tracks whether `selectedAddress` came from the user typing a @@ -40,7 +48,6 @@ export default function MobilePage(): React.JSX.Element { const [refreshingNetworkInterfaces, setRefreshingNetworkInterfaces] = useState(false) const hasGeneratedRef = useRef(false) const pairingRequestIdRef = useRef(0) - const wasSignedInRef = useRef(signedIn) const mountedRef = useMountedRef() const closeMobilePage = useAppStore((s) => s.closeMobilePage) const showMobileButton = useAppStore((s) => s.settings?.showMobileButton !== false) @@ -58,95 +65,44 @@ export default function MobilePage(): React.JSX.Element { const installQrUrl = useMobileInstallQr(stage, platform, iosChannel) const { copyInstallUrl, openInstallUrl } = useMobileInstallActions(platform, iosChannel) - const generatePairing = useCallback( - async ( - rotate: boolean, - addressOverride?: string, - connectionModeOverride?: MobilePairingConnectionMode - ) => { - const requestId = ++pairingRequestIdRef.current - // Mark the request synchronously so state changes cannot make the - // Step 2 auto-generate effect start a second offer in parallel. - hasGeneratedRef.current = true - if (mountedRef.current) { - setPairLoading(true) - } - try { - const address = addressOverride ?? selectedAddress - const nextConnectionMode = connectionModeOverride ?? connectionMode - const result = await window.api.mobile.getPairingQR({ - ...(address ? { address } : {}), - connectionMode: nextConnectionMode, - ...(rotate ? { rotate: true } : {}) - }) - if (requestId !== pairingRequestIdRef.current) { - return - } - if (result.available) { - if (mountedRef.current) { - setPairQrDataUrl(result.qrDataUrl) - setPairingUrl(result.pairingUrl) - } - } else { - hasGeneratedRef.current = false - if (mountedRef.current) { - setPairQrDataUrl(null) - setPairingUrl(null) - toast.error( - translate( - 'auto.components.mobile.MobilePage.b353e18de1', - 'WebSocket transport is not running' - ) - ) - } - } - } catch { - if (mountedRef.current && requestId === pairingRequestIdRef.current) { - hasGeneratedRef.current = false - setPairQrDataUrl(null) - setPairingUrl(null) - toast.error( - translate( - 'auto.components.mobile.MobilePage.4c8bd11c1a', - 'Failed to generate pairing code' - ) - ) - } - } finally { - if (mountedRef.current && requestId === pairingRequestIdRef.current) { - setPairLoading(false) - } - } - }, - [connectionMode, mountedRef, selectedAddress] - ) + const { generatePairing } = useMobilePairingGeneration({ + connectionMode, + signedIn, + selectedAddress, + mountedRef, + hasGeneratedRef, + pairingRequestIdRef, + setPairQrDataUrl, + setPairingUrl, + setPairLoading, + setEncodedConnectionMode + }) const handleConnectionModeChange = useCallback( (nextMode: MobilePairingConnectionMode): void => { if (nextMode === connectionMode) { return } + // Why: persist the pick and update local state. The QR invalidation + + // rotate-regenerate is handled centrally by useMobilePairingQrInvalidation + // (below), which also covers cross-window preference syncs. setConnectionMode(nextMode) - // Why: an offer encodes its connection policy. Invalidate the prior - // request before rotating so a late response cannot restore a stale QR. - pairingRequestIdRef.current += 1 - const shouldRegenerate = hasGeneratedRef.current || pairLoading - hasGeneratedRef.current = false - setPairingUrl(null) - if (shouldRegenerate) { - void generatePairing(true, undefined, nextMode) - } + void updateSettings({ mobilePairingConnectionMode: nextMode }) }, - [connectionMode, generatePairing, pairLoading] + [connectionMode, updateSettings, setConnectionMode] ) - useEffect(() => { - const wasSignedIn = wasSignedInRef.current - wasSignedInRef.current = signedIn - if (wasSignedIn && !signedIn) { - handleConnectionModeChange('local-only') - } - }, [handleConnectionModeChange, signedIn]) + useMobilePairingQrInvalidation({ + connectionMode, + signedIn, + pairLoading, + hasGeneratedRef, + pairingRequestIdRef, + setPairQrDataUrl, + setPairingUrl, + setPairLoading, + regenerate: (mode, opts) => void generatePairing(opts.rotate, undefined, mode) + }) const loadNetworkInterfaces = useCallback(async () => { if (mountedRef.current) { @@ -177,7 +133,12 @@ export default function MobilePage(): React.JSX.Element { !result.interfaces.some((iface) => iface.address === newAddress) setAddressIsManual(nextIsManual) } - if (newAddress !== selectedAddress && hasGeneratedRef.current && mountedRef.current) { + if ( + newAddress !== selectedAddress && + hasGeneratedRef.current && + canMintMobilePairingOffer({ connectionMode, signedIn }) && + mountedRef.current + ) { void generatePairing(true, newAddress) } } catch { @@ -187,7 +148,7 @@ export default function MobilePage(): React.JSX.Element { setRefreshingNetworkInterfaces(false) } } - }, [selectedAddress, generatePairing, mountedRef, addressIsManual]) + }, [selectedAddress, generatePairing, mountedRef, addressIsManual, connectionMode, signedIn]) useEffect(() => { if (stage !== 'flow') { @@ -204,10 +165,13 @@ export default function MobilePage(): React.JSX.Element { // not snap it back to a tailnet/LAN fallback. const isManual = !networkInterfaces.some((iface) => iface.address === address) setAddressIsManual(isManual) - // Switching network must remint so the QR encodes the new endpoint. - void generatePairing(true, address) + // Switching network must remint so the QR encodes the new endpoint — + // but only when the selected path may honestly mint (not signed-out Anywhere). + if (canMintMobilePairingOffer({ connectionMode, signedIn })) { + void generatePairing(true, address) + } }, - [generatePairing, networkInterfaces] + [generatePairing, networkInterfaces, connectionMode, signedIn] ) const copyPairingCode = useCallback(async () => { @@ -234,30 +198,37 @@ export default function MobilePage(): React.JSX.Element { // Why: when Step 2 first becomes visible, mint a pairing offer so the // user sees a real QR immediately. Subsequent visits keep the existing // token unless they hit Regenerate. + const canGenerate = canMintMobilePairingOffer({ connectionMode, signedIn }) useEffect(() => { if (stage !== 'flow' || stepIdx !== 1 || hasGeneratedRef.current) { return } + // Why: signed-out Anywhere cannot serve Relay; auto-minting here would show a + // scannable local-only QR under the Relay label. Wait for sign-in or a switch + // to Local network (both flip canGenerate and re-run this effect) instead. + if (!canGenerate) { + return + } void generatePairing(false) - }, [stage, stepIdx, generatePairing]) + }, [stage, stepIdx, canGenerate, generatePairing]) + // Why: entering the flow must mint a fresh pairing token — clear stale QR + // state so we never flash an expired code from a previous session. const enterFlow = (): void => { - // Force the auto-generate effect to mint a fresh pairing token on next - // entry into Step 2, and clear stale QR state so we never flash an - // expired code from a previous session. hasGeneratedRef.current = false setPairQrDataUrl(null) setPairingUrl(null) + setEncodedConnectionMode(null) showFirstPairingFlow() } // Why: from the paired summary, "Pair another device" jumps straight to // Step 2 since the app is presumably already installed on the user's phone. const pairAnotherDevice = (): void => { - // Same reset as enterFlow — re-entering must mint a fresh pairing offer. hasGeneratedRef.current = false setPairQrDataUrl(null) setPairingUrl(null) + setEncodedConnectionMode(null) showPairAnotherDeviceFlow() } @@ -290,6 +261,7 @@ export default function MobilePage(): React.JSX.Element { devices={devices} enterFlow={enterFlow} generatePairing={(rotate) => void generatePairing(rotate)} + canGeneratePairing={canGenerate} handleAddressChange={handleAddressChange} handleBack={handleBack} handleContinue={handleContinue} @@ -305,6 +277,11 @@ export default function MobilePage(): React.JSX.Element { handleConnectionModeChange={handleConnectionModeChange} pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} + relayDegraded={ + pairQrDataUrl != null && + connectionMode === 'automatic' && + encodedConnectionMode === 'local-only' + } platform={platform} refreshingNetworkInterfaces={refreshingNetworkInterfaces} revokeDevice={(id) => void revokeDevice(id)} diff --git a/src/renderer/src/components/mobile/MobilePageContent.tsx b/src/renderer/src/components/mobile/MobilePageContent.tsx index c2b4c5ecbf7..432e4e5f654 100644 --- a/src/renderer/src/components/mobile/MobilePageContent.tsx +++ b/src/renderer/src/components/mobile/MobilePageContent.tsx @@ -15,6 +15,7 @@ type MobilePageContentProps = { devices: readonly PairedDevice[] enterFlow: () => void generatePairing: (rotate: boolean) => void + canGeneratePairing: boolean handleAddressChange: (address: string) => void handleBack: () => void handleContinue: () => void @@ -30,6 +31,7 @@ type MobilePageContentProps = { handleConnectionModeChange: (mode: MobilePairingConnectionMode) => void pairQrDataUrl: string | null pairingUrl: string | null + relayDegraded: boolean platform: Platform refreshingNetworkInterfaces: boolean revokeDevice: (id: string) => void @@ -50,6 +52,7 @@ export function MobilePageContent({ devices, enterFlow, generatePairing, + canGeneratePairing, handleAddressChange, handleBack, handleContinue, @@ -65,6 +68,7 @@ export function MobilePageContent({ handleConnectionModeChange, pairQrDataUrl, pairingUrl, + relayDegraded, platform, refreshingNetworkInterfaces, revokeDevice, @@ -108,10 +112,12 @@ export function MobilePageContent({ onCopyInstallUrl={copyInstallUrl} pairQrDataUrl={pairQrDataUrl} pairingUrl={pairingUrl} + relayDegraded={relayDegraded} pairLoading={pairLoading} connectionMode={connectionMode} onConnectionModeChange={handleConnectionModeChange} onRegeneratePairing={() => generatePairing(true)} + canGeneratePairing={canGeneratePairing} onCopyPairingCode={copyPairingCode} networkInterfaces={networkInterfaces} selectedAddress={selectedAddress} diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-connection-mode.ts b/src/renderer/src/components/mobile/use-mobile-pairing-connection-mode.ts new file mode 100644 index 00000000000..2b0eaea7ff0 --- /dev/null +++ b/src/renderer/src/components/mobile/use-mobile-pairing-connection-mode.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import { useAppStore } from '@/store' +import { + resolveMobilePairingConnectionMode, + type MobilePairingConnectionMode +} from '../../../../shared/mobile-pairing-connection-mode' + +/** + * Selected pairing path, seeded from the persisted preference and re-synced + * when it changes. Anywhere is the default; an explicit saved `local-only` + * means the user already chose same-network only. Shared by MobilePage and + * MobilePane so the two surfaces cannot resolve the saved value differently. + */ +export function useMobilePairingConnectionMode(): [ + MobilePairingConnectionMode, + React.Dispatch> +] { + const savedConnectionMode = useAppStore((s) => s.settings?.mobilePairingConnectionMode) + const [connectionMode, setConnectionMode] = useState(() => + resolveMobilePairingConnectionMode(savedConnectionMode) + ) + useEffect(() => { + setConnectionMode(resolveMobilePairingConnectionMode(savedConnectionMode)) + }, [savedConnectionMode]) + return [connectionMode, setConnectionMode] +} diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts b/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts new file mode 100644 index 00000000000..cb77f82e65f --- /dev/null +++ b/src/renderer/src/components/mobile/use-mobile-pairing-generation.ts @@ -0,0 +1,134 @@ +import { useCallback } from 'react' +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { + canMintMobilePairingOffer, + type MobilePairingConnectionMode +} from '../../../../shared/mobile-pairing-connection-mode' + +type MutableRef = { current: T } + +/** + * Mints (or rotates) a pairing QR. Every caller must go through this path so + * signed-out Anywhere is refused rather than silently degraded to a local-only + * code under the Relay label. + */ +export function useMobilePairingGeneration(params: { + connectionMode: MobilePairingConnectionMode + signedIn: boolean + selectedAddress: string | undefined + mountedRef: MutableRef + hasGeneratedRef: MutableRef + pairingRequestIdRef: MutableRef + setPairQrDataUrl: (value: string | null) => void + setPairingUrl: (value: string | null) => void + setPairLoading: (value: boolean) => void + /** Mode the minted QR actually encodes (degraded Relay mints report + * 'local-only'); null while no QR is shown. */ + setEncodedConnectionMode: (value: MobilePairingConnectionMode | null) => void +}): { + generatePairing: ( + rotate: boolean, + addressOverride?: string, + connectionModeOverride?: MobilePairingConnectionMode + ) => Promise +} { + const { + connectionMode, + signedIn, + selectedAddress, + mountedRef, + hasGeneratedRef, + pairingRequestIdRef, + setPairQrDataUrl, + setPairingUrl, + setPairLoading, + setEncodedConnectionMode + } = params + + const generatePairing = useCallback( + async ( + rotate: boolean, + addressOverride?: string, + connectionModeOverride?: MobilePairingConnectionMode + ) => { + const preferredMode = connectionModeOverride ?? connectionMode + // Why: every mint path (auto-generate, regenerate, address change, path + // invalidation) must refuse signed-out Anywhere rather than degrade to a + // local-only QR under the Relay label. + if (!canMintMobilePairingOffer({ connectionMode: preferredMode, signedIn })) { + return + } + const requestId = ++pairingRequestIdRef.current + // Mark the request synchronously so state changes cannot make the + // Step 2 auto-generate effect start a second offer in parallel. + hasGeneratedRef.current = true + if (mountedRef.current) { + setPairLoading(true) + } + try { + const address = addressOverride ?? selectedAddress + // canMint already requires sign-in for Anywhere, so preferred is honest. + const result = await window.api.mobile.getPairingQR({ + ...(address ? { address } : {}), + connectionMode: preferredMode, + ...(rotate ? { rotate: true } : {}) + }) + if (requestId !== pairingRequestIdRef.current) { + return + } + if (result.available) { + if (mountedRef.current) { + setPairQrDataUrl(result.qrDataUrl) + setPairingUrl(result.pairingUrl) + setEncodedConnectionMode(result.connectionMode) + } + } else { + hasGeneratedRef.current = false + if (mountedRef.current) { + setPairQrDataUrl(null) + setPairingUrl(null) + setEncodedConnectionMode(null) + toast.error( + translate( + 'auto.components.mobile.MobilePage.b353e18de1', + 'WebSocket transport is not running' + ) + ) + } + } + } catch { + if (mountedRef.current && requestId === pairingRequestIdRef.current) { + hasGeneratedRef.current = false + setPairQrDataUrl(null) + setPairingUrl(null) + setEncodedConnectionMode(null) + toast.error( + translate( + 'auto.components.mobile.MobilePage.4c8bd11c1a', + 'Failed to generate pairing code' + ) + ) + } + } finally { + if (mountedRef.current && requestId === pairingRequestIdRef.current) { + setPairLoading(false) + } + } + }, + [ + connectionMode, + hasGeneratedRef, + mountedRef, + pairingRequestIdRef, + selectedAddress, + setEncodedConnectionMode, + setPairLoading, + setPairQrDataUrl, + setPairingUrl, + signedIn + ] + ) + + return { generatePairing } +} diff --git a/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts b/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts new file mode 100644 index 00000000000..47f57dfd063 --- /dev/null +++ b/src/renderer/src/components/mobile/use-mobile-pairing-qr-invalidation.ts @@ -0,0 +1,112 @@ +import { useEffect, useRef } from 'react' +import { + canMintMobilePairingOffer, + type MobilePairingConnectionMode +} from '../../../../shared/mobile-pairing-connection-mode' + +type MutableRef = { current: T } + +/** + * Keeps the displayed pairing QR consistent with the selected path and sign-in + * state. Signing out of Anywhere clears the Relay QR (Step 2 does not re-mint a + * local-only code under the Relay label), signing in mints Relay, and a path + * change (local or cross-window) invalidates the encoded policy — otherwise the + * shown code silently mismatches what it actually encodes. + */ +export function useMobilePairingQrInvalidation(params: { + connectionMode: MobilePairingConnectionMode + signedIn: boolean + pairLoading: boolean + hasGeneratedRef: MutableRef + pairingRequestIdRef: MutableRef + setPairQrDataUrl: (value: string | null) => void + setPairingUrl: (value: string | null) => void + setPairLoading: (value: boolean) => void + regenerate: (mode: MobilePairingConnectionMode, opts: { rotate: boolean }) => void +}): void { + const { + connectionMode, + signedIn, + pairLoading, + hasGeneratedRef, + pairingRequestIdRef, + setPairQrDataUrl, + setPairingUrl, + setPairLoading, + regenerate + } = params + const wasSignedInRef = useRef(signedIn) + // Tracks the mode we last acted on so the mode effect can tell a cross-window + // preference sync apart from an already-handled change. + const handledModeRef = useRef(connectionMode) + + // Sign-in/out edges on Anywhere: signing out clears the Relay QR without + // re-minting (a local-only code must not appear under the Relay label); + // signing in mints Relay. Anywhere stays selected across both edges. Clear + // loading too so a superseded in-flight generate can't leave a stuck spinner. + useEffect(() => { + const wasSignedIn = wasSignedInRef.current + wasSignedInRef.current = signedIn + if (connectionMode !== 'automatic' || !hasGeneratedRef.current || wasSignedIn === signedIn) { + return + } + pairingRequestIdRef.current += 1 + hasGeneratedRef.current = false + setPairingUrl(null) + setPairQrDataUrl(null) + if (signedIn && canMintMobilePairingOffer({ connectionMode, signedIn })) { + // Why: rotate on the sign-in edge — the token behind the QR cleared at + // sign-out may have been exposed, so the fresh session mints fresh. + regenerate(connectionMode, { rotate: true }) + } else { + setPairLoading(false) + } + }, [ + connectionMode, + signedIn, + hasGeneratedRef, + pairingRequestIdRef, + setPairQrDataUrl, + setPairingUrl, + setPairLoading, + regenerate + ]) + + // Any path change — a user pick or another window persisting a new default — + // invalidates the prior request before rotating so a late response cannot + // restore a QR for the old policy. No updateSettings here (the caller/other + // window already wrote it) so there is no cross-window loop. + // Why: remint only when the new path may honestly encode a QR. Switching into + // signed-out Anywhere must clear, not mint a local-only code under Relay. + useEffect(() => { + if (connectionMode === handledModeRef.current) { + return + } + handledModeRef.current = connectionMode + pairingRequestIdRef.current += 1 + const shouldRegenerate = hasGeneratedRef.current || pairLoading + hasGeneratedRef.current = false + setPairingUrl(null) + setPairQrDataUrl(null) + if (shouldRegenerate && canMintMobilePairingOffer({ connectionMode, signedIn })) { + // Why: no rotate here — the main process rotates exactly once when the + // requested mode differs from the pending token's minted mode, so the + // initiating window and windows reacting to a cross-window preference + // sync converge on the same fresh token instead of racing rotations. + regenerate(connectionMode, { rotate: false }) + } else { + // No honest re-mint (blocked path or nothing pending); drop spinner. + setPairLoading(false) + } + }, [ + connectionMode, + signedIn, + pairLoading, + hasGeneratedRef, + pairingRequestIdRef, + setPairQrDataUrl, + setPairingUrl, + setPairLoading, + regenerate + ]) +} diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx new file mode 100644 index 00000000000..74476af1fb2 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NativeChatPickerMenu } from './NativeChatAutocompleteMenus' +import type { ComposerAutocomplete } from './native-chat-composer-state' + +function autocomplete( + overrides: Partial> = {} +): Extract { + return { + mode: 'slash', + query: '', + triggerKey: '/:0', + prefix: '/', + grouped: true, + commandsEnabled: true, + skillsEnabled: true, + items: [ + { + kind: 'command', + id: 'command:clear', + name: 'clear', + description: 'Clear history', + skillCollision: false + }, + { + kind: 'skill', + id: 'skill:browser', + name: 'browser', + description: 'Use a browser', + sources: [{ sourceKind: 'repo', skillFilePath: '/repo/browser/SKILL.md' }] + } + ], + skillStatus: 'ready', + ...overrides + } +} + +describe('NativeChatPickerMenu', () => { + afterEach(() => cleanup()) + + it('renders grouped command and skill options with active-descendant ids', () => { + const onChoose = vi.fn() + render( + + ) + expect(screen.getByText('Commands')).toBeTruthy() + expect(screen.getByText('Skills')).toBeTruthy() + expect(screen.getByRole('option', { name: /browser/i }).getAttribute('aria-selected')).toBe( + 'true' + ) + expect(screen.getByText('Project')).toBeTruthy() + }) + + it('completes a command on pointer down instead of dispatching it internally', () => { + const onChoose = vi.fn() + const value = autocomplete() + render( + + ) + fireEvent.pointerDown(screen.getByRole('option', { name: /clear/i })) + expect(onChoose).toHaveBeenCalledWith(value.items[0]) + }) + + it('keeps commands selectable while skills load', () => { + render( + + ) + expect(screen.getByRole('option', { name: /clear/i })).toBeTruthy() + expect(screen.getAllByText('Loading skills...')).toHaveLength(2) + }) + + it('uses command-only empty copy for a picker without skill support', () => { + render( + + ) + expect(screen.getAllByText('No matching commands')).toHaveLength(2) + }) + + it('announces a successful empty skill result distinctly from loading', () => { + render( + + ) + expect(screen.getAllByText('No matching skills')).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index f0662c52acd..8357931fb49 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -1,38 +1,262 @@ -import { useEffect, useRef } from 'react' +import { memo, useEffect, useRef } from 'react' +import { Loader2, Package, RotateCcw } from 'lucide-react' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' -import type { SlashCommandSuggestion } from './native-chat-composer-state' -import type { DiscoveredSkill } from '../../../../shared/skills' +import type { SkillSourceKind } from '../../../../shared/skills' +import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state' -export function NativeChatSlashMenu({ - suggestions, +export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ + autocomplete, activeIndex, - onChoose + listboxId, + onChoose, + onRetry }: { - suggestions: SlashCommandSuggestion[] + autocomplete: Extract activeIndex: number - onChoose: (command: SlashCommandSuggestion) => void + listboxId: string + onChoose: (item: NativeChatPickerItem) => void + onRetry: () => void }): React.JSX.Element { + const activeItemRef = useRef(null) + const commands = autocomplete.items.filter( + (item): item is Extract => item.kind === 'command' + ) + const skills = autocomplete.items.filter( + (item): item is Extract => item.kind === 'skill' + ) + + useEffect(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, autocomplete.items]) + + const hasSkillStatus = + autocomplete.skillStatus === 'loading' || autocomplete.skillStatus === 'error' + const showCommandsHeading = autocomplete.grouped && commands.length > 0 + const showSkillsHeading = autocomplete.grouped && (skills.length > 0 || hasSkillStatus) + const noMatches = + autocomplete.skillStatus === 'ready' && commands.length === 0 && skills.length === 0 + const emptyText = noMatches ? getPickerEmptyText(autocomplete) : null + const collision = commands.find((item) => item.skillCollision) + const duplicate = skills.find((item) => item.sources.length > 1) + + let optionIndex = 0 return ( -
- {suggestions.map((command, index) => ( - ) : null} - - ))} + + ) : null} + {skills.map((item) => { + const index = optionIndex++ + return ( + + ) + })} + {noMatches ? {emptyText} : null} +
+ {autocomplete.skillStatus === 'loading' + ? translate('components.native-chat.composer.loadingSkills', 'Loading skills...') + : autocomplete.skillStatus === 'error' + ? translate( + 'components.native-chat.composer.skillsLoadFailed', + 'Could not load skills from this host' + ) + : emptyText + ? emptyText + : autocomplete.skillsEnabled + ? [ + translate('components.native-chat.composer.skillsLoaded', 'Skills loaded'), + collision + ? getPickerAnnotation(collision) + : duplicate + ? getPickerAnnotation(duplicate) + : null + ] + .filter(Boolean) + .join('. ') + : ''} +
) +}) + +function getPickerEmptyText( + autocomplete: Extract +): string { + if (autocomplete.mode === 'skill' || !autocomplete.commandsEnabled) { + return translate('components.native-chat.composer.noSkills', 'No matching skills') + } + if (autocomplete.skillsEnabled) { + return translate( + 'components.native-chat.composer.noCommandsOrSkills', + 'No matching commands or skills' + ) + } + return translate('components.native-chat.composer.noCommands', 'No matching commands') +} + +function PickerGroupHeading({ kind }: { kind: 'commands' | 'skills' }): React.JSX.Element { + return ( +
+ {kind === 'commands' + ? translate('components.native-chat.composer.commands', 'Commands') + : translate('components.native-chat.composer.skills', 'Skills')} +
+ ) +} + +function PickerStatus({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + +function PickerOption({ + item, + prefix, + index, + activeIndex, + listboxId, + activeItemRef, + onChoose +}: { + item: NativeChatPickerItem + prefix: '/' | '$' + index: number + activeIndex: number + listboxId: string + activeItemRef: React.MutableRefObject + onChoose: (item: NativeChatPickerItem) => void +}): React.JSX.Element { + const annotation = getPickerAnnotation(item) + const selected = index === activeIndex + return ( + + ) +} + +function getPickerAnnotation(item: NativeChatPickerItem): string | null { + if (item.kind === 'command' && item.skillCollision) { + return translate( + 'components.native-chat.composer.skillCommandCollision', + 'Also a skill name - agent decides' + ) + } + if (item.kind === 'skill' && item.sources.length > 1) { + // Why: name the interpolation `sourceCount`, not `count` — a `count` option + // makes i18next resolve plural-suffixed keys that these locales don't define. + return translate( + 'components.native-chat.composer.skillMultipleSources', + '{{sourceCount}} sources - agent resolves', + { sourceCount: item.sources.length } + ) + } + return null +} + +function scopeLabel(sourceKind: SkillSourceKind | undefined): string { + const labels: Record = { + repo: translate('components.native-chat.composer.skillScopeProject', 'Project'), + home: translate('components.native-chat.composer.skillScopePersonal', 'Personal'), + bundled: translate('components.native-chat.composer.skillScopeBuiltIn', 'Built-in'), + plugin: translate('components.native-chat.composer.skillScopePlugin', 'Plugin') + } + return sourceKind ? (labels[sourceKind] ?? '') : '' } export function NativeChatMentionHint({ @@ -45,7 +269,10 @@ export function NativeChatMentionHint({ return ( ) } - -export function NativeChatSkillMenu({ - suggestions, - activeIndex, - onChoose -}: { - suggestions: DiscoveredSkill[] - activeIndex: number - onChoose: (skill: DiscoveredSkill) => void -}): React.JSX.Element { - const activeItemRef = useRef(null) - - useEffect(() => { - activeItemRef.current?.scrollIntoView({ block: 'nearest' }) - }, [activeIndex, suggestions]) - - return ( -
- {suggestions.length === 0 ? ( -
- {translate('components.native-chat.composer.noSkills', 'No matching skills')} -
- ) : null} - {suggestions.map((skill, index) => ( - - ))} -
- ) -} diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 25000521dbc..cb340ee0c13 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -6,6 +6,7 @@ import type { SessionOptionDescriptor, SessionOptionsSurface } from '../../../../shared/native-chat-session-options' +import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles' import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' const mocks = vi.hoisted(() => ({ @@ -63,11 +64,15 @@ vi.mock('./claude-model-switch-confirmation', () => ({ createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) => mocks.createClaudeModelSwitchConfirmationObserver(...args) })) -vi.mock('./native-chat-agent-commands', () => ({ - getAgentSlashCommands: () => [] +vi.mock('../../../../shared/native-chat-agent-profiles', async (importOriginal) => ({ + ...(await importOriginal()), + getVerifiedNativeChatCommands: () => [] })) vi.mock('@/lib/native-chat-telemetry', () => ({ - emitNativeChatMessageSent: vi.fn() + emitNativeChatMessageSent: vi.fn(), + emitNativeChatPickerItemAccepted: vi.fn(), + emitNativeChatPickerOpened: vi.fn(), + emitNativeChatSendClassified: vi.fn() })) vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: (scopeKey: string) => { @@ -84,7 +89,9 @@ vi.mock('./NativeChatComposerField', () => ({ return null } })) -vi.mock('./use-native-chat-skills', () => ({ useNativeChatSkills: () => [] })) +vi.mock('./use-native-chat-skills', () => ({ + useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} }) +})) vi.mock('./use-native-chat-composer-attachments', () => ({ useNativeChatComposerAttachments: () => ({ imageAttachments: [], diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index e9a6f8f8407..b2c763e6e26 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -8,19 +8,13 @@ import { submitNativeChatPrompt } from './native-chat-runtime-send' import type { NativeChatSendHandle } from './native-chat-runtime-send' -import { getAgentSlashCommands } from './native-chat-agent-commands' +import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { applyMentionSuggestion, - applySkillSuggestion, - applySlashSuggestion, - deriveComposerAutocomplete, EMPTY_HISTORY, - isSlashCommandDraft, pushHistory, - slashCommandDispatchText, - type HistoryState, - type SlashCommandSuggestion + type HistoryState } from './native-chat-composer-state' import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useNativeChatDraft } from './use-native-chat-draft' @@ -29,7 +23,6 @@ import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget } from './native-chat-composer-target' -import { useNativeChatSkills } from './use-native-chat-skills' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' @@ -39,6 +32,9 @@ import { useNativeChatSessionOptions } from './use-native-chat-session-options' import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' import { useNativeChatDictationActions } from './use-native-chat-dictation-actions' import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' +import { useNativeChatPickerState } from './use-native-chat-picker-state' +import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch' +import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion' import type { NativeChatComposerHandle, NativeChatComposerProps @@ -92,8 +88,8 @@ export const NativeChatComposer = forwardRef(null) const [dictationPressed, setDictationPressed] = useState(false) - const skills = useNativeChatSkills(agent, terminalTabId) const textareaRef = useRef(null) + const isComposingRef = useRef(false) const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, targetPtyId, @@ -118,12 +114,27 @@ export const NativeChatComposer = forwardRef getAgentSlashCommands(agent), [agent]) - const autocomplete = useMemo( - () => - deriveComposerAutocomplete(draft, caret, agentCommands, agent === 'codex' ? skills : []), - [draft, caret, agentCommands, agent, skills] - ) + const agentCommands = useMemo(() => getVerifiedNativeChatCommands(agent), [agent]) + const picker = useNativeChatPickerState({ + agent, + terminalTabId, + draftScopeKey, + draft, + caret, + agentCommands, + textareaRef, + setDraft, + setCaret, + setActiveSuggestion + }) + const { + autocomplete, + classifySend, + clearSkillOrigin, + completeItem, + dismiss, + handleDraftOrCaretChange + } = picker // Resolve the live ptyId for this chat leaf; runtime owner settings route // local vs remote (SSH) sends. @@ -134,8 +145,7 @@ export const NativeChatComposer = forwardRef { setCaret(el.selectionStart ?? el.value.length) @@ -155,37 +165,15 @@ export const NativeChatComposer = forwardRef { - const textarea = textareaRef.current - if (!textarea || textarea.disabled) { - return false - } - const selectionStart = textarea.selectionStart ?? caret - const selectionEnd = textarea.selectionEnd ?? selectionStart - const next = `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}` - const nextCaret = selectionStart + text.length - textarea.focus() - setDraft(next) - setCaret(nextCaret) - setHistory((prev) => ({ entries: prev.entries, index: null })) - setActiveSuggestion(0) - requestAnimationFrame(() => { - textarea.setSelectionRange(nextCaret, nextCaret) - }) - return true - }, - [caret, draft, setDraft] - ) - - const focus = useCallback((): boolean => { - const textarea = textareaRef.current - if (!textarea || textarea.disabled) { - return false - } - textarea.focus() - return true - }, []) + const { insertTypedText, focus } = useNativeChatTypedInsertion({ + textareaRef, + caret, + draft, + setDraft, + setCaret, + setHistory, + setActiveSuggestion + }) const { attachExternalPaths, resolveAttachmentOwner } = useNativeChatExternalAttachments({ terminalTabId, @@ -249,14 +237,12 @@ export const NativeChatComposer = forwardRef 0) { pendingHandle = sendNativeChatMessageWithImageAttachments( @@ -270,14 +256,16 @@ export const NativeChatComposer = forwardRef pushHistory(prev, text)) setDraft('') setCaret(0) + clearSkillOrigin() clearImageAttachments() setNotice(null) }, [ agent, + classifySend, + clearSkillOrigin, clearImageAttachments, draft, imageAttachments, @@ -323,58 +314,32 @@ export const NativeChatComposer = forwardRef { - const next = applySlashSuggestion(command) - setDraft(next) - setCaret(next.length) - setActiveSuggestion(0) - textareaRef.current?.focus() - }, - [setDraft] - ) - - const dispatchSlash = useCallback( - (command: SlashCommandSuggestion) => { - const next = slashCommandDispatchText(command) - const target = resolveTarget() - if (!target || disabled) { - return - } - trackPendingSend(sendNativeChatMessage(target.settings, target.ptyId, next)) - // Surface the command as a system line (this is the autocomplete-menu - // dispatch path; the typed-Enter path in `send` does the same). - onSlashCommand?.(next.trim()) - sessionOptionsSurface?.recordOutgoingCommand(next.trim()) - emitNativeChatMessageSent({ - agent, - runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' - }) - setHistory((prev) => pushHistory(prev, next)) - setDraft('') - setCaret(0) - setActiveSuggestion(0) - setNotice(null) - }, - [ - agent, - disabled, - resolveTarget, - onSlashCommand, - sessionOptionsSurface, - setDraft, - trackPendingSend - ] - ) + const dispatchPickerCommand = useNativeChatPickerCommandDispatch({ + agent, + disabled, + isDispatchingSessionOption, + resolveTarget, + onSlashCommand, + sessionOptionsSurface, + trackPendingSend, + setHistory, + setDraft, + setCaret, + setActiveSuggestion, + clearSkillOrigin, + clearImageAttachments, + setNotice + }) const handleKeyDown = useNativeChatComposerKeyDown({ autocomplete, activeSuggestion, draft, - caret, history, - chooseSlash, - dispatchSlash, + isComposing: () => isComposingRef.current, + completePickerItem: completeItem, + dispatchPickerCommand, + dismissPicker: dismiss, interrupt, send, setActiveSuggestion, @@ -404,12 +369,25 @@ export const NativeChatComposer = forwardRef ({ entries: prev.entries, index: null })) syncCaret(element) + handleDraftOrCaretChange(value, element.selectionStart ?? value.length) + setActiveSuggestion(0) + }} + onTextareaSelect={(element) => { + syncCaret(element) + handleDraftOrCaretChange(element.value, element.selectionStart ?? element.value.length) setActiveSuggestion(0) }} - onTextareaSelect={syncCaret} onKeyDown={handleKeyDown} + onCompositionStart={() => { + isComposingRef.current = true + }} + onCompositionEnd={() => { + isComposingRef.current = false + }} onPaste={handlePaste} - onChooseSlash={chooseSlash} + pickerListboxId={picker.listboxId} + onChoosePickerItem={completeItem} + onRetrySkills={picker.retrySkills} onAcceptMention={() => { if (autocomplete.mode !== 'mention') { return @@ -417,14 +395,9 @@ export const NativeChatComposer = forwardRef { - const result = applySkillSuggestion(draft, caret, skill.name) - setDraft(result.draft) - setCaret(result.caret) - setActiveSuggestion(0) - textareaRef.current?.focus() + const textarea = textareaRef.current + textarea?.focus() + requestAnimationFrame(() => textarea?.setSelectionRange(result.caret, result.caret)) }} onRemoveImageAttachment={(id) => removeImageAttachment(id)} onAttach={pickAttachment} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index fd76146650b..8da288e3b39 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -1,19 +1,19 @@ -import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' +import type { + ClipboardEventHandler, + CompositionEventHandler, + KeyboardEventHandler, + RefObject +} from 'react' import { Image as ImageIcon, ImageOff, X } from 'lucide-react' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' import { basename } from '@/lib/path' import { isNativeChatPastedImagePath } from './native-chat-image-paste' -import type { ComposerAutocomplete, SlashCommandSuggestion } from './native-chat-composer-state' -import { - NativeChatMentionHint, - NativeChatSkillMenu, - NativeChatSlashMenu -} from './NativeChatAutocompleteMenus' +import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state' +import { NativeChatMentionHint, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import { NativeChatComposerActions } from './NativeChatComposerActions' import { nativeChatComposerPlaceholder } from './native-chat-composer-target' -import type { DiscoveredSkill } from '../../../../shared/skills' import type { SessionOptionDescriptor, SessionOptionsSurface @@ -38,10 +38,13 @@ export type NativeChatComposerFieldProps = { onDraftChange: (value: string, element: HTMLTextAreaElement) => void onTextareaSelect: (element: HTMLTextAreaElement) => void onKeyDown: KeyboardEventHandler + onCompositionStart: CompositionEventHandler + onCompositionEnd: CompositionEventHandler onPaste: ClipboardEventHandler - onChooseSlash: (command: SlashCommandSuggestion) => void + pickerListboxId: string + onChoosePickerItem: (item: NativeChatPickerItem) => void + onRetrySkills: () => void onAcceptMention: () => void - onChooseSkill: (skill: DiscoveredSkill) => void onRemoveImageAttachment: (id: string) => void onAttach: () => void onDictationToggle: () => void @@ -77,10 +80,13 @@ export function NativeChatComposerField({ onDraftChange, onTextareaSelect, onKeyDown, + onCompositionStart, + onCompositionEnd, onPaste, - onChooseSlash, + pickerListboxId, + onChoosePickerItem, + onRetrySkills, onAcceptMention, - onChooseSkill, onRemoveImageAttachment, onAttach, onDictationToggle, @@ -96,23 +102,18 @@ export function NativeChatComposerField({ {/* Extra bottom padding keeps the input box off the window rim. */}
- {autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? ( - ) : null} {autocomplete.mode === 'mention' ? ( ) : null} - {autocomplete.mode === 'skill' ? ( - - ) : null} {notice ? (
@@ -168,8 +169,22 @@ export function NativeChatComposerField({ rows={2} onChange={(e) => onDraftChange(e.target.value, e.currentTarget)} onKeyDown={onKeyDown} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} onPaste={onPaste} onSelect={(e) => onTextareaSelect(e.currentTarget)} + aria-expanded={autocomplete.mode === 'slash' || autocomplete.mode === 'skill'} + aria-controls={ + autocomplete.mode === 'slash' || autocomplete.mode === 'skill' + ? pickerListboxId + : undefined + } + aria-activedescendant={ + (autocomplete.mode === 'slash' || autocomplete.mode === 'skill') && + autocomplete.items.length > 0 + ? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}` + : undefined + } placeholder={nativeChatComposerPlaceholder(hasPty, canSend)} // Why: coarse-pointer min-height follows the app's touch target convention. // scrollbar-sleek keeps the overflow gutter from showing the heavy diff --git a/src/renderer/src/components/native-chat/native-chat-agent-commands.ts b/src/renderer/src/components/native-chat/native-chat-agent-commands.ts deleted file mode 100644 index 3c363bf5e53..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-agent-commands.ts +++ /dev/null @@ -1,8 +0,0 @@ -// The per-agent slash-command catalog now lives in src/shared so the desktop -// renderer and the mobile app share one source of truth (no drift). This file -// re-exports it for existing desktop import sites. - -export { - getAgentSlashCommands, - type SlashCommandSuggestion -} from '../../../../shared/native-chat-slash-commands' diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts index 499d2b1610c..802ecfe5c45 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' import { applyMentionSuggestion, - applySkillSuggestion, + applyPickerSuggestion, applySlashSuggestion, + buildNativeChatPickerItems, + classifyNativeChatSend, deriveComposerAutocomplete, + editReplacesTriggerToken, EMPTY_HISTORY, - filterSkillSuggestions, filterSlashCommands, isSlashCommandDraft, pushHistory, @@ -15,6 +17,7 @@ import { type SlashCommandSuggestion } from './native-chat-composer-state' import type { DiscoveredSkill } from '../../../../shared/skills' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' const COMMANDS: SlashCommandSuggestion[] = [ { name: 'clear' }, @@ -48,7 +51,7 @@ describe('deriveComposerAutocomplete — slash', () => { return } expect(result.query).toBe('cl') - expect(result.suggestions.map((c) => c.name)).toEqual(['clear']) + expect(result.items.map((item) => item.name)).toEqual(['clear']) }) it('a bare `/` returns the full command list', () => { @@ -57,7 +60,7 @@ describe('deriveComposerAutocomplete — slash', () => { if (result.mode !== 'slash') { return } - expect(result.suggestions).toHaveLength(3) + expect(result.items).toHaveLength(3) }) it('does not fire slash mode after a space', () => { @@ -106,7 +109,7 @@ describe('deriveComposerAutocomplete — skill', () => { return } expect(result.query).toBe('type') - expect(result.suggestions.map((entry) => entry.name)).toEqual(['typescript']) + expect(result.items.map((entry) => entry.name)).toEqual(['typescript']) }) it('fires at the start of input too', () => { @@ -132,20 +135,6 @@ describe('isSlashCommandDraft', () => { }) }) -describe('filterSkillSuggestions', () => { - it('filters installed skills by name or directory basename', () => { - const skills = [ - skill({ name: 'TypeScript' }), - skill({ name: 'Display Name', directoryPath: '/repo/.agents/skills/ref-oss' }), - skill({ name: 'hidden', installed: false }) - ] - expect(filterSkillSuggestions(skills, 'ref').map((entry) => entry.name)).toEqual([ - 'Display Name' - ]) - expect(filterSkillSuggestions(skills, 'h')).toEqual([]) - }) -}) - describe('history recall', () => { it('up-arrow on empty composer recalls the last sent input', () => { const history = pushHistory(EMPTY_HISTORY, 'first') @@ -205,9 +194,192 @@ describe('apply suggestions', () => { expect(result.caret).toBe('open @src/app.ts '.length) }) - it('applySkillSuggestion replaces the active $token at the caret', () => { - const result = applySkillSuggestion('use $typ now', 8, 'typescript') + it('applyPickerSuggestion replaces the active $token at the caret', () => { + const result = applyPickerSuggestion( + 'use $typ now', + 8, + { kind: 'skill', id: 'skill:typescript', name: 'typescript', description: null, sources: [] }, + '$' + ) expect(result.draft).toBe('use $typescript now') expect(result.caret).toBe('use $typescript '.length) }) }) + +describe('native skill and command picker', () => { + it('keeps Codex commands under slash and skills under dollar', () => { + const profile = getNativeChatAgentProfile('codex') + const slash = deriveComposerAutocomplete('/', 1, COMMANDS, [skill({})], profile) + expect(slash.mode).toBe('slash') + if (slash.mode === 'slash') { + expect(slash.items.every((item) => item.kind === 'command')).toBe(true) + } + const dollar = deriveComposerAutocomplete('$', 1, COMMANDS, [skill({})], profile) + expect(dollar.mode).toBe('skill') + if (dollar.mode === 'skill') { + expect(dollar.items.every((item) => item.kind === 'skill')).toBe(true) + } + }) + + it('groups Claude commands and skills under slash', () => { + const result = deriveComposerAutocomplete( + '/', + 1, + COMMANDS, + [skill({ name: 'browser' })], + getNativeChatAgentProfile('claude') + ) + expect(result.mode).toBe('slash') + if (result.mode === 'slash') { + expect(result.grouped).toBe(true) + expect(result.items.map((item) => item.kind)).toContain('command') + expect(result.items.map((item) => item.kind)).toContain('skill') + } + }) + + it('ranks exact, prefix, fuzzy, then description matches within a group', () => { + const items = buildNativeChatPickerItems( + [], + [ + skill({ name: 'deploy', skillFilePath: '/1/SKILL.md' }), + skill({ name: 'deployment', skillFilePath: '/2/SKILL.md' }), + skill({ name: 'd-e-p-l-o-y', skillFilePath: '/3/SKILL.md' }), + skill({ + name: 'release', + description: 'Deploy an application', + skillFilePath: '/4/SKILL.md' + }) + ], + 'deploy', + '$' + ) + expect(items.map((item) => item.name)).toEqual([ + 'deploy', + 'deployment', + 'd-e-p-l-o-y', + 'release' + ]) + }) + + it('merges duplicate names but annotates command collisions on one command row', () => { + const duplicateSkills = [ + skill({ name: 'clear', skillFilePath: '/project/clear/SKILL.md', sourceKind: 'repo' }), + skill({ name: 'clear', skillFilePath: '/home/clear/SKILL.md', sourceKind: 'home' }) + ] + const skillOnly = buildNativeChatPickerItems([], duplicateSkills, '', '$') + expect(skillOnly).toEqual([ + expect.objectContaining({ kind: 'skill', name: 'clear', sources: expect.any(Array) }) + ]) + expect(skillOnly[0].kind === 'skill' ? skillOnly[0].sources : []).toHaveLength(2) + + const collision = buildNativeChatPickerItems(COMMANDS, duplicateSkills, 'clear', '/') + expect(collision).toEqual([ + expect.objectContaining({ kind: 'command', name: 'clear', skillCollision: true }) + ]) + }) + + it('keeps a long token-safe name intact for insertion instead of truncating it', () => { + const longName = `skill-${'x'.repeat(100)}` + const items = buildNativeChatPickerItems( + [], + [skill({ name: longName, skillFilePath: '/long/SKILL.md' })], + '', + '$' + ) + expect(items.map((item) => item.name)).toEqual([longName]) + const applied = applyPickerSuggestion('$sk', 3, items[0], '$') + expect(applied.draft).toBe(`$${longName} `) + }) + + it('rejects names carrying zero-width characters instead of inserting them', () => { + const items = buildNativeChatPickerItems( + [], + [ + skill({ + name: 'cle\u200bar', + directoryPath: '/repo/.agents/skills/safe-dir', + skillFilePath: '/repo/.agents/skills/safe-dir/SKILL.md' + }) + ], + '', + '$' + ) + expect(items.map((item) => item.name)).toEqual(['safe-dir']) + }) + + it('falls back to a token-safe directory name and strips unsafe display text', () => { + const items = buildNativeChatPickerItems( + [], + [ + skill({ + name: 'Spoof\u202e Name', + directoryPath: '/repo/.agents/skills/safe-name', + skillFilePath: '/repo/.agents/skills/safe-name/SKILL.md' + }) + ], + '', + '$' + ) + expect(items.map((item) => item.name)).toEqual(['safe-name']) + }) + + it('replaces only the active slash token and preserves text after the caret', () => { + const result = applyPickerSuggestion( + '/bro trailing', + 4, + { kind: 'skill', id: 'skill:browser', name: 'browser', description: null, sources: [] }, + '/' + ) + expect(result.draft).toBe('/browser trailing') + expect(result.caret).toBe('/browser '.length) + }) + + it('classifies sends only from the origin tag and exact command catalog', () => { + expect(classifyNativeChatSend('/browser do work', COMMANDS, '/browser', '/')).toBe('chat') + expect(classifyNativeChatSend('/clear', COMMANDS, null, '/')).toBe('command') + expect(classifyNativeChatSend('/Clear', COMMANDS, null, '/')).toBe('unknown-token') + expect(classifyNativeChatSend('/usr/bin/python is missing', COMMANDS, null, '/')).toBe( + 'unknown-token' + ) + expect(classifyNativeChatSend('ordinary prompt', COMMANDS, null, '/')).toBe('chat') + }) + + it('leading whitespace makes a slash draft prose, never a dispatched command', () => { + expect(classifyNativeChatSend(' /clear', COMMANDS, null, '/')).toBe('chat') + }) + + it('treats a leading $ token as unknown only for the $-prefix (Codex) profile', () => { + expect(classifyNativeChatSend('$deploy now', COMMANDS, null, '$')).toBe('unknown-token') + expect(classifyNativeChatSend('$PATH is wrong', COMMANDS, null, '/')).toBe('chat') + expect(classifyNativeChatSend('$50 is the budget', COMMANDS, null, null)).toBe('chat') + }) + + it('treats a one-edit token swap as a new trigger occurrence', () => { + expect(editReplacesTriggerToken('/foo', '/bar', '/:0')).toBe(true) + expect(editReplacesTriggerToken('use $foo', 'use $bar', '$:4')).toBe(true) + }) + + it('keeps suppression while typing or deleting inside the dismissed token', () => { + expect(editReplacesTriggerToken('/foo', '/food', '/:0')).toBe(false) + expect(editReplacesTriggerToken('/food', '/foo', '/:0')).toBe(false) + expect(editReplacesTriggerToken('use $foo now', 'ran $foo now', '$:4')).toBe(false) + }) + + it('suppresses only the dismissed trigger occurrence', () => { + const profile = getNativeChatAgentProfile('codex') + expect(deriveComposerAutocomplete('use $bro', 8, COMMANDS, [skill({})], profile).mode).toBe( + 'skill' + ) + expect( + deriveComposerAutocomplete( + 'use $bro', + 8, + COMMANDS, + [skill({})], + profile, + { status: 'ready', skills: [skill({})] }, + '$:4' + ).mode + ).toBe('none') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.ts index 9871bba1b95..c7c320af52e 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.ts @@ -1,11 +1,5 @@ -// Pure state machine for the native chat composer. Given the current draft, -// caret position, sent-input history, and the active agent's known slash -// commands/skills, it derives the autocomplete mode, the -// query, and the filtered suggestions. Keeping this DOM-free makes the slash / -// mention / skill / history behavior unit-testable; the .tsx only owns rendering and -// the actual textarea/caret wiring. - import type { DiscoveredSkill } from '../../../../shared/skills' +import type { NativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' import { filterSlashCommands, isSlashCommandDraft, @@ -13,145 +7,165 @@ import { slashCommandDispatchText, type SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' +import { + buildNativeChatPickerItems, + type NativeChatPickerItem, + type NativeChatSkillDiscoverySnapshot +} from './native-chat-picker-items' export type { SlashCommandSuggestion } export { filterSlashCommands, isSlashCommandDraft, applySlashSuggestion, slashCommandDispatchText } +export { + applyPickerSuggestion, + buildNativeChatPickerItems, + classifyNativeChatSend, + type NativeChatPickerItem, + type NativeChatSendClassification, + type NativeChatSkillDiscoverySnapshot +} from './native-chat-picker-items' export type ComposerAutocompleteMode = 'none' | 'slash' | 'mention' | 'skill' -export type ComposerAutocomplete = - | { mode: 'none' } - | { mode: 'slash'; query: string; suggestions: SlashCommandSuggestion[] } - | { mode: 'mention'; query: string } - | { mode: 'skill'; query: string; suggestions: DiscoveredSkill[] } - -export type ComposerDerivation = { - autocomplete: ComposerAutocomplete +type PickerAutocomplete = { + query: string + items: NativeChatPickerItem[] + triggerKey: string + prefix: '/' | '$' + grouped: boolean + commandsEnabled: boolean + skillsEnabled: boolean + skillStatus: NativeChatSkillDiscoverySnapshot['status'] + skillErrorKind?: NativeChatSkillDiscoverySnapshot['errorKind'] } -/** - * Detect the active autocomplete trigger from the text before the caret. - * - * Rules (intentionally conservative to avoid firing inside ordinary prose): - * - Slash: the draft starts with `/` and the caret is within that first token - * (no whitespace between `/` and the caret). Agent slash commands are only - * valid at the very start of the input, mirroring how the TUIs accept them. - * - Mention: an `@` immediately preceded by start-of-input or whitespace, with - * no whitespace between it and the caret. Works anywhere in the line so you - * can reference a file mid-sentence. - */ +export type ComposerAutocomplete = + | { mode: 'none' } + | ({ mode: 'slash' } & PickerAutocomplete) + | { mode: 'mention'; query: string } + | ({ mode: 'skill' } & PickerAutocomplete) + +const EMPTY_DISCOVERY: NativeChatSkillDiscoverySnapshot = { status: 'ready', skills: [] } + export function deriveComposerAutocomplete( draft: string, caret: number, agentCommands: readonly SlashCommandSuggestion[], - skills: readonly DiscoveredSkill[] = [] + skills: readonly DiscoveredSkill[] = [], + profile: NativeChatAgentProfile | null = null, + discovery: NativeChatSkillDiscoverySnapshot = { ...EMPTY_DISCOVERY, skills }, + dismissedTriggerKey: string | null = null ): ComposerAutocomplete { const before = draft.slice(0, caret) - - // Slash: only at the absolute start of the input, and only while the caret is - // still inside the unbroken command token. if (before.startsWith('/') && !/\s/.test(before)) { - const query = before.slice(1) - return { mode: 'slash', query, suggestions: filterSlashCommands(agentCommands, query) } + return deriveSlashAutocomplete(before, agentCommands, profile, discovery, dismissedTriggerKey) } - const mentionMatch = before.match(/(?:^|\s)@(\S*)$/) if (mentionMatch) { return { mode: 'mention', query: mentionMatch[1] } } - - const skillMatch = before.match(/(?:^|\s)\$(\S*)$/) - if (skillMatch) { - const query = skillMatch[1] - return { mode: 'skill', query, suggestions: filterSkillSuggestions(skills, query) } + const skillMatch = + profile?.skillPrefix === '$' || (!profile && skills.length > 0) + ? before.match(/(?:^|\s)\$(\S*)$/) + : null + if (!skillMatch) { + return { mode: 'none' } } - - return { mode: 'none' } -} - -export function filterSkillSuggestions( - skills: readonly DiscoveredSkill[], - query: string -): DiscoveredSkill[] { - const normalized = query.toLowerCase() - const installed = skills.filter((skill) => skill.installed) - if (normalized === '') { - return installed.slice(0, 12) + const triggerKey = `$:${before.length - skillMatch[1].length - 1}` + if (dismissedTriggerKey === triggerKey) { + return { mode: 'none' } } - return installed - .filter((skill) => { - const name = skill.name.toLowerCase() - const dirName = skill.directoryPath.split(/[\\/]/).findLast(Boolean)?.toLowerCase() - return name.startsWith(normalized) || dirName?.startsWith(normalized) - }) - .slice(0, 12) -} - -export type HistoryState = { - /** Most-recent-last list of previously sent drafts. */ - entries: readonly string[] - /** Cursor into `entries`; null means "live draft" (not recalling). */ - index: number | null -} - -export const EMPTY_HISTORY: HistoryState = { entries: [], index: null } - -/** Append a sent draft to history and reset the recall cursor. Blank sends and - * immediate duplicates of the last entry are not recorded (shell-style). */ -export function pushHistory(history: HistoryState, sent: string): HistoryState { - if (sent.trim() === '') { - return { entries: history.entries, index: null } - } - if (history.entries.at(-1) === sent) { - return { entries: history.entries, index: null } - } - return { entries: [...history.entries, sent], index: null } -} - -export type HistoryRecall = { - history: HistoryState - /** The draft text to show, or null to leave the live draft untouched. */ - draft: string | null -} - -/** - * Move one step toward older entries (Up arrow). From the live draft this jumps - * to the most recent entry; thereafter it walks backward and clamps at the - * oldest. Returns the recalled draft, or null when there is nothing to recall. - */ -export function recallPrevious(history: HistoryState): HistoryRecall { - if (history.entries.length === 0) { - return { history, draft: null } - } - const nextIndex = - history.index === null ? history.entries.length - 1 : Math.max(0, history.index - 1) + const query = skillMatch[1] return { - history: { entries: history.entries, index: nextIndex }, - draft: history.entries[nextIndex] + mode: 'skill', + query, + triggerKey, + prefix: '$', + grouped: false, + commandsEnabled: false, + skillsEnabled: true, + items: buildNativeChatPickerItems([], discovery.skills, query, '$'), + skillStatus: discovery.status === 'idle' ? 'loading' : discovery.status, + ...(discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) } } -/** - * Move one step toward newer entries (Down arrow). Walking past the newest entry - * returns to the live (empty) draft and clears the recall cursor. Returns null - * draft when not currently recalling. - */ -export function recallNext(history: HistoryState): HistoryRecall { - if (history.index === null) { - return { history, draft: null } - } - const nextIndex = history.index + 1 - if (nextIndex >= history.entries.length) { - return { history: { entries: history.entries, index: null }, draft: '' } +function deriveSlashAutocomplete( + before: string, + agentCommands: readonly SlashCommandSuggestion[], + profile: NativeChatAgentProfile | null, + discovery: NativeChatSkillDiscoverySnapshot, + dismissedTriggerKey: string | null +): ComposerAutocomplete { + const triggerKey = '/:0' + if (dismissedTriggerKey === triggerKey) { + return { mode: 'none' } } + const query = before.slice(1) + const hasSlashSkills = profile?.skillPrefix === '/' + // Why: the caller owns catalog policy (e.g. Grok ships skills-only until a + // verified catalog lands); this derivation must not re-gate per agent. + const items = buildNativeChatPickerItems( + agentCommands, + hasSlashSkills ? discovery.skills : [], + query, + '/' + ) return { - history: { entries: history.entries, index: nextIndex }, - draft: history.entries[nextIndex] + mode: 'slash', + query, + triggerKey, + prefix: '/', + grouped: profile?.groupedSlash === true, + commandsEnabled: agentCommands.length > 0, + skillsEnabled: hasSlashSkills, + items, + skillStatus: hasSlashSkills + ? discovery.status === 'idle' + ? 'loading' + : discovery.status + : 'ready', + ...(hasSlashSkills && discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) } } -/** Replace the active `@query` token before the caret with the chosen path. - * Returns the new full draft and the caret offset to place after insertion. */ +/** True when one edit both removed and inserted text across the dismissed + * trigger token — a wholesale replacement (e.g. select-all + paste), which is + * a new trigger occurrence even though a trigger character lands back on the + * same draft position. Typing or deleting inside the token is not. */ +export function editReplacesTriggerToken( + previous: string, + next: string, + triggerKey: string +): boolean { + const triggerPosition = Number.parseInt(triggerKey.slice(triggerKey.indexOf(':') + 1), 10) + if (!Number.isFinite(triggerPosition) || previous === next) { + return false + } + let commonPrefix = 0 + const maxPrefix = Math.min(previous.length, next.length) + while (commonPrefix < maxPrefix && previous[commonPrefix] === next[commonPrefix]) { + commonPrefix += 1 + } + let commonSuffix = 0 + while ( + commonSuffix < previous.length - commonPrefix && + commonSuffix < next.length - commonPrefix && + previous[previous.length - 1 - commonSuffix] === next[next.length - 1 - commonSuffix] + ) { + commonSuffix += 1 + } + const removed = previous.length - commonPrefix - commonSuffix + const inserted = next.length - commonPrefix - commonSuffix + if (removed === 0 || inserted === 0) { + return false + } + let tokenEnd = triggerPosition + 1 + while (tokenEnd < previous.length && !/\s/.test(previous[tokenEnd])) { + tokenEnd += 1 + } + return commonPrefix < tokenEnd && previous.length - commonSuffix > triggerPosition +} + export function applyMentionSuggestion( draft: string, caret: number, @@ -163,25 +177,38 @@ export function applyMentionSuggestion( if (!match) { return { draft, caret } } - const tokenStart = before.length - match[2].length - 1 // -1 for the '@' - const insertion = `@${path} ` - const nextBefore = before.slice(0, tokenStart) + insertion + const tokenStart = before.length - match[2].length - 1 + const nextBefore = `${before.slice(0, tokenStart)}@${path} ` return { draft: nextBefore + after, caret: nextBefore.length } } -export function applySkillSuggestion( - draft: string, - caret: number, - skillName: string -): { draft: string; caret: number } { - const before = draft.slice(0, caret) - const after = draft.slice(caret) - const match = before.match(/(^|\s)\$(\S*)$/) - if (!match) { - return { draft, caret } +export type HistoryState = { entries: readonly string[]; index: number | null } +export const EMPTY_HISTORY: HistoryState = { entries: [], index: null } + +export function pushHistory(history: HistoryState, sent: string): HistoryState { + if (sent.trim() === '' || history.entries.at(-1) === sent) { + return { entries: history.entries, index: null } } - const tokenStart = before.length - match[2].length - 1 // -1 for the '$' - const insertion = `$${skillName} ` - const nextBefore = before.slice(0, tokenStart) + insertion - return { draft: nextBefore + after, caret: nextBefore.length } + return { entries: [...history.entries, sent], index: null } +} + +export type HistoryRecall = { history: HistoryState; draft: string | null } + +export function recallPrevious(history: HistoryState): HistoryRecall { + if (history.entries.length === 0) { + return { history, draft: null } + } + const index = history.index === null ? history.entries.length - 1 : Math.max(0, history.index - 1) + return { history: { entries: history.entries, index }, draft: history.entries[index] } +} + +export function recallNext(history: HistoryState): HistoryRecall { + if (history.index === null) { + return { history, draft: null } + } + const index = history.index + 1 + if (index >= history.entries.length) { + return { history: { entries: history.entries, index: null }, draft: '' } + } + return { history: { entries: history.entries, index }, draft: history.entries[index] } } diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts new file mode 100644 index 00000000000..e1c2c4fc78f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts @@ -0,0 +1,248 @@ +import type { DiscoveredSkill, SkillSourceKind } from '../../../../shared/skills' +import type { SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' +import { + isSafeDisplayCharacter, + stripUnsafeDisplayCharacters +} from '../../../../shared/skill-display-text' + +export type NativeChatPickerItem = + | { + kind: 'command' + id: string + name: string + description?: string + skillCollision: boolean + } + | { + kind: 'skill' + id: string + name: string + description: string | null + sources: { sourceKind: SkillSourceKind; skillFilePath: string }[] + } + +export type NativeChatSkillDiscoverySnapshot = { + status: 'idle' | 'loading' | 'ready' | 'error' + skills: readonly DiscoveredSkill[] + errorKind?: 'unavailable' | 'timeout' | 'host' | 'unknown' +} + +const PICKER_RESULT_LIMIT = 50 +const SCOPE_PRIORITY: Record = { + repo: 0, + home: 1, + bundled: 2, + plugin: 3 +} + +export function buildNativeChatPickerItems( + commands: readonly SlashCommandSuggestion[], + skills: readonly DiscoveredSkill[], + query: string, + prefix: '/' | '$' +): NativeChatPickerItem[] { + const mergedSkills = mergeNativeChatSkills(skills) + const skillNames = new Set(mergedSkills.map((skill) => skill.name)) + const commandNames = new Set(commands.map((command) => command.name)) + const commandItems = rankItems( + commands.map((command, index) => ({ + item: { + kind: 'command' as const, + // Why: the name is the dispatch token and the catalog is curated, so + // it is inserted verbatim; only untrusted skill text gets sanitized. + id: `command:${command.name}`, + name: command.name, + description: command.description ? sanitizePickerText(command.description, 240) : undefined, + skillCollision: prefix === '/' && skillNames.has(command.name) + }, + stableOrder: index + })), + query + ) + const skillItems = rankItems( + mergedSkills + .filter((skill) => !(prefix === '/' && commandNames.has(skill.name))) + .map((item, index) => ({ item, stableOrder: index })), + query + ) + return [ + ...commandItems.slice(0, PICKER_RESULT_LIMIT), + ...skillItems.slice(0, PICKER_RESULT_LIMIT) + ] +} + +function mergeNativeChatSkills( + skills: readonly DiscoveredSkill[] +): Extract[] { + const exactPaths = new Map() + for (const skill of skills) { + if (skill.installed && !exactPaths.has(skill.skillFilePath)) { + exactPaths.set(skill.skillFilePath, skill) + } + } + const byName = new Map() + for (const skill of exactPaths.values()) { + const safeName = getSafeSkillName(skill) + if (!safeName) { + continue + } + byName.set(safeName, [...(byName.get(safeName) ?? []), { ...skill, name: safeName }]) + } + return [...byName.entries()] + .map(([name, namedSkills]) => { + const sorted = [...namedSkills].sort(compareDiscoveredSkills) + return { + kind: 'skill' as const, + id: `skill:${name}`, + name, + description: sorted[0]?.description ? sanitizePickerText(sorted[0].description, 240) : null, + sources: sorted.map((skill) => ({ + sourceKind: skill.sourceKind, + skillFilePath: skill.skillFilePath + })) + } + }) + .sort(comparePickerSkills) +} + +function rankItems( + entries: { item: T; stableOrder: number }[], + query: string +): T[] { + if (!query) { + return entries.map((entry) => entry.item) + } + return entries + .map((entry) => ({ ...entry, rank: getMatchRank(entry.item, query) })) + .filter((entry) => entry.rank !== null) + .sort((a, b) => a.rank! - b.rank! || a.stableOrder - b.stableOrder) + .map((entry) => entry.item) +} + +function getMatchRank( + item: Pick, + query: string +): number | null { + const normalizedQuery = query.toLocaleLowerCase() + const name = item.name.toLocaleLowerCase() + if (name === normalizedQuery) { + return 0 + } + if (name.startsWith(normalizedQuery)) { + return 1 + } + if (name.includes(normalizedQuery)) { + return 2 + } + if (isSubsequence(normalizedQuery, name)) { + return 3 + } + if (item.description?.toLocaleLowerCase().includes(normalizedQuery)) { + return 4 + } + return null +} + +function isSubsequence(query: string, value: string): boolean { + let queryIndex = 0 + for (const character of value) { + if (character === query[queryIndex]) { + queryIndex += 1 + } + if (queryIndex === query.length) { + return true + } + } + return false +} + +// Why: the row's visual truncation is CSS; the name IS the inserted PTY token, +// so it must never be sliced. Token safety instead rejects absurd lengths. +const MAX_TOKEN_SAFE_NAME_LENGTH = 200 + +function getSafeSkillName(skill: DiscoveredSkill): string | null { + if (isTokenSafe(skill.name)) { + return skill.name + } + const directoryName = skill.directoryPath.split(/[\\/]/).findLast(Boolean) ?? '' + return isTokenSafe(directoryName) ? directoryName : null +} + +function isTokenSafe(value: string): boolean { + return ( + value.length > 0 && + value.length <= MAX_TOKEN_SAFE_NAME_LENGTH && + !/\s/u.test(value) && + [...value].every(isSafeDisplayCharacter) + ) +} + +function sanitizePickerText(value: string, maxLength: number): string { + return stripUnsafeDisplayCharacters(value).slice(0, maxLength) +} + +function compareDiscoveredSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { + return ( + SCOPE_PRIORITY[a.sourceKind] - SCOPE_PRIORITY[b.sourceKind] || + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || + a.skillFilePath.localeCompare(b.skillFilePath) + ) +} + +function comparePickerSkills( + a: Extract, + b: Extract +): number { + return ( + SCOPE_PRIORITY[a.sources[0].sourceKind] - SCOPE_PRIORITY[b.sources[0].sourceKind] || + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ) +} + +export function applyPickerSuggestion( + draft: string, + caret: number, + item: NativeChatPickerItem, + prefix: '/' | '$' +): { draft: string; caret: number; insertedToken: string } { + const before = draft.slice(0, caret) + const after = draft.slice(caret) + const match = prefix === '/' ? before.match(/^\/(\S*)$/) : before.match(/(^|\s)\$(\S*)$/) + if (!match) { + return { draft, caret, insertedToken: '' } + } + const query = match.at(-1) ?? '' + const tokenStart = before.length - query.length - 1 + const insertedToken = `${prefix}${item.name}` + const nextBefore = `${before.slice(0, tokenStart)}${insertedToken} ` + return { draft: nextBefore + after, caret: nextBefore.length, insertedToken } +} + +export type NativeChatSendClassification = 'chat' | 'command' | 'unknown-token' + +export function classifyNativeChatSend( + draft: string, + commands: readonly SlashCommandSuggestion[], + pickerSkillOriginToken: string | null, + skillPrefix: '/' | '$' | null +): NativeChatSendClassification { + // Why: the supported TUIs only treat a line-leading token as a command, so a + // draft with leading whitespace is prose; trimming here would claim a "Ran" + // line for text the agent never dispatched. + const firstToken = draft.split(/\s/, 1)[0] ?? '' + if (pickerSkillOriginToken && firstToken === pickerSkillOriginToken) { + return 'chat' + } + if (commands.some((command) => firstToken === `/${command.name}`)) { + return 'command' + } + if (firstToken.startsWith('/')) { + return 'unknown-token' + } + // Why: `$` is Codex grammar only. For other agents a leading `$PATH`-style + // token is ordinary prose and must keep its bubble and attachments. + if (skillPrefix === '$' && firstToken.startsWith('$')) { + return 'unknown-token' + } + return 'chat' +} diff --git a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts new file mode 100644 index 00000000000..17e05af6a5c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts @@ -0,0 +1,156 @@ +import type { AppState } from '../../store/types' +import type { SkillDiscoveryTarget } from '../../../../shared/skills' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getExecutionHostIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +export type NativeChatSkillStateInputs = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projectGroups' + | 'projects' + | 'repos' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' + | 'settings' + | 'tabsByWorktree' + | 'worktreesByRepo' +> + +type NativeChatSkillTab = { id: string; startupCwd?: string } + +type NativeChatSkillWorktreeState = { + tabsByWorktree: Record + worktreesByRepo: Record +} + +export type NativeChatSkillDiscoveryContext = { + key: string + cwd: string + executionHostKind: 'local' | 'runtime' | 'ssh' + runtimeTarget: RuntimeClientTarget + discoveryTarget: SkillDiscoveryTarget +} + +export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSkillStateInputs { + return { + activeRepoId: state.activeRepoId, + activeWorktreeId: state.activeWorktreeId, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + projects: state.projects, + repos: state.repos, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, + settings: state.settings, + tabsByWorktree: state.tabsByWorktree, + worktreesByRepo: state.worktreesByRepo + } +} + +export function resolveNativeChatSkillDiscoveryCwd( + state: NativeChatSkillWorktreeState, + terminalTabId: string +): string | null { + const found = findTerminalTab(state.tabsByWorktree, terminalTabId) + if (!found) { + return null + } + // Why: the agent runs where its pane started. A pane launched in a + // subdirectory must not scan (or share a cache key with) the worktree root. + const startupCwd = found.tab.startupCwd?.trim() + if (startupCwd) { + return startupCwd + } + for (const worktrees of Object.values(state.worktreesByRepo)) { + const worktree = worktrees.find((entry) => entry.id === found.worktreeId) + if (worktree) { + return worktree.path + } + } + return null +} + +export function resolveNativeChatSkillDiscoveryContext( + state: NativeChatSkillStateInputs, + terminalTabId: string +): NativeChatSkillDiscoveryContext | null { + const worktreeId = findTerminalTab(state.tabsByWorktree, terminalTabId)?.worktreeId ?? null + if (!worktreeId) { + return null + } + const workspaceScope = parseWorkspaceKey(worktreeId) + const cwd = + resolveNativeChatSkillDiscoveryCwd(state, terminalTabId) ?? + (workspaceScope?.type === 'folder' + ? state.folderWorkspaces.find( + (workspace) => workspace.id === workspaceScope.folderWorkspaceId + )?.folderPath + : null) + if (!cwd) { + return null + } + + const hostId = getExecutionHostIdForWorktree(state, worktreeId) + const parsedHost = parseExecutionHostId(hostId) + if (parsedHost?.kind === 'ssh') { + return { + key: JSON.stringify(['ssh', hostId, cwd]), + cwd, + executionHostKind: 'ssh', + runtimeTarget: { kind: 'local' }, + discoveryTarget: { cwd, worktreeId } + } + } + + const runtimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) + // Why: a selected global runtime is not proof that it owns this pane. Modern + // panes carry an owner stamp; ambiguous legacy panes stay not-ready. + if (parsedHost?.kind === 'runtime' && !runtimeEnvironmentId) { + return null + } + const runtimeTarget: RuntimeClientTarget = runtimeEnvironmentId + ? { kind: 'environment', environmentId: runtimeEnvironmentId } + : { kind: 'local' } + const projectRuntime = runtimeEnvironmentId + ? undefined + : getLocalProjectExecutionRuntimeContext(state, worktreeId) + const projectRuntimeKey = + projectRuntime?.status === 'resolved' + ? projectRuntime.runtime.cacheKey + : projectRuntime?.repair.cacheKey + return { + key: JSON.stringify([ + runtimeTarget.kind, + runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null, + hostId, + projectRuntimeKey ?? null, + cwd + ]), + cwd, + executionHostKind: runtimeEnvironmentId ? 'runtime' : 'local', + runtimeTarget, + // Why: worktreeId lets the owning runtime resolve its own WSL project + // preference when this client cannot supply projectRuntime (environment- + // owned panes resolve host semantics on the runtime, never here). + discoveryTarget: { cwd, worktreeId, ...(projectRuntime ? { projectRuntime } : {}) } + } +} + +function findTerminalTab( + tabsByWorktree: Record, + terminalTabId: string +): { worktreeId: string; tab: NativeChatSkillTab } | null { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const tab = tabs.find((entry) => entry.id === terminalTabId) + if (tab) { + return { worktreeId, tab } + } + } + return null +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx new file mode 100644 index 00000000000..ed3814e8476 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { EMPTY_HISTORY, type ComposerAutocomplete } from './native-chat-composer-state' +import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' + +const COMMAND = { + kind: 'command' as const, + id: 'command:clear', + name: 'clear', + description: 'Clear history', + skillCollision: false +} + +function picker(items = [COMMAND]): Extract { + return { + mode: 'slash', + query: '', + items, + triggerKey: '/:0', + prefix: '/', + grouped: false, + commandsEnabled: true, + skillsEnabled: false, + skillStatus: 'ready' + } +} + +function setup(autocomplete: ComposerAutocomplete = picker(), composing = false) { + const callbacks = { + completePickerItem: vi.fn(), + dispatchPickerCommand: vi.fn(), + dismissPicker: vi.fn(), + interrupt: vi.fn(), + send: vi.fn(), + setActiveSuggestion: vi.fn(), + setDraft: vi.fn(), + setCaret: vi.fn(), + setHistory: vi.fn() + } + const hook = renderHook(() => + useNativeChatComposerKeyDown({ + autocomplete, + activeSuggestion: 0, + draft: '/', + history: EMPTY_HISTORY, + isComposing: () => composing, + ...callbacks + }) + ) + return { handler: hook.result.current, callbacks } +} + +function keyEvent(key: string, isComposing = false) { + return { + key, + shiftKey: false, + keyCode: isComposing ? 229 : 0, + nativeEvent: { isComposing }, + preventDefault: vi.fn() + } +} + +describe('useNativeChatComposerKeyDown', () => { + it('dispatches command Enter but completes command Tab', () => { + const enter = setup() + enter.handler(keyEvent('Enter') as never) + expect(enter.callbacks.dispatchPickerCommand).toHaveBeenCalledWith(COMMAND) + expect(enter.callbacks.completePickerItem).not.toHaveBeenCalled() + + const tab = setup() + tab.handler(keyEvent('Tab') as never) + expect(tab.callbacks.completePickerItem).toHaveBeenCalledWith(COMMAND) + expect(tab.callbacks.dispatchPickerCommand).not.toHaveBeenCalled() + }) + + it('falls through to composer send when the open picker has no options', () => { + const { handler, callbacks } = setup(picker([])) + handler(keyEvent('Enter') as never) + expect(callbacks.send).toHaveBeenCalledOnce() + }) + + it('dismisses Escape without interrupting the agent', () => { + const { handler, callbacks } = setup() + handler(keyEvent('Escape') as never) + expect(callbacks.dismissPicker).toHaveBeenCalledWith('/:0') + expect(callbacks.interrupt).not.toHaveBeenCalled() + }) + + it('does not accept or submit while IME composition is active', () => { + const { handler, callbacks } = setup(picker(), true) + const event = keyEvent('Enter', true) + handler(event as never) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(callbacks.dispatchPickerCommand).not.toHaveBeenCalled() + expect(callbacks.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index ac28aae3482..d287ab4fdce 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -1,22 +1,21 @@ import { useCallback, type Dispatch, type KeyboardEventHandler, type SetStateAction } from 'react' -import type { DiscoveredSkill } from '../../../../shared/skills' import { - applySkillSuggestion, recallNext, recallPrevious, type ComposerAutocomplete, type HistoryState, - type SlashCommandSuggestion + type NativeChatPickerItem } from './native-chat-composer-state' export type UseNativeChatComposerKeyDownArgs = { autocomplete: ComposerAutocomplete activeSuggestion: number draft: string - caret: number history: HistoryState - chooseSlash: (command: SlashCommandSuggestion) => void - dispatchSlash: (command: SlashCommandSuggestion) => void + isComposing: () => boolean + completePickerItem: (item: NativeChatPickerItem) => void + dispatchPickerCommand: (item: Extract) => void + dismissPicker: (triggerKey: string) => void interrupt: () => void send: () => void setActiveSuggestion: Dispatch> @@ -29,10 +28,11 @@ export function useNativeChatComposerKeyDown({ autocomplete, activeSuggestion, draft, - caret, history, - chooseSlash, - dispatchSlash, + isComposing, + completePickerItem, + dispatchPickerCommand, + dismissPicker, interrupt, send, setActiveSuggestion, @@ -42,60 +42,40 @@ export function useNativeChatComposerKeyDown({ }: UseNativeChatComposerKeyDownArgs): KeyboardEventHandler { return useCallback( (event) => { - if (autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0) { - if (event.key === 'ArrowDown') { - event.preventDefault() - setActiveSuggestion((i) => (i + 1) % autocomplete.suggestions.length) - return - } - if (event.key === 'ArrowUp') { - event.preventDefault() - setActiveSuggestion( - (i) => (i - 1 + autocomplete.suggestions.length) % autocomplete.suggestions.length - ) - return - } + if (isComposing() || event.nativeEvent.isComposing || event.keyCode === 229) { + // Why: IME Enter confirms composition; allowing it to fall through + // would accept a picker row or submit a partial draft. if (event.key === 'Enter') { event.preventDefault() - dispatchSlash(autocomplete.suggestions[activeSuggestion] ?? autocomplete.suggestions[0]) - return - } - if (event.key === 'Tab') { - event.preventDefault() - chooseSlash(autocomplete.suggestions[activeSuggestion] ?? autocomplete.suggestions[0]) - return - } - if (event.key === 'Escape') { - event.preventDefault() - setDraft('') - setCaret(0) - return } + return } - if (autocomplete.mode === 'skill') { - if (event.key === 'ArrowDown' && autocomplete.suggestions.length > 0) { + if (autocomplete.mode === 'slash' || autocomplete.mode === 'skill') { + const items = autocomplete.items + if (event.key === 'ArrowDown' && items.length > 0) { event.preventDefault() - setActiveSuggestion((i) => (i + 1) % autocomplete.suggestions.length) + setActiveSuggestion((index) => (index + 1) % items.length) return } - if (event.key === 'ArrowUp' && autocomplete.suggestions.length > 0) { + if (event.key === 'ArrowUp' && items.length > 0) { event.preventDefault() - setActiveSuggestion( - (i) => (i - 1 + autocomplete.suggestions.length) % autocomplete.suggestions.length - ) + setActiveSuggestion((index) => (index - 1 + items.length) % items.length) return } - if ((event.key === 'Enter' || event.key === 'Tab') && autocomplete.suggestions.length > 0) { + if ((event.key === 'Enter' || event.key === 'Tab') && items.length > 0) { event.preventDefault() - const skill = autocomplete.suggestions[activeSuggestion] ?? autocomplete.suggestions[0] - applySkill({ skill, draft, caret, setDraft, setCaret, setActiveSuggestion }) + const item = items[activeSuggestion] ?? items[0] + if (event.key === 'Enter' && item.kind === 'command') { + dispatchPickerCommand(item) + } else { + completePickerItem(item) + } return } if (event.key === 'Escape') { event.preventDefault() - setDraft('') - setCaret(0) + dismissPicker(autocomplete.triggerKey) return } } @@ -105,13 +85,11 @@ export function useNativeChatComposerKeyDown({ interrupt() return } - if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() send() return } - if (event.key === 'ArrowUp' && (draft === '' || history.index !== null)) { const recall = recallPrevious(history) if (recall.draft !== null) { @@ -133,15 +111,16 @@ export function useNativeChatComposerKeyDown({ } }, [ - autocomplete, activeSuggestion, - chooseSlash, - dispatchSlash, - interrupt, - send, + autocomplete, + completePickerItem, + dismissPicker, + dispatchPickerCommand, draft, - caret, history, + interrupt, + isComposing, + send, setActiveSuggestion, setCaret, setDraft, @@ -149,24 +128,3 @@ export function useNativeChatComposerKeyDown({ ] ) } - -function applySkill({ - skill, - draft, - caret, - setDraft, - setCaret, - setActiveSuggestion -}: { - skill: DiscoveredSkill - draft: string - caret: number - setDraft: Dispatch> - setCaret: Dispatch> - setActiveSuggestion: Dispatch> -}): void { - const result = applySkillSuggestion(draft, caret, skill.name) - setDraft(result.draft) - setCaret(result.caret) - setActiveSuggestion(0) -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index bcca032fac7..74a4d12b6c3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -16,6 +16,8 @@ import { reset as resetAssembler } from './native-chat-incremental-assembler' import { mergeNativeChatLiveSession } from './native-chat-live-status' +import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' +import { surfaceSkillInvocationUserTurns } from '../../../../shared/native-chat-command-envelope' import { hasMoreNativeChatHistory, NATIVE_CHAT_INITIAL_LIMIT, @@ -386,9 +388,22 @@ export function useNativeChatLiveSession( // eslint-disable-next-line react-hooks/exhaustive-deps }, [baseMessages, appended, sessionId, agent]) + // Why: Claude-family harnesses record slash inputs as command envelopes, + // which the noise filter hides. A skill invocation is the user's chat turn, + // so surface it here — upstream of pending-echo pruning, view state, and the + // list — as the literal token; catalog commands keep their `Ran /x` marker. + const surfacedMessages = useMemo( + () => + surfaceSkillInvocationUserTurns( + assembledMessages, + new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name)) + ), + [assembledMessages, agent] + ) + return useMemo(() => { const session = mergeNativeChatLiveSession({ - sources: { transcript: assembledMessages }, + sources: { transcript: surfacedMessages }, sessionId, agent, hookState, @@ -404,7 +419,7 @@ export function useNativeChatLiveSession( }) return { ...session, hasMore, loadingEarlier, loadEarlier } }, [ - assembledMessages, + surfacedMessages, read, sessionId, agent, diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts new file mode 100644 index 00000000000..66f34362fce --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts @@ -0,0 +1,97 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { + emitNativeChatMessageSent, + emitNativeChatPickerItemAccepted, + emitNativeChatSendClassified +} from '@/lib/native-chat-telemetry' +import { sendNativeChatMessage } from './native-chat-runtime-send' +import { + nativeChatComposerTargetIsRemote, + type NativeChatResolvedTarget +} from './native-chat-composer-target' +import { + pushHistory, + type HistoryState, + type NativeChatPickerItem +} from './native-chat-composer-state' +import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' +import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' + +export function useNativeChatPickerCommandDispatch(args: { + agent: AgentType + disabled: boolean + isDispatchingSessionOption: boolean + resolveTarget: () => NativeChatResolvedTarget | null + onSlashCommand?: (command: string) => void + sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null + trackPendingSend: NativeChatSendLifecycle['trackPendingSend'] + setHistory: Dispatch> + setDraft: (value: string) => void + setCaret: Dispatch> + setActiveSuggestion: Dispatch> + clearSkillOrigin: () => void + clearImageAttachments: () => void + setNotice: Dispatch> +}): (command: Extract) => void { + const { + agent, + disabled, + isDispatchingSessionOption, + resolveTarget, + onSlashCommand, + sessionOptionsSurface, + trackPendingSend, + setHistory, + setDraft, + setCaret, + setActiveSuggestion, + clearSkillOrigin, + clearImageAttachments, + setNotice + } = args + return useCallback( + (command) => { + const text = `/${command.name}` + const target = resolveTarget() + if (!target || disabled || isDispatchingSessionOption) { + return + } + trackPendingSend(sendNativeChatMessage(target.settings, target.ptyId, text)) + emitNativeChatPickerItemAccepted({ agent, itemKind: 'command' }) + // Why: picker dispatch is a catalog-verified command send; it must leave + // the same telemetry and composer state as the typed path — including + // disarming attachments, or a stale image rides the next prompt. + emitNativeChatSendClassified({ agent, outcome: 'command' }) + onSlashCommand?.(text) + sessionOptionsSurface?.recordOutgoingCommand(text) + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((previous) => pushHistory(previous, text)) + setDraft('') + setCaret(0) + setActiveSuggestion(0) + clearSkillOrigin() + clearImageAttachments() + setNotice(null) + }, + [ + agent, + clearImageAttachments, + clearSkillOrigin, + disabled, + isDispatchingSessionOption, + onSlashCommand, + resolveTarget, + sessionOptionsSurface, + setActiveSuggestion, + setCaret, + setDraft, + setHistory, + setNotice, + trackPendingSend + ] + ) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts new file mode 100644 index 00000000000..d66228356a2 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -0,0 +1,198 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction +} from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import type { SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' +import { + applyPickerSuggestion, + classifyNativeChatSend, + deriveComposerAutocomplete, + editReplacesTriggerToken, + type ComposerAutocomplete, + type NativeChatPickerItem, + type NativeChatSendClassification +} from './native-chat-composer-state' +import { useNativeChatSkills } from './use-native-chat-skills' +import { + emitNativeChatPickerItemAccepted, + emitNativeChatPickerOpened, + emitNativeChatSendClassified +} from '@/lib/native-chat-telemetry' + +export type NativeChatPickerState = { + autocomplete: ComposerAutocomplete + listboxId: string + retrySkills: () => void + classifySend: (draft: string) => NativeChatSendClassification + clearSkillOrigin: () => void + completeItem: (item: NativeChatPickerItem) => void + dismiss: (triggerKey: string) => void + handleDraftOrCaretChange: (value: string, caret: number) => void +} + +export function useNativeChatPickerState(args: { + agent: AgentType + terminalTabId: string + draftScopeKey: string + draft: string + caret: number + agentCommands: readonly SlashCommandSuggestion[] + textareaRef: RefObject + setDraft: (value: string) => void + setCaret: Dispatch> + setActiveSuggestion: Dispatch> +}): NativeChatPickerState { + const { + agent, + terminalTabId, + draftScopeKey, + draft, + caret, + agentCommands, + textareaRef, + setDraft, + setCaret, + setActiveSuggestion + } = args + const profile = useMemo(() => getNativeChatAgentProfile(agent), [agent]) + const beforeCaret = draft.slice(0, caret) + const skillPickerTriggered = + profile?.skillPrefix === '$' + ? /(?:^|\s)\$\S*$/.test(beforeCaret) + : profile?.skillPrefix === '/' + ? beforeCaret.startsWith('/') && !/\s/.test(beforeCaret) + : false + const discovery = useNativeChatSkills(agent, terminalTabId, skillPickerTriggered) + const listboxId = `native-chat-picker-${useId().replaceAll(':', '')}` + const dismissalContext = `${draftScopeKey}:${agent}` + const [dismissed, setDismissed] = useState<{ context: string; triggerKey: string } | null>(null) + const skillOriginRef = useRef(null) + const lastOpenKeyRef = useRef(null) + const autocomplete = useMemo( + () => + deriveComposerAutocomplete( + draft, + caret, + agentCommands, + discovery.skills, + profile, + discovery, + dismissed?.context === dismissalContext ? dismissed.triggerKey : null + ), + [agentCommands, caret, dismissalContext, dismissed, discovery, draft, profile] + ) + + useEffect(() => { + // Why: suppression is per-trigger-occurrence AND per-context. The composer + // is reused across pane/agent switches, so a stale dismissal must clear or + // the picker stays closed for an in-progress token when that context returns. + skillOriginRef.current = null + setDismissed(null) + }, [dismissalContext]) + + useEffect(() => { + if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') { + lastOpenKeyRef.current = null + return + } + const openKey = `${dismissalContext}:${autocomplete.triggerKey}` + if (lastOpenKeyRef.current !== openKey) { + lastOpenKeyRef.current = openKey + emitNativeChatPickerOpened({ agent, prefix: autocomplete.prefix }) + } + }, [agent, autocomplete, dismissalContext]) + + const completeItem = useCallback( + (item: NativeChatPickerItem) => { + if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') { + return + } + const result = applyPickerSuggestion(draft, caret, item, autocomplete.prefix) + setDraft(result.draft) + setCaret(result.caret) + setActiveSuggestion(0) + setDismissed(null) + skillOriginRef.current = item.kind === 'skill' ? result.insertedToken : null + emitNativeChatPickerItemAccepted({ agent, itemKind: item.kind }) + const textarea = textareaRef.current + textarea?.focus() + requestAnimationFrame(() => textarea?.setSelectionRange(result.caret, result.caret)) + }, + [agent, autocomplete, caret, draft, setActiveSuggestion, setCaret, setDraft, textareaRef] + ) + + const handleDraftOrCaretChange = useCallback( + (value: string, nextCaret: number) => { + const firstToken = value.split(/\s/, 1)[0] ?? '' + if (skillOriginRef.current && firstToken !== skillOriginRef.current) { + skillOriginRef.current = null + } + if (!dismissed || dismissed.context !== dismissalContext) { + return + } + // Why: a single edit that replaces the dismissed token wholesale (e.g. + // select-all + paste) is a new trigger occurrence even though a trigger + // character lands back on the same draft position. + if (editReplacesTriggerToken(draft, value, dismissed.triggerKey)) { + setDismissed(null) + return + } + const next = deriveComposerAutocomplete( + value, + nextCaret, + agentCommands, + discovery.skills, + profile, + discovery + ) + if ( + (next.mode !== 'slash' && next.mode !== 'skill') || + next.triggerKey !== dismissed.triggerKey + ) { + setDismissed(null) + } + }, + [agentCommands, dismissalContext, dismissed, discovery, draft, profile] + ) + + const classifySend = useCallback( + (value: string) => { + const outcome = classifyNativeChatSend( + value, + agentCommands, + skillOriginRef.current, + profile?.skillPrefix ?? null + ) + emitNativeChatSendClassified({ agent, outcome }) + return outcome + }, + [agent, agentCommands, profile] + ) + const clearSkillOrigin = useCallback(() => { + skillOriginRef.current = null + }, []) + const dismiss = useCallback( + (triggerKey: string) => setDismissed({ context: dismissalContext, triggerKey }), + [dismissalContext] + ) + + return { + autocomplete, + listboxId, + retrySkills: discovery.retry, + classifySend, + clearSkillOrigin, + completeItem, + dismiss, + handleDraftOrCaretChange + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx new file mode 100644 index 00000000000..647a07b2f4f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment happy-dom + +import { cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatSkillDiscovery } from './use-native-chat-skills' + +const mocks = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + state: {} as Record, + snapshots: [] as NativeChatSkillDiscovery[] +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => selector(mocks.state) +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => mocks.callRuntimeRpc(...args) +})) +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalProjectExecutionRuntimeContext: () => undefined +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatSkillDiscovery: vi.fn() })) + +import { + resetNativeChatSkillDiscoveryCacheForTests, + useNativeChatSkills +} from './use-native-chat-skills' + +function stateForHost(hostId: string) { + return { + activeRepoId: 'repo-1', + activeWorktreeId: 'worktree-1', + folderWorkspaces: [], + projectGroups: [], + projects: [], + repos: [ + { + id: 'repo-1', + path: '/repo', + connectionId: null, + executionHostId: hostId + } + ], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] }, + worktreesByRepo: { + 'repo-1': [{ id: 'worktree-1', repoId: 'repo-1', path: '/repo/worktree', hostId }] + } + } +} + +function Probe({ enabled }: { enabled: boolean }): null { + mocks.snapshots.push(useNativeChatSkills('codex', 'tab-1', enabled)) + return null +} + +describe('useNativeChatSkills', () => { + beforeEach(() => { + mocks.state = stateForHost('local') + mocks.snapshots = [] + mocks.callRuntimeRpc.mockReset() + mocks.callRuntimeRpc.mockResolvedValue({ + skills: [ + { + id: 'browser', + name: 'browser', + description: null, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: '/home/test/.agents/skills', + directoryPath: '/home/test/.agents/skills/browser', + skillFilePath: '/home/test/.agents/skills/browser/SKILL.md', + installed: true, + fileCount: 1, + updatedAt: null + } + ], + sources: [ + { + id: 'home-agents', + label: 'Agent skills home', + path: '/home/test/.agents/skills', + sourceKind: 'home', + providers: ['agent-skills'], + owner: null, + exists: true + } + ], + scannedAt: 1 + }) + resetNativeChatSkillDiscoveryCacheForTests() + }) + + afterEach(() => cleanup()) + + it('starts lazily and exposes loading separately from ready results', async () => { + const view = render() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(mocks.snapshots.at(-1)?.status).toBe('idle') + + view.rerender() + expect(mocks.snapshots.at(-1)?.status).toBe('loading') + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser']) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'skills.discover', + { cwd: '/repo/worktree', worktreeId: 'worktree-1' }, + { timeoutMs: 10_000 } + ) + }) + + it('shares one in-flight request between sibling panes', async () => { + render( + <> + + + + ) + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1) + }) + + it('marks SSH discovery unavailable without scanning another host', async () => { + mocks.state = stateForHost('ssh:connection-1') + render() + await waitFor(() => expect(mocks.snapshots.at(-1)?.errorKind).toBe('unavailable')) + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('routes runtime-owned panes through their saved environment', async () => { + mocks.state = stateForHost('runtime:env-1') + render() + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'skills.discover', + { cwd: '/repo/worktree', worktreeId: 'worktree-1' }, + { timeoutMs: 10_000 } + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 9efe7a23d0e..7154abb1043 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { DiscoveredSkill } from '../../../../shared/skills' +import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' import { isNativeChatSkillForAgent, resolveNativeChatSkillDiscoveryCwd @@ -23,6 +23,22 @@ function skill(overrides: Partial): DiscoveredSkill { } } +function discovery(owner: string | null, rootPath = '/Users/test/.agents/skills') { + return { + sources: [ + { + id: 'source', + label: 'Source', + path: rootPath, + sourceKind: 'home' as const, + providers: ['agent-skills' as const], + owner, + exists: true + } + ] + } satisfies Pick +} + describe('isNativeChatSkillForAgent', () => { it('shows Codex-native and generic agent skills for Codex chat', () => { expect(isNativeChatSkillForAgent('codex', skill({ providers: ['codex'] }))).toBe(true) @@ -36,6 +52,59 @@ describe('isNativeChatSkillForAgent', () => { it('does not enable skill autocomplete for other agents yet', () => { expect(isNativeChatSkillForAgent('claude', skill({ providers: ['agent-skills'] }))).toBe(false) }) + + it('uses explicit source ownership and keeps shared roots visible', () => { + const shared = discovery(null) + expect(isNativeChatSkillForAgent('codex', skill({}), shared)).toBe(true) + expect(isNativeChatSkillForAgent('claude', skill({}), shared)).toBe(true) + expect(isNativeChatSkillForAgent('grok', skill({}), shared)).toBe(true) + }) + + it('aliases OpenClaude to Claude roots without exposing them to other agents', () => { + const claude = discovery('claude') + expect(isNativeChatSkillForAgent('claude', skill({}), claude)).toBe(true) + expect(isNativeChatSkillForAgent('openclaude', skill({}), claude)).toBe(true) + expect(isNativeChatSkillForAgent('codex', skill({}), claude)).toBe(false) + expect(isNativeChatSkillForAgent('grok', skill({}), claude)).toBe(false) + }) + + it('grants visibility through any contributing root, not just the dedup survivor', () => { + const result = { + sources: [ + { + id: 'codex-home', + label: 'Codex home', + path: '/Users/test/.codex/skills', + sourceKind: 'home' as const, + providers: ['codex' as const], + owner: 'codex', + exists: true + }, + { + id: 'shared-home', + label: 'Agent skills home', + path: '/Users/test/.agents/skills', + sourceKind: 'home' as const, + providers: ['agent-skills' as const], + owner: null, + exists: true + } + ] + } satisfies Pick + // A symlinked skill deduped under the Codex root but also reachable + // through the shared root stays visible to every agent. + const merged = skill({ + rootPath: '/Users/test/.codex/skills', + rootPaths: ['/Users/test/.codex/skills', '/Users/test/.agents/skills'] + }) + expect(isNativeChatSkillForAgent('claude', merged, result)).toBe(true) + expect(isNativeChatSkillForAgent('codex', merged, result)).toBe(true) + const codexOnly = skill({ + rootPath: '/Users/test/.codex/skills', + rootPaths: ['/Users/test/.codex/skills'] + }) + expect(isNativeChatSkillForAgent('claude', codexOnly, result)).toBe(false) + }) }) describe('resolveNativeChatSkillDiscoveryCwd', () => { @@ -69,4 +138,23 @@ describe('resolveNativeChatSkillDiscoveryCwd', () => { resolveNativeChatSkillDiscoveryCwd({ tabsByWorktree: {}, worktreesByRepo: {} }, 'tab-1') ).toBeNull() }) + + it('prefers the pane startupCwd over the worktree root', () => { + expect( + resolveNativeChatSkillDiscoveryCwd( + { + tabsByWorktree: { + 'repo-1::/repo/worktree': [ + { id: 'tab-1', startupCwd: '/repo/worktree/packages/app' }, + { id: 'tab-2' } + ] + }, + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/worktree', path: '/repo/worktree' }] + } + }, + 'tab-1' + ) + ).toBe('/repo/worktree/packages/app') + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index 04327478956..42c7702d017 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -1,71 +1,249 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' -import type { DiscoveredSkill } from '../../../../shared/skills' +import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { emitNativeChatSkillDiscovery } from '@/lib/native-chat-telemetry' +import { + resolveNativeChatSkillDiscoveryContext, + selectNativeChatSkillStateInputs, + type NativeChatSkillDiscoveryContext +} from './native-chat-skill-discovery-context' -type NativeChatSkillWorktreeState = { - tabsByWorktree: Record - worktreesByRepo: Record +export { + resolveNativeChatSkillDiscoveryContext, + resolveNativeChatSkillDiscoveryCwd +} from './native-chat-skill-discovery-context' + +// The host scan budget honored by runtime targets that respect timeoutMs. +const DISCOVERY_TIMEOUT_MS = 10_000 +// Renderer wall-clock backstop for the local branch (which ignores timeoutMs). +// It must exceed the host's summed worst case — a WSL scan runs a metadata read +// (5s) then the tree walk (10s) in sequence — so the host's own precise timeout +// wins and a WSL cold boot does not surface a healthy scan as a spurious timeout. +const DISCOVERY_BACKSTOP_TIMEOUT_MS = 18_000 + +export type NativeChatSkillDiscovery = { + status: 'idle' | 'loading' | 'ready' | 'error' + skills: DiscoveredSkill[] + error: Error | null + errorKind?: 'unavailable' | 'timeout' | 'host' | 'unknown' + retry: () => void } -export function isNativeChatSkillForAgent(agent: AgentType, skill: DiscoveredSkill): boolean { - if (agent !== 'codex') { +type StoredDiscoveryState = Omit & { + contextKey: string | null +} + +const IDLE_STATE: StoredDiscoveryState = { + status: 'idle', + skills: [], + error: null, + contextKey: null +} +const inFlightDiscovery = new Map>() + +export function isNativeChatSkillForAgent( + agent: AgentType, + skill: DiscoveredSkill, + result?: Pick +): boolean { + const profile = getNativeChatAgentProfile(agent) + if (!profile) { return false } - // Why: Codex sessions can see both Codex-native skills and generic agent - // skills (for example ~/.agents/skills), so the picker should mirror that. - return skill.providers.includes('codex') || skill.providers.includes('agent-skills') + if (!result) { + return ( + agent === 'codex' && + (skill.providers.includes('codex') || skill.providers.includes('agent-skills')) + ) + } + // Why: canonical-path dedup keeps one row per file, but a symlinked skill can + // be reachable through several roots; any shared or agent-owned root grants + // visibility regardless of which root the scanner happened to list first. + const rootPaths = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath] + return rootPaths.some((rootPath) => { + const source = result.sources.find((entry) => entry.path === rootPath) + return source?.owner === null || source?.owner === profile.skillSourceOwner + }) } -export function resolveNativeChatSkillDiscoveryCwd( - state: NativeChatSkillWorktreeState, - terminalTabId: string -): string | null { - let ownerWorktreeId: string | null = null - for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { - if (tabs.some((tab) => tab.id === terminalTabId)) { - ownerWorktreeId = worktreeId - break - } - } - if (!ownerWorktreeId) { - return null - } - for (const worktrees of Object.values(state.worktreesByRepo)) { - const worktree = worktrees.find((entry) => entry.id === ownerWorktreeId) - if (worktree) { - return worktree.path - } - } - return null -} - -export function useNativeChatSkills(agent: AgentType, terminalTabId: string): DiscoveredSkill[] { - const [skills, setSkills] = useState([]) - const cwd = useAppStore((state) => resolveNativeChatSkillDiscoveryCwd(state, terminalTabId)) +export function useNativeChatSkills( + agent: AgentType, + terminalTabId: string, + enabled = false +): NativeChatSkillDiscovery { + const inputs = useAppStore(useShallow(selectNativeChatSkillStateInputs)) + const context = useMemo( + () => resolveNativeChatSkillDiscoveryContext(inputs, terminalTabId), + [inputs, terminalTabId] + ) + const [state, setState] = useState(IDLE_STATE) + const [retryGeneration, setRetryGeneration] = useState(0) + const paneDiscoveryCache = useRef(new Map()) + const profile = getNativeChatAgentProfile(agent) useEffect(() => { let cancelled = false - if (agent !== 'codex') { - setSkills([]) + if (!profile || !enabled || !context) { + setState(IDLE_STATE) return } - void window.api.skills - .discover(cwd ? { cwd } : undefined) - .then((result) => { - if (!cancelled) { - setSkills(result.skills.filter((skill) => isNativeChatSkillForAgent(agent, skill))) - } + if (context.executionHostKind === 'ssh') { + emitNativeChatSkillDiscovery({ + agent, + outcome: 'unavailable', + executionHostKind: 'ssh' }) - .catch(() => { - if (!cancelled) { - setSkills([]) - } + setState({ + status: 'error', + skills: [], + error: new Error('Skill discovery is unavailable for SSH hosts.'), + errorKind: 'unavailable', + contextKey: context.key }) + return + } + + const paneCacheKey = context.key + const cached = paneDiscoveryCache.current.get(paneCacheKey) + if (cached) { + emitNativeChatSkillDiscovery({ + agent, + outcome: 'ready', + executionHostKind: context.executionHostKind + }) + setState({ status: 'ready', skills: cached.skills, error: null, contextKey: context.key }) + return + } + setState({ status: 'loading', skills: [], error: null, contextKey: context.key }) + const request = getOrStartDiscovery(context) + void request.then( + (result) => { + paneDiscoveryCache.current.set(paneCacheKey, result) + if (cancelled) { + return + } + emitNativeChatSkillDiscovery({ + agent, + outcome: 'ready', + executionHostKind: context.executionHostKind + }) + setState({ status: 'ready', skills: result.skills, error: null, contextKey: paneCacheKey }) + }, + (reason) => { + if (cancelled) { + return + } + const error = reason instanceof Error ? reason : new Error(String(reason)) + const timedOut = /timed?\s*out|timeout/i.test(error.message) + emitNativeChatSkillDiscovery({ + agent, + outcome: timedOut ? 'timeout' : 'error', + executionHostKind: context.executionHostKind + }) + setState({ + status: 'error', + skills: [], + error, + errorKind: timedOut + ? 'timeout' + : context.executionHostKind === 'runtime' + ? 'host' + : 'unknown', + contextKey: paneCacheKey + }) + } + ) return () => { cancelled = true } - }, [agent, cwd]) + }, [agent, context, enabled, profile, retryGeneration]) - return skills + const effectiveState = useMemo( + () => + !profile || !enabled || !context + ? IDLE_STATE + : state.contextKey === context.key + ? state + : { status: 'loading' as const, skills: [], error: null, contextKey: context.key }, + [context, enabled, profile, state] + ) + const visibleSkills = useMemo(() => { + if (!profile || effectiveState.status !== 'ready') { + return [] + } + const result = context ? paneDiscoveryCache.current.get(context.key) : undefined + return result + ? effectiveState.skills.filter((skill) => isNativeChatSkillForAgent(agent, skill, result)) + : [] + }, [agent, context, effectiveState, profile]) + + const retry = useCallback(() => { + if (context) { + paneDiscoveryCache.current.delete(context.key) + setState({ status: 'loading', skills: [], error: null, contextKey: context.key }) + } + setRetryGeneration((generation) => generation + 1) + }, [context]) + return useMemo( + () => ({ + status: effectiveState.status, + skills: visibleSkills, + error: effectiveState.error, + ...(effectiveState.errorKind ? { errorKind: effectiveState.errorKind } : {}), + retry + }), + [effectiveState, retry, visibleSkills] + ) +} + +function getOrStartDiscovery( + context: NativeChatSkillDiscoveryContext +): Promise { + const existing = inFlightDiscovery.get(context.key) + if (existing) { + return existing + } + // Why: the local runtime.call branch ignores timeoutMs, so the renderer must + // enforce the design's scan timeout itself or a stalled local scan loads forever. + const request = withDiscoveryTimeout( + callRuntimeRpc( + context.runtimeTarget, + 'skills.discover', + context.discoveryTarget, + { + timeoutMs: DISCOVERY_TIMEOUT_MS + } + ), + DISCOVERY_BACKSTOP_TIMEOUT_MS + ).finally(() => { + if (inFlightDiscovery.get(context.key) === request) { + inFlightDiscovery.delete(context.key) + } + }) + inFlightDiscovery.set(context.key, request) + return request +} + +function withDiscoveryTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Skill discovery timed out.')), timeoutMs) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (reason) => { + clearTimeout(timer) + reject(reason) + } + ) + }) +} + +export function resetNativeChatSkillDiscoveryCacheForTests(): void { + inFlightDiscovery.clear() } diff --git a/src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts b/src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts new file mode 100644 index 00000000000..c82e808e9fa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts @@ -0,0 +1,50 @@ +import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react' +import type { HistoryState } from './native-chat-composer-state' + +/** Imperative text insertion and focus for the composer textarea, used by the + * paste pipeline and the composer's imperative handle. */ +export function useNativeChatTypedInsertion(args: { + textareaRef: RefObject + caret: number + draft: string + setDraft: (value: string) => void + setCaret: Dispatch> + setHistory: Dispatch> + setActiveSuggestion: Dispatch> +}): { insertTypedText: (text: string) => boolean; focus: () => boolean } { + const { textareaRef, caret, draft, setDraft, setCaret, setHistory, setActiveSuggestion } = args + + const insertTypedText = useCallback( + (text: string): boolean => { + const textarea = textareaRef.current + if (!textarea || textarea.disabled) { + return false + } + const selectionStart = textarea.selectionStart ?? caret + const selectionEnd = textarea.selectionEnd ?? selectionStart + const next = `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}` + const nextCaret = selectionStart + text.length + textarea.focus() + setDraft(next) + setCaret(nextCaret) + setHistory((prev) => ({ entries: prev.entries, index: null })) + setActiveSuggestion(0) + requestAnimationFrame(() => { + textarea.setSelectionRange(nextCaret, nextCaret) + }) + return true + }, + [caret, draft, setActiveSuggestion, setCaret, setDraft, setHistory, textareaRef] + ) + + const focus = useCallback((): boolean => { + const textarea = textareaRef.current + if (!textarea || textarea.disabled) { + return false + } + textarea.focus() + return true + }, [textareaRef]) + + return { insertTypedText, focus } +} diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts index ab1d1a43f91..e0916c5fad0 100644 --- a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts @@ -301,4 +301,24 @@ describe('onboarding feature setup runner', () => { installCli.mock.invocationCallOrder[0] ) }) + + it('warns without changing PATH when the Windows registry read is unknown', async () => { + const unknownStatus: CliInstallStatus = { + ...INSTALLED_CLI_STATUS, + platform: 'win32', + pathConfigured: null, + detail: 'Orca could not read the Windows user PATH registry value.' + } + const deps = createDeps({ getCliStatus: vi.fn(async () => unknownStatus) }) + + const result = await runOnboardingFeatureSetup( + { browserUse: true, computerUse: false, orchestration: false, linearTickets: false }, + deps + ) + + expect(result.cliTouched).toBe(false) + expect(result.warnings).toContainEqual({ featureId: 'cli', message: unknownStatus.detail }) + expect(deps.showCliRegistrationPrompt).not.toHaveBeenCalled() + expect(deps.installCli).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.ts index c16632d874b..f92a5e85f63 100644 --- a/src/renderer/src/components/onboarding/onboarding-feature-setup.ts +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.ts @@ -225,7 +225,13 @@ export async function runOnboardingFeatureSetup( featureId: 'cli', message: status.detail ?? 'Orca CLI registration is not available on this platform.' }) - } else if (status.state !== 'installed' || !status.pathConfigured) { + } else if (status.pathConfigured === null) { + // Why: an unknown registry read cannot safely drive a PATH read-modify-write. + warnings.push({ + featureId: 'cli', + message: status.detail ?? 'Orca could not check your Windows user PATH.' + }) + } else if (status.state !== 'installed' || status.pathConfigured === false) { await deps.showCliRegistrationPrompt?.() const next = await deps.installCli() cliTouched = true @@ -234,7 +240,7 @@ export async function runOnboardingFeatureSetup( featureId: 'cli', message: next.detail ?? 'Orca CLI registration needs attention.' }) - } else if (!next.pathConfigured && next.detail) { + } else if (next.pathConfigured !== true && next.detail) { warnings.push({ featureId: 'cli', message: next.detail }) } } diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts index 12fe7d1bf00..c0354249fc5 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -119,7 +119,7 @@ export function useCloseWith({ const total = Math.max(0, Date.now() - startTimeRef.current) // Why: no `is_git_repo` — project selection now happens in the Add // Project modal after this fires, so the signal moved to - // `repo_added.is_git_repo`. See docs/reference/telemetry-availability.md. + // `repo_added.is_git_repo`. track('onboarding_completed', { path: completedPath, total_duration_ms: total diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index ac08b19fdf9..66ff0f85a35 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -60,7 +60,8 @@ import type { PRInfo, PRCheckDetail, PRCheckRunDetails, - PRComment + PRComment, + PRRefreshErrorType } from '../../../../shared/types' import { getConnectionId } from '@/lib/connection-context' import { @@ -82,6 +83,7 @@ import type { HostedReviewProvider } from '../../../../shared/hosted-review' import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers' +import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' import { getHostedReviewCacheKey, refreshHostedReviewCard } from '@/store/slices/hosted-review' import { toast } from 'sonner' @@ -99,11 +101,17 @@ import { } from './pr-comment-thread-resolution' import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller' import { - getChecksPanelEmptyStateCopy, + getChecksPanelReviewState, shouldShowChecksPanelPublishBranchAction } from './checks-panel-empty-state' import { getChecksPanelRefreshErrorBannerLine } from './github-refresh-error-copy' -import { hasAmbiguousGitHubHostedReviewForChecksPanel } from './checks-panel-ambiguous-github-review' +import { resolveChecksPanelReviewLookup } from './checks-panel-review-lookup-authority' +import { + computeChecksPanelConfirmedReadiness, + isChecksPanelHardErrorCleared, + isChecksPanelHardRefreshErrorType, + type ChecksPanelConfirmedReadinessInput +} from './checks-panel-review-creation' import { recordChecksPanelPRRefreshBreadcrumb } from './checks-panel-pr-refresh-breadcrumb' import { cancelRuntimeGeneratePullRequestFields, @@ -181,12 +189,47 @@ const GIT_STATUS_FAILURE_RETRY_MS = 3000 type HostedReviewCreationSnapshot = { requestKey: string + /** Panel context key (repo/worktree/branch/host) at request time. */ + contextKey: string repoId: string worktreeId: string | null branch: string + /** Wall-clock time the eligibility request started (hard-error clear ordering). */ + requestStartedAt: number + /** Wall-clock time the eligibility result settled (confirmed freshness window). */ + completedAt: number + /** Git snapshot fingerprint used for this eligibility (confirmed freshness). */ + gitFingerprint: string data: HostedReviewCreationEligibility } +// Confirmed readiness must drop when HEAD, dirty, upstream, base, or the +// execution host move under the eligibility that produced it. Fingerprint those +// fields so a stale snapshot cannot keep an enabled Create open. +function buildChecksPanelEligibilityGitFingerprint(input: { + headOid: string | null + hasUncommittedChanges: boolean | undefined + hasUpstream: boolean | undefined + ahead: number | undefined + behind: number | undefined + base: string | null + runtimeEnvironmentId: string | null + repoConnectionId: string | null + localExecutionScope: string | null +}): string { + return JSON.stringify({ + headOid: input.headOid ?? null, + hasUncommittedChanges: input.hasUncommittedChanges ?? null, + hasUpstream: input.hasUpstream ?? null, + ahead: input.ahead ?? null, + behind: input.behind ?? null, + base: input.base ?? null, + runtimeEnvironmentId: input.runtimeEnvironmentId ?? null, + repoConnectionId: input.repoConnectionId ?? null, + localExecutionScope: input.localExecutionScope ?? null + }) +} + type ChecksAgentComposerState = { actionId: SourceControlLaunchActionId title: string @@ -416,6 +459,7 @@ export default function ChecksPanel(): React.JSX.Element { ) const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive) const pushBranch = useAppStore((s) => s.pushBranch) + const syncBranch = useAppStore((s) => s.syncBranch) const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus) const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) @@ -452,6 +496,7 @@ export default function ChecksPanel(): React.JSX.Element { const [isCreatingPr, setIsCreatingPr] = useState(false) const [createPrError, setCreatePrError] = useState(null) const [isPublishingBranch, setIsPublishingBranch] = useState(false) + const [isSyncingBranch, setIsSyncingBranch] = useState(false) const isResolvingConflictsWithAI = false const [isFixingChecksWithAI, setIsFixingChecksWithAI] = useState(false) const [agentComposerState, setAgentComposerState] = useState( @@ -459,10 +504,28 @@ export default function ChecksPanel(): React.JSX.Element { ) const [hostedReviewCreationSnapshot, setHostedReviewCreationSnapshot] = useState(null) + // Sticky observation of the most recent hard refresh error for the exact + // context. Persisted across queued/in-flight auto-retries so Create cannot + // flap back until a qualifying eligibility request clears it. + const [hardRefreshError, setHardRefreshError] = useState<{ + observedAt: number + errorType: PRRefreshErrorType + contextKey: string + } | null>(null) const [gitStatusSnapshot, setGitStatusSnapshot] = useState( null ) + // Context key whose git-status probe failed with no usable snapshot, so the + // empty state can distinguish "checking branch status" from "could not check". + const [gitStatusProbeErrorContextKey, setGitStatusProbeErrorContextKey] = useState( + null + ) const [gitStatusRefreshNonce, setGitStatusRefreshNonce] = useState(0) + // Bumped by a manual Retry/Refresh so eligibility re-runs even when the Git + // snapshot is unchanged. A hard error can only be cleared by an eligibility + // request that started strictly after the error, so an auth fix followed by + // Retry (which does not change Git state) must still trigger a fresh request. + const [eligibilityRefreshNonce, setEligibilityRefreshNonce] = useState(0) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState('') const [titleSaving, setTitleSaving] = useState(false) @@ -543,6 +606,16 @@ export default function ChecksPanel(): React.JSX.Element { [runtimeEnvironmentId, settings] ) const repoConnectionId = repo?.connectionId?.trim() || null + // Local execution host variant (`wsl:{distro}` vs `host`). WSL is a local-host + // variant, not a runtime/SSH host, so it only applies when execution is local; + // remote contexts are already scoped by runtimeEnvironmentId / connectionId. + const localExecutionScope = useMemo(() => { + if (runtimeEnvironmentId != null || repoConnectionId != null) { + return null + } + const localRuntime = normalizeGlobalWindowsRuntimeDefault(settings?.localWindowsRuntimeDefault) + return localRuntime.kind === 'wsl' ? `wsl:${localRuntime.distro ?? ''}` : 'host' + }, [runtimeEnvironmentId, repoConnectionId, settings?.localWindowsRuntimeDefault]) const sshConnectionStatus = useAppStore((s) => repoConnectionId ? s.sshConnectionStates.get(repoConnectionId)?.status : undefined ) @@ -558,6 +631,7 @@ export default function ChecksPanel(): React.JSX.Element { linkedGiteaPR: activeWorktree?.linkedGiteaPR ?? null, runtimeEnvironmentId, repoConnectionId, + localExecutionScope, pushTarget: activeWorktreePushTarget }) const panelContextKeyRef = useRef(panelContextKey) @@ -607,7 +681,9 @@ export default function ChecksPanel(): React.JSX.Element { setIsPublishingBranch(false) setAgentComposerState(null) setHostedReviewCreationSnapshot(null) + setHardRefreshError(null) setGitStatusSnapshot(null) + setGitStatusProbeErrorContextKey(null) setGitStatusRefreshNonce((value) => value + 1) pollIntervalRef.current = 30_000 prevChecksRef.current = '' @@ -659,11 +735,13 @@ export default function ChecksPanel(): React.JSX.Element { const hostedReview = useAppStore((s) => hostedReviewCacheKey ? (s.hostedReviewCache[hostedReviewCacheKey]?.data ?? null) : null ) - const hasAmbiguousGitHubHostedReview = hasAmbiguousGitHubHostedReviewForChecksPanel({ - hostedReview, - prCacheEntry, - prCacheKey - }) + const linkedReviewNumber = + activeWorktree?.linkedPR ?? + activeWorktree?.linkedGitLabMR ?? + activeWorktree?.linkedBitbucketPR ?? + activeWorktree?.linkedAzureDevOpsPR ?? + activeWorktree?.linkedGiteaPR ?? + null // Fetch PR data when the active worktree/branch changes. // Why: branch lookup is lossy for fork/deleted-head PRs; reuse a known PR // number from metadata or the visible cache whenever we have one. @@ -748,6 +826,25 @@ export default function ChecksPanel(): React.JSX.Element { panelVisibleSinceRef.current = Date.now() }, [isPanelVisible, panelContextKey]) + // Record the latest hard refresh error for the exact context. Kept sticky + // (only cleared via computeChecksPanelConfirmedReadiness) so a background + // queued/in-flight auto-retry — which replaces `status: 'error'` — cannot + // silently re-enable Create while the lookup is still impossible here. + useEffect(() => { + const errorType = prRefreshState?.status === 'error' ? prRefreshState.errorType : undefined + if (!isChecksPanelHardRefreshErrorType(errorType)) { + return + } + const observedAt = prRefreshState?.updatedAt ?? Date.now() + const contextKey = panelContextKeyRef.current + setHardRefreshError((prev) => { + if (prev && prev.contextKey === contextKey && prev.observedAt >= observedAt) { + return prev + } + return { observedAt, errorType: errorType as PRRefreshErrorType, contextKey } + }) + }, [prRefreshState]) + // Why: select only timestamps (not whole cache records) so the entry-refresh // effect doesn't re-run on every cache mutation. See // docs/refresh-on-checks-tab.md. @@ -824,6 +921,27 @@ export default function ChecksPanel(): React.JSX.Element { const gitStatusReadyForPanelContext = gitStatusInputs.hasUncommittedChanges !== undefined const hasUncommittedChanges = gitStatusInputs.hasUncommittedChanges const remoteStatus = gitStatusInputs.remoteStatus + const eligibilityHeadOid = + gitStatusSnapshot?.contextKey === panelContextKey + ? (gitStatusSnapshot.gitIdentity?.head ?? null) + : null + // Read at eligibility request time via a ref so a HEAD move drops confirmed + // (fingerprint mismatch) without re-triggering the eligibility network call. + const eligibilityHeadOidRef = useRef(eligibilityHeadOid) + eligibilityHeadOidRef.current = eligibilityHeadOid + const eligibilityGitFingerprint = gitStatusReadyForPanelContext + ? buildChecksPanelEligibilityGitFingerprint({ + headOid: eligibilityHeadOid, + hasUncommittedChanges, + hasUpstream: remoteStatus?.hasUpstream, + ahead: remoteStatus?.ahead, + behind: remoteStatus?.behind, + base: repo?.worktreeBaseRef ?? null, + runtimeEnvironmentId, + repoConnectionId, + localExecutionScope + }) + : null // Why: Create PR eligibility waits for the stricter panel snapshot, but the // Publish affordance can use the active worktree poller when SSH snapshot // refresh is delayed; publishing is still blocked for dirty fallback status. @@ -843,7 +961,70 @@ export default function ChecksPanel(): React.JSX.Element { const hostedReviewCreateProvider = resolveHostedReviewCreationProvider( hostedReviewCreation?.provider ) + // Only GitHub contexts run the gh-based PR refresh coordinator. Bitbucket / + // Azure DevOps / Gitea / GitLab must never be driven by (or show copy from) a + // GitHub `gh` failure — that would hide a valid composer and mislabel the CLI. + // Prefer the resolved eligibility provider (authoritative); before it resolves, + // treat a non-GitHub linked review as a definite non-GitHub signal and stay + // GitHub-optimistic otherwise so GitHub status is not delayed on initial load. + // (`resolveHostedReviewCreationProvider` defaults null → 'github', so the raw + // value cannot distinguish "unknown" from "GitHub".) + const hasNonGitHubLinkedReview = + activeWorktree?.linkedGitLabMR != null || + activeWorktree?.linkedBitbucketPR != null || + activeWorktree?.linkedAzureDevOpsPR != null || + activeWorktree?.linkedGiteaPR != null + const isGitHubReviewContext = hostedReviewCreation + ? hostedReviewCreation.provider === 'github' + : !hasNonGitHubLinkedReview const hostedReviewCreateCopy = localizedHostedReviewCopy(hostedReviewCreateProvider) + // The PR cache is branch/host scoped but not push-target scoped, so an accepted + // no-PR fetched for a previous push target could alias as `not_found` after the + // push target (or other exact-context field) changes. When an eligibility + // snapshot exists for a *different* context than the current one, the context + // has moved and that branch-scoped no-PR is not authoritative for it — demote + // it to unknown until eligibility re-resolves. Same-context background churn + // (snapshot matches, or no snapshot yet) keeps the sticky no-review intact. + const prCachedHasPRForContext = + hostedReviewCreationSnapshot && hostedReviewCreationSnapshot.contextKey !== panelContextKey + ? null + : prCachedHasPR + // Four-state review evidence: replaces the old ambiguous-GitHub boolean so the + // empty state can never claim "No review found" without accepted evidence. + const checksPanelReviewLookupResult = resolveChecksPanelReviewLookup({ + pr, + prCachedHasPR: prCachedHasPRForContext, + hostedReview, + linkedReviewNumber, + eligibilityReviewLookupOutcome: hostedReviewCreation?.reviewLookupOutcome ?? null, + eligibilityReview: hostedReviewCreation?.review ?? null + }) + const checksPanelReviewLookup = checksPanelReviewLookupResult.state + // Confirmed readiness for the exact context, computed from the last confirmed + // eligibility snapshot rather than live `canCreate` (which would be circular + // and would flap during transient refresh/eligibility failures). This both + // gates the composer and clears the sticky hard-error block below. + const hardErrorObservedAt = + isGitHubReviewContext && hardRefreshError && hardRefreshError.contextKey === panelContextKey + ? hardRefreshError.observedAt + : undefined + const confirmedReadinessInput: ChecksPanelConfirmedReadinessInput = { + contextKeyMatches: hostedReviewCreationSnapshot?.contextKey === panelContextKey, + eligibility: hostedReviewCreationSnapshot?.data ?? null, + eligibilityCompletedAt: hostedReviewCreationSnapshot?.completedAt, + eligibilityRequestStartedAt: hostedReviewCreationSnapshot?.requestStartedAt, + reviewLookup: checksPanelReviewLookup, + hardErrorObservedAt, + gitSnapshotMatches: + eligibilityGitFingerprint !== null && + hostedReviewCreationSnapshot?.gitFingerprint === eligibilityGitFingerprint, + now: Date.now() + } + const confirmedReadiness = computeChecksPanelConfirmedReadiness(confirmedReadinessInput) + // A hard error stays active across auto-retries until a qualifying eligibility + // request clears it, so `status: 'queued' | 'in-flight'` no longer un-hides Create. + const checksPanelHasHardRefreshError = + hardErrorObservedAt !== undefined && !isChecksPanelHardErrorCleared(confirmedReadinessInput) const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({ worktreeId: activeWorktreeId, worktreePath: activeWorktreePath, @@ -919,12 +1100,12 @@ export default function ChecksPanel(): React.JSX.Element { () => (settings ? resolveSourceControlAiEnabled({ settings, repo }) : false), [repo, settings] ) + // Confirmed-only composer gate. Driven by confirmed readiness (context match, + // fresh eligibility, matching git snapshot, cleared hard error) so an + // already-confirmed composer survives transient refresh/eligibility failures + // but a refresh failure never *opens* a never-confirmed Create. const createComposerOpen = - !activeReview && - !isFolder && - Boolean(branch) && - (hostedReviewCreation?.canCreate === true || - hostedReviewCreation?.blockedReason === 'needs_push') + !isFolder && !activeReview && Boolean(branch) && confirmedReadiness.confirmed const handleGeneratePullRequestFieldsForActive = useCallback( async ( fields: PullRequestGenerationFields, @@ -1121,6 +1302,9 @@ export default function ChecksPanel(): React.JSX.Element { submitting: isCreatingPr, prCreationDefaults, sourceControlAiActionsVisible, + // Preserve the draft when a hard refresh error hides the composer so the + // user's title/body/base survive recovery for the same exact context. + retainDraftWhenClosed: true, generation: { generating: activePullRequestGenerationRecord?.status === 'running', generateError: activePullRequestGenerationRecord?.error ?? null, @@ -1232,7 +1416,10 @@ export default function ChecksPanel(): React.JSX.Element { linkedGiteaPR, staleWhileRevalidate: true }) - if (activeWorktreeId && !isGitLabReviewContext) { + // Why: the gh-based PR refresh coordinator is GitHub-only. Running it for + // Bitbucket/Azure/Gitea produced a spurious `gh_unavailable` hard error that + // hid a valid composer; those providers refresh via fetchHostedReviewForBranch. + if (activeWorktreeId && isGitHubReviewContext) { const refreshRequest = resolveChecksPanelPRRefreshRequest({ cachedHasPR: prCachedHasPR, cachedFetchedAt: prFetchedAt ?? null, @@ -1248,7 +1435,7 @@ export default function ChecksPanel(): React.JSX.Element { fallbackGitHubPRNumber, fetchHostedReviewForBranch, isFolder, - isGitLabReviewContext, + isGitHubReviewContext, isPanelVisible, activeWorktree?.head, linkedAzureDevOpsPR, @@ -1382,6 +1569,9 @@ export default function ChecksPanel(): React.JSX.Element { branch: status.branch ?? (status.head ? null : undefined) } }) + // A fresh probe succeeded, so this context is no longer in the failed + // "could not check branch status" state. + setGitStatusProbeErrorContextKey((key) => (key === requestContextKey ? null : key)) } }) .catch((error) => { @@ -1392,6 +1582,13 @@ export default function ChecksPanel(): React.JSX.Element { setGitStatusSnapshot((snapshot) => shouldClearChecksPanelGitStatusSnapshot(snapshot, requestContextKey) ? null : snapshot ) + // Mark the probe as failed so the empty state can show "Could not check + // branch status" instead of an indefinite "Checking branch status". + if ( + shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey) + ) { + setGitStatusProbeErrorContextKey(requestContextKey) + } gitStatusSnapshotRetryTimerRef.current = setTimeout(() => { gitStatusSnapshotRetryTimerRef.current = null if ( @@ -1454,6 +1651,19 @@ export default function ChecksPanel(): React.JSX.Element { return } let stale = false + const requestContextKey = panelContextKey + const requestStartedAt = Date.now() + const requestGitFingerprint = buildChecksPanelEligibilityGitFingerprint({ + headOid: eligibilityHeadOidRef.current, + hasUncommittedChanges, + hasUpstream: remoteStatus?.hasUpstream, + ahead: remoteStatus?.ahead, + behind: remoteStatus?.behind, + base: repo.worktreeBaseRef ?? null, + runtimeEnvironmentId, + repoConnectionId, + localExecutionScope + }) void getHostedReviewCreationEligibility({ repoPath: repo.path, repoId: repo.id, @@ -1475,22 +1685,31 @@ export default function ChecksPanel(): React.JSX.Element { if (!stale) { setHostedReviewCreationSnapshot({ requestKey: hostedReviewCreationRequestKey, + contextKey: requestContextKey, repoId: repo.id, worktreeId: activeWorktreeId, branch, + requestStartedAt, + completedAt: Date.now(), + gitFingerprint: requestGitFingerprint, data: result }) } }) .catch(() => { - if (!stale) { - setHostedReviewCreationSnapshot(null) - } + // Why: a transient GitHub outage makes the existing-review lookup inside + // eligibility rethrow. Do NOT tear down the last confirmed snapshot — a + // clean, already-confirmed composer must survive the outage. Confirmed + // readiness still gates it on context + git-fingerprint + freshness, so a + // stale snapshot can never open a never-confirmed Create. }) return () => { stale = true } }, [ + panelContextKey, + runtimeEnvironmentId, + repoConnectionId, activeWorktreeId, activeWorktreePath, branch, @@ -1498,6 +1717,8 @@ export default function ChecksPanel(): React.JSX.Element { gitStatusReadyForPanelContext, hasUncommittedChanges, hostedReviewCreationRequestKey, + eligibilityRefreshNonce, + localExecutionScope, isFolder, isPanelVisible, linkedPR, @@ -2181,6 +2402,10 @@ export default function ChecksPanel(): React.JSX.Element { if (isCurrentRequest()) { refreshInFlightRef.current = false setIsRefreshing(false) + // Why: force a fresh eligibility request whose start time is after any + // current hard-refresh error, so a resolved auth/permission failure can + // clear the sticky hard error even when Git state did not change. + setEligibilityRefreshNonce((value) => value + 1) } } }, [ @@ -3263,6 +3488,51 @@ export default function ChecksPanel(): React.JSX.Element { pushBranch ]) + // Sync the branch with its upstream using the same runtime-scoped operation and + // push target as Source Control, so a `needs_sync` create blocker is actionable + // from the Checks panel instead of showing guidance with no button. + const handleSyncBranch = useCallback(async (): Promise => { + if (!activeWorktreeId || !activeWorktree?.path || isSyncingBranch || isRemoteOperationActive) { + return + } + const connectionId = activeConnectionId ?? undefined + setIsSyncingBranch(true) + try { + await syncBranch( + activeWorktreeId, + activeWorktree.path, + connectionId, + activeWorktree.pushTarget, + { + runtimeTargetSettings: ownerSettings + } + ) + await fetchUpstreamStatus( + activeWorktreeId, + activeWorktree.path, + connectionId, + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } + ) + } catch { + // Store remote actions already surface the sync failure toast. + } finally { + // Why: syncing changes ahead/behind, which the panel uses to choose between + // Sync, Create PR, and Push & Create PR. + setGitStatusRefreshNonce((value) => value + 1) + setIsSyncingBranch(false) + } + }, [ + activeWorktree, + activeWorktreeId, + activeConnectionId, + fetchUpstreamStatus, + isSyncingBranch, + isRemoteOperationActive, + ownerSettings, + syncBranch + ]) + const handlePullRequestCreated = useCallback( async (result: { provider: HostedReviewProvider @@ -3581,17 +3851,79 @@ export default function ChecksPanel(): React.JSX.Element { hasUpstream: publishActionRemoteStatus?.hasUpstream, hasCurrentBranch: Boolean(branch) })) - const emptyStateCopy = getChecksPanelEmptyStateCopy({ + // Non-GitHub providers (GitLab/Bitbucket/Azure/Gitea) have no typed GitHub + // refresh state; only feed the GitHub refresh classification into the selector + // for GitHub contexts. When a hard error is still active (sticky across + // auto-retries), surface it as the refresh state so the hard-error card and + // composer suppression persist even while a queued/in-flight retry is + // temporarily the live status. + const emptyRefreshInput = !isGitHubReviewContext + ? undefined + : checksPanelHasHardRefreshError && hardRefreshError + ? { status: 'error' as const, errorType: hardRefreshError.errorType } + : prRefreshState + ? { + status: prRefreshState.status, + errorType: prRefreshState.errorType, + skippedReason: prRefreshState.skippedReason, + nextAutoRetryAt: prRefreshState.nextAutoRetryAt, + retryDisabledUntil: prRefreshState.retryDisabledUntil + } + : undefined + const emptyGitStatusPhase: 'loading' | 'ready' | 'error' = + gitStatusInputs.hasUncommittedChanges !== undefined + ? 'ready' + : gitStatusProbeErrorContextKey === panelContextKey + ? 'error' + : 'loading' + const reviewState = getChecksPanelReviewState({ operationLabel, - prRefreshStatus: emptyReviewIsGitLab ? undefined : prRefreshState?.status, - prRefreshErrorType: emptyReviewIsGitLab ? undefined : prRefreshState?.errorType, - hostedReviewBlockedReason: hostedReviewCreation?.blockedReason, - hasUpstream: publishActionRemoteStatus?.hasUpstream, - hasCurrentBranch: Boolean(branch), reviewLabel: emptyReviewLabel, reviewShortLabel: emptyReviewShortLabel, - hasAmbiguousGitHubHostedReview + providerName: hostedReviewCreateCopy.providerName, + isGitHubProvider: hostedReviewCreateProvider === 'github', + reviewLookup: checksPanelReviewLookup, + openReviewUrl: checksPanelReviewLookupResult.openReviewUrl, + eligibilityBlockedReason: hostedReviewCreation?.blockedReason, + // Confirmed readiness (not the live create gate) drives composer mode so + // the selector matches the preserved-composer semantics. + confirmedReadiness: confirmedReadiness.confirmed, + confirmedNeedsPush: confirmedReadiness.needsPush, + refresh: emptyRefreshInput, + gitStatusPhase: emptyGitStatusPhase, + hasUpstream: publishActionRemoteStatus?.hasUpstream, + hasCurrentBranch: Boolean(branch) }) + const emptyStateCopy = { title: reviewState.title, description: reviewState.description } + const reviewStateAutoRetryText = + reviewState.autoRetryAt !== undefined && reviewState.autoRetryAt > Date.now() + ? translate( + 'auto.components.right.sidebar.ChecksPanel.review.auto_retry', + 'Orca will retry at {{time}}.', + { time: new Date(reviewState.autoRetryAt).toLocaleTimeString() } + ) + : null + const reviewRecoveryRetryDisabled = + reviewState.retryDisabledUntil !== undefined && Date.now() < reviewState.retryDisabledUntil + const reviewRecoveryLabelIsRefresh = reviewState.recovery.includes('refresh') + // Only offer Retry/Refresh when the selector's recovery set includes it; + // states like git-status loading, bare, or archived expose no recovery. + const reviewShowRetryOrRefresh = + reviewState.recovery.includes('retry') || reviewRecoveryLabelIsRefresh + const reviewShowOpenReview = + reviewState.recovery.includes('open_review') && Boolean(reviewState.openReviewUrl) + // The selector is the authority for the Sync workflow action; a `needs_sync` + // create blocker must expose Sync Branch, not just guidance copy. + const reviewShowSyncBranch = reviewState.workflowAction === 'sync_branch' + // The action row must render whenever any action is available — recovery + // actions (Retry/Refresh/Open Review) are shown independently of the composer + // so a preserved confirmed composer still exposes a working Retry during a + // transient failure. + const reviewShowActionRow = + canPublishBranch || + reviewShowSyncBranch || + (reviewShowOpenReview && Boolean(reviewState.openReviewUrl)) || + reviewShowRetryOrRefresh return (
{detachedHeadDisplay && ( @@ -3601,6 +3933,12 @@ export default function ChecksPanel(): React.JSX.Element { )}
{emptyStateCopy.title}
{emptyStateCopy.description}
+ {reviewState.detail ? ( +
{reviewState.detail}
+ ) : null} + {reviewStateAutoRetryText ? ( +
{reviewStateAutoRetryText}
+ ) : null} {!operationInProgress && createComposerOpen ? (
) : null} - {!operationInProgress && (!createComposerOpen || canPublishBranch) && ( + {!operationInProgress && reviewShowActionRow && (
{canPublishBranch && ( + )} + {reviewShowOpenReview && reviewState.openReviewUrl ? ( + ) : null} + {reviewShowRetryOrRefresh ? ( + ) : null}
diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index b360795e662..a34540f7823 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -448,7 +448,8 @@ describe('CommitArea', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) const markup = renderCommitArea(baseProps(input)) @@ -481,7 +482,8 @@ describe('CommitArea', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) const markup = renderCommitArea(baseProps(input)) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 665fd7419c1..d9cb51a24a3 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -195,7 +195,10 @@ import { resolveCreateReviewDraftTitle } from './create-review-draft-title' import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel' import { useGitHistoryCommitActions } from './useGitHistoryCommitActions' import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' -import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' +import { + isBehindOnlyUpstream, + shouldForcePushWithLeaseForUpstream +} from '../../../../shared/git-upstream-status' import type { DiffComment, GitBranchChangeEntry, @@ -258,7 +261,6 @@ import { createPrIntentRunTokenMatches, getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, - isCreatePrIntentSyncConflictError, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep, type CreatePrIntentRunToken @@ -2515,6 +2517,16 @@ function SourceControlInner(): React.JSX.Element { // chevron dropdown can trigger. Keeps the error-swallow pattern in one // place — store slices already surface actionable toasts, so additional // try/catch here would duplicate the notification. + // Why: callers must distinguish real failures from "a newer remote action won + // the sequence" and "this call was a no-op (missing target/base)". Collapsing + // those into `{ ok: false, error: null }` made Create PR treat supersession as + // a destructive remote failure. + type RunRemoteActionResult = + | { status: 'ok' } + | { status: 'failed'; error: SourceControlActionError } + | { status: 'superseded' } + | { status: 'skipped' } + const runRemoteAction = useCallback( async ( kind: @@ -2530,10 +2542,7 @@ function SourceControlInner(): React.JSX.Element { target?: SourceControlOperationTarget baseRef?: string | null } - // Why: return the failure inline (null error = success or superseded) so - // callers like the Create PR intent flow can classify it without a - // stale-prone mirror of remoteActionErrors. - ): Promise<{ ok: boolean; error: SourceControlActionError | null }> => { + ): Promise => { const target = options?.target ?? (activeWorktreeId && worktreePath @@ -2546,7 +2555,7 @@ function SourceControlInner(): React.JSX.Element { } : null) if (!target) { - return { ok: false, error: null } + return { status: 'skipped' } } const sequence = (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] ?? 0) + 1 remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] = sequence @@ -2572,7 +2581,7 @@ function SourceControlInner(): React.JSX.Element { target.pushTarget, { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'push') { // Why: kind 'push' must stay a regular push. Force-with-lease is only @@ -2587,7 +2596,7 @@ function SourceControlInner(): React.JSX.Element { target.pushTarget, { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'force_push') { await pushBranch( @@ -2598,7 +2607,7 @@ function SourceControlInner(): React.JSX.Element { target.pushTarget, { forceWithLease: true, runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'pull') { await pullBranch( @@ -2610,7 +2619,7 @@ function SourceControlInner(): React.JSX.Element { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'fast_forward') { await fastForwardBranch( @@ -2620,7 +2629,7 @@ function SourceControlInner(): React.JSX.Element { target.pushTarget, { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'fetch') { await fetchBranch( @@ -2632,12 +2641,12 @@ function SourceControlInner(): React.JSX.Element { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'rebase') { const baseRef = options?.baseRef ?? effectiveBaseRef if (!baseRef) { - return { ok: false, error: null } + return { status: 'skipped' } } await rebaseFromBase( target.worktreeId, @@ -2647,7 +2656,7 @@ function SourceControlInner(): React.JSX.Element { target.pushTarget, { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } await syncBranch( target.worktreeId, @@ -2661,14 +2670,14 @@ function SourceControlInner(): React.JSX.Element { if (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] === sequence) { setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: null })) } - return { ok: true, error: null } + return { status: 'ok' } } catch (error) { // Why: remote action failures are surfaced by editor-slice actions to keep // one consistent toast path and avoid duplicate notifications in the UI. // Keep the latest failure inline too: dropdown-only actions like Fetch can // otherwise look like nothing happened once the menu closes. if (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] !== sequence) { - return { ok: false, error: null } + return { status: 'superseded' } } const actionError: SourceControlActionError = { kind, @@ -2682,7 +2691,7 @@ function SourceControlInner(): React.JSX.Element { sequence } setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: actionError })) - return { ok: false, error: actionError } + return { status: 'failed', error: actionError } } finally { if (!options?.target) { refreshSourceControlAfterRemoteAction({ @@ -3333,10 +3342,8 @@ function SourceControlInner(): React.JSX.Element { if (stale) { return } - // Why: a failed/timed-out remote probe must not leave an inert disabled - // button. When the local git status determines a blocker (dirty, no - // upstream, needs push/sync), surface the actionable preparation intent; - // otherwise fall back to the retryable failed state. + // Why: a failed remote probe can provide branch guidance, but it cannot + // authorize hosted-review creation. const localBlocker = buildLocalBlockerHostedReviewCreationEligibility( resolveCurrentHostedReviewCreationProvider(), { @@ -3967,6 +3974,42 @@ function SourceControlInner(): React.JSX.Element { return } + // Why: fast-forward behind-only *before* commit so a dirty worktree does + // not become ahead+behind after Create PR commits, then dead-end at the + // explicit sync-first stop. --ff-only refuses if the branch diverged mid + // flight or local edits would be overwritten — never auto-merges. + if (isBehindOnlyUpstream(latestUpstreamStatus)) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentFastForwarding', + 'Updating branch…' + ) + }) + const earlyFfResult = await runRemoteAction('fast_forward', { + target: operationTarget + }) + if (abortIfStale()) { + return + } + if (earlyFfResult.status === 'superseded') { + return + } + if (earlyFfResult.status !== 'ok') { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentRemoteFailed', + 'Could not update the remote branch. Retry Create PR.' + ) + }) + return + } + if (!(await refreshIntentSnapshot())) { + return + } + } + if (!(await stageLatestIntentPaths())) { return } @@ -4100,7 +4143,7 @@ function SourceControlInner(): React.JSX.Element { if (remoteStep === 'blocked' || remoteStep === 'none') { setCreatePrIntentNoticeForWorktree(token.worktreeId, { tone: 'muted', - // Why: a diverged branch is deliberately not auto-synced (that would + // Why: a diverged branch is deliberately not auto-prepared (that would // merge without consent), so keep the explicit sync-first guidance. message: eligibility.blockedReason === 'needs_sync' @@ -4131,10 +4174,10 @@ function SourceControlInner(): React.JSX.Element { 'auto.components.right.sidebar.SourceControl.createPrIntentForcePushing', 'Force pushing with lease…' ) - : remoteStep === 'sync' + : remoteStep === 'fast_forward' ? translate( - 'auto.components.right.sidebar.SourceControl.createPrIntentSyncing', - 'Syncing branch…' + 'auto.components.right.sidebar.SourceControl.createPrIntentFastForwarding', + 'Updating branch…' ) : translate( 'auto.components.right.sidebar.SourceControl.createPrIntentPushing', @@ -4148,20 +4191,17 @@ function SourceControlInner(): React.JSX.Element { if (abortIfStale()) { return } - if (!remoteResult.ok) { - // A sync push-stage rejection is not a conflict — it falls to the generic - // copy so the push-recovery panel drives the fix. + // Superseded by a newer remote action — drop quietly, same as target drift. + if (remoteResult.status === 'superseded') { + return + } + if (remoteResult.status !== 'ok') { setCreatePrIntentNoticeForWorktree(token.worktreeId, { tone: 'destructive', - message: isCreatePrIntentSyncConflictError(remoteResult.error) - ? translate( - 'auto.components.right.sidebar.SourceControl.createPrIntentSyncConflicts', - 'Sync hit conflicts. Resolve them, then retry Create PR.' - ) - : translate( - 'auto.components.right.sidebar.SourceControl.createPrIntentRemoteFailed', - 'Could not update the remote branch. Retry Create PR.' - ) + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentRemoteFailed', + 'Could not update the remote branch. Retry Create PR.' + ) }) return } diff --git a/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.test.ts deleted file mode 100644 index db51fd6ef33..00000000000 --- a/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { hasAmbiguousGitHubHostedReviewForChecksPanel } from './checks-panel-ambiguous-github-review' - -describe('hasAmbiguousGitHubHostedReviewForChecksPanel', () => { - it('treats missing PR cache as ambiguous when a GitHub hosted review exists', () => { - expect( - hasAmbiguousGitHubHostedReviewForChecksPanel({ - hostedReview: { provider: 'github' }, - prCacheEntry: undefined, - prCacheKey: 'repo::feature' - }) - ).toBe(true) - }) - - it('treats cached no-PR data as ambiguous when a GitHub hosted review exists', () => { - expect( - hasAmbiguousGitHubHostedReviewForChecksPanel({ - hostedReview: { provider: 'github' }, - prCacheEntry: { data: null }, - prCacheKey: 'repo::feature' - }) - ).toBe(true) - }) - - it('does not treat existing PR cache data as ambiguous', () => { - expect( - hasAmbiguousGitHubHostedReviewForChecksPanel({ - hostedReview: { provider: 'github' }, - prCacheEntry: { data: { number: 12 } }, - prCacheKey: 'repo::feature' - }) - ).toBe(false) - }) - - it('does not treat non-GitHub hosted reviews as ambiguous', () => { - expect( - hasAmbiguousGitHubHostedReviewForChecksPanel({ - hostedReview: { provider: 'gitlab' }, - prCacheEntry: { data: null }, - prCacheKey: 'repo::feature' - }) - ).toBe(false) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.ts b/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.ts deleted file mode 100644 index bca25a7a228..00000000000 --- a/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.ts +++ /dev/null @@ -1,22 +0,0 @@ -type HostedReviewProviderLike = { - provider?: string | null -} - -type PRCacheEntryLike = { - data?: unknown -} - -/** - * Flags GitHub-hosted review metadata whose matching PR cache may still be unknown. - */ -export function hasAmbiguousGitHubHostedReviewForChecksPanel(input: { - hostedReview: HostedReviewProviderLike | null | undefined - prCacheEntry: PRCacheEntryLike | null | undefined - prCacheKey: string -}): boolean { - return ( - input.hostedReview?.provider === 'github' && - input.prCacheKey !== '' && - input.prCacheEntry?.data == null - ) -} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-blocker-copy.ts b/src/renderer/src/components/right-sidebar/checks-panel-blocker-copy.ts new file mode 100644 index 00000000000..1313afa7e05 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-blocker-copy.ts @@ -0,0 +1,220 @@ +import type { HostedReviewCreationBlockedReason } from '../../../../shared/hosted-review' +import { translate } from '@/i18n/i18n' +import { + autoRetrySchedule, + capitalizeReviewLabel, + isHardRefreshError, + type ChecksPanelRecoveryAction, + type ChecksPanelReviewState, + type ChecksPanelReviewStateInput +} from './checks-panel-review-state-model' +import { concurrentLookupDetail } from './checks-panel-review-copy' + +type KeyedCopy = { key: string; fallback: string } + +const SAFETY_COPY: Record< + | 'detached_head' + | 'dirty' + | 'default_branch' + | 'fork_head_unsupported' + | 'base_not_on_remote' + | 'unsupported_provider', + { title: KeyedCopy; body: KeyedCopy } +> = { + detached_head: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.detached.title', + fallback: 'No current branch' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.detached.body', + fallback: 'Check out a branch before creating a {{reviewLabel}}.' + } + }, + dirty: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.dirty.title', + fallback: 'Commit changes first' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.dirty.body', + fallback: 'Commit or stash your changes before creating a {{reviewLabel}}.' + } + }, + default_branch: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.default_branch.title', + fallback: 'On the default branch' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.default_branch.body', + fallback: 'Switch to a feature branch before creating a {{reviewLabel}}.' + } + }, + fork_head_unsupported: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.fork.title', + fallback: 'Fork head unsupported' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.fork.body', + fallback: 'Orca cannot create a {{reviewLabel}} from this fork head here.' + } + }, + base_not_on_remote: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.base_missing.title', + fallback: 'Base branch not on remote' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.base_missing.body', + fallback: + "This branch's base is not on the remote yet, so a {{reviewLabel}} cannot target it." + } + }, + unsupported_provider: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.unsupported.title', + fallback: '{{reviewLabelCap}} not supported here' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.unsupported.body', + fallback: 'This repository provider does not support creating a {{reviewLabel}} from Orca.' + } + } +} + +export function safetyBlockerState( + input: ChecksPanelReviewStateInput, + reason: NonNullable +): ChecksPanelReviewState { + const { reviewLabel } = input + if (reason === 'existing_review') { + // Same family as positive evidence — offer trusted Open Review, never Create. + return { + renderReview: false, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.existing.title', + '{{reviewLabelCap}} already exists', + { reviewLabelCap: capitalizeReviewLabel(reviewLabel) } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.existing.body', + 'Orca found an existing {{reviewLabel}} for this branch.', + { reviewLabel } + ), + composerMode: 'hidden', + workflowAction: null, + recovery: input.openReviewUrl ? ['open_review'] : [], + openReviewUrl: input.openReviewUrl + } + } + const copy = SAFETY_COPY[reason as keyof typeof SAFETY_COPY] ?? SAFETY_COPY.unsupported_provider + const vars = { reviewLabel, reviewLabelCap: capitalizeReviewLabel(reviewLabel) } + return { + renderReview: false, + title: translate(copy.title.key, copy.title.fallback, vars), + description: translate(copy.body.key, copy.body.fallback, vars), + composerMode: 'hidden', + workflowAction: null, + recovery: [] + } +} + +const BRANCH_BLOCKER_COPY: Record< + 'no_upstream' | 'needs_sync' | 'auth_required', + { title: KeyedCopy; body: KeyedCopy; workflow: ChecksPanelReviewState['workflowAction'] } +> = { + no_upstream: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.no_upstream.title', + fallback: 'No upstream configured' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.no_upstream.body', + fallback: 'Publish this branch to set its upstream before creating a {{reviewLabel}}.' + }, + workflow: 'publish_branch' + }, + needs_sync: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.needs_sync.title', + fallback: 'Branch needs to sync' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.needs_sync.body', + fallback: 'Sync this branch with its upstream before creating a {{reviewLabel}}.' + }, + workflow: 'sync_branch' + }, + auth_required: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.auth_required.title', + fallback: 'Connect {{provider}}' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.auth_required.body', + fallback: + '{{provider}} must be connected in this environment before Orca can create a {{reviewLabel}}.' + }, + workflow: null + } +} + +export function branchBlockerState( + input: ChecksPanelReviewStateInput, + reason: NonNullable +): ChecksPanelReviewState { + const { reviewLabel, providerName } = input + const detail = concurrentLookupDetail(input) + const schedule = detail ? autoRetrySchedule(input) : {} + // Positive-unresolved evidence with a trusted URL must still expose Open Review + // beneath the blocker (Create/Push & Create stay suppressed). Retry is offered + // whenever a concurrent lookup failure produced a detail sentence. + const canOpenReview = input.reviewLookup === 'positive_unresolved' && Boolean(input.openReviewUrl) + const lookupRecovery: ChecksPanelRecoveryAction[] = [ + ...(canOpenReview ? (['open_review'] as ChecksPanelRecoveryAction[]) : []), + ...(detail ? (['retry'] as ChecksPanelRecoveryAction[]) : []) + ] + const openReviewUrl = canOpenReview ? input.openReviewUrl : undefined + const vars = { reviewLabel, provider: providerName } + + if (reason === 'needs_push') { + // Push & Create unless review evidence or a hard error blocks create. + const blocked = + input.reviewLookup === 'positive_unresolved' || + isHardRefreshError(input.refresh) || + !input.confirmedReadiness + return { + renderReview: false, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.needs_push.title', + 'Branch has unpushed commits' + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.needs_push.body', + 'Push the latest commits before creating a {{reviewLabel}}.', + { reviewLabel } + ), + detail, + composerMode: blocked ? 'hidden' : 'needs_push_open', + workflowAction: blocked ? null : 'push_and_create', + recovery: lookupRecovery, + openReviewUrl, + ...schedule + } + } + + const copy = BRANCH_BLOCKER_COPY[reason as keyof typeof BRANCH_BLOCKER_COPY] + return { + renderReview: false, + title: translate(copy.title.key, copy.title.fallback, vars), + description: translate(copy.body.key, copy.body.fallback, vars), + detail, + composerMode: 'hidden', + workflowAction: copy.workflow, + recovery: lookupRecovery, + openReviewUrl, + ...schedule + } +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts index 7e3f8ca3c41..941012d7a8d 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts @@ -1,281 +1,263 @@ import { describe, expect, it } from 'vitest' import { - getChecksPanelEmptyStateCopy, - shouldShowChecksPanelPublishBranchAction + getChecksPanelReviewState, + shouldShowChecksPanelPublishBranchAction, + type ChecksPanelReviewStateInput } from './checks-panel-empty-state' -describe('getChecksPanelEmptyStateCopy', () => { - it('shows a local-only branch message instead of a refresh error', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: 'no_upstream', - hasUpstream: false - }) - ).toEqual({ - title: 'Branch not published', - description: 'Publish this branch before creating a pull request.' - }) +function input(overrides: Partial = {}): ChecksPanelReviewStateInput { + return { + operationLabel: null, + reviewLabel: 'pull request', + reviewShortLabel: 'PR', + providerName: 'GitHub', + isGitHubProvider: true, + reviewLookup: 'unknown', + openReviewUrl: null, + eligibilityBlockedReason: undefined, + confirmedReadiness: false, + confirmedNeedsPush: false, + refresh: undefined, + gitStatusPhase: 'ready', + hasUpstream: true, + hasCurrentBranch: true, + ...overrides + } +} + +describe('getChecksPanelReviewState — no-review honesty', () => { + it('only renders "No pull request found" for an accepted no-review result', () => { + expect(getChecksPanelReviewState(input({ reviewLookup: 'not_found' })).title).toBe( + 'No pull request found' + ) }) - it('uses remote status as a fallback before eligibility finishes', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: undefined, - hasUpstream: false - }).title - ).toBe('Branch not published') + it('never says "No pull request found" for a missing/unknown lookup', () => { + const state = getChecksPanelReviewState(input({ reviewLookup: 'unknown' })) + expect(state.title).not.toContain('No pull request found') + expect(state.title).toBe('Pull request status unavailable') }) - it('does not show unpublished branch copy when HEAD is detached', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: 'no_upstream', - hasUpstream: false, - hasCurrentBranch: false - }).title - ).toBe('Could not refresh pull request') + it('never says "No pull request found" for a paused refresh', () => { + const state = getChecksPanelReviewState( + input({ reviewLookup: 'unknown', refresh: { status: 'paused' } }) + ) + expect(state.title).toBe('GitHub refresh paused') }) - it('uses remote status as a fallback when eligibility has no concrete blocker', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: null, - hasUpstream: false - }).title - ).toBe('Branch not published') + it('never says "No pull request found" for a skipped refresh', () => { + const state = getChecksPanelReviewState( + input({ reviewLookup: 'unknown', refresh: { status: 'skipped', skippedReason: 'bare' } }) + ) + expect(state.title).toBe('Bare repository') }) - it('shows unpushed commits before a refresh error', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: 'needs_push', - hasUpstream: true - }) - ).toEqual({ - title: 'Branch has unpushed commits', - description: 'Push your branch before creating a pull request.' - }) - }) - - it('shows unpublished branch copy even when PR provider eligibility has another blocker', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: 'unsupported_provider', - hasUpstream: false - }).title - ).toBe('Branch not published') - }) - - it('keeps the generic refresh error when no local branch action is known', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: null, - hasUpstream: true - }).title - ).toBe('Could not refresh pull request') - }) - - it('uses merge request copy for GitLab review contexts', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: undefined, - hostedReviewBlockedReason: 'unsupported_provider', - hasUpstream: true, + it('uses merge request wording for GitLab', () => { + const state = getChecksPanelReviewState( + input({ reviewLabel: 'merge request', - reviewShortLabel: 'MR' + reviewShortLabel: 'MR', + providerName: 'GitLab', + isGitHubProvider: false, + reviewLookup: 'not_found' }) - ).toEqual({ - title: 'No merge request found', - description: 'Create a merge request to start checks and review.' - }) + ) + expect(state.title).toBe('No merge request found') + }) +}) + +describe('getChecksPanelReviewState — precedence', () => { + it('operation in progress wins', () => { + expect( + getChecksPanelReviewState(input({ operationLabel: 'Rebase', reviewLookup: 'not_found' })) + .title + ).toBe('Rebase in progress') }) - it('uses neutral copy when GitHub hosted-review data exists without PR cache', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: undefined, - hostedReviewBlockedReason: null, - hasUpstream: true, - hasAmbiguousGitHubHostedReview: true + it('renderReview for found lookup', () => { + expect(getChecksPanelReviewState(input({ reviewLookup: 'found' })).renderReview).toBe(true) + }) + + it('positive_unresolved blocks create and offers Open Review', () => { + const state = getChecksPanelReviewState( + input({ reviewLookup: 'positive_unresolved', openReviewUrl: 'https://x/pull/1' }) + ) + expect(state.title).toBe('Pull request details unavailable') + expect(state.composerMode).toBe('hidden') + expect(state.workflowAction).toBeNull() + expect(state.recovery).toContain('open_review') + expect(state.openReviewUrl).toBe('https://x/pull/1') + }) + + it('offers Open Review under a branch blocker when positive evidence has a trusted URL', () => { + // A concurrent branch blocker owns the copy but must not swallow the known + // review: Open Review is exposed while Create / Push & Create stay suppressed. + const state = getChecksPanelReviewState( + input({ + reviewLookup: 'positive_unresolved', + openReviewUrl: 'https://x/pull/7', + eligibilityBlockedReason: 'no_upstream', + hasUpstream: false }) - ).toEqual({ - title: 'Pull request status unavailable', - description: 'Refresh GitHub status for this branch to load checks and review.' - }) + ) + expect(state.title).toBe('No upstream configured') + expect(state.workflowAction).toBe('publish_branch') + expect(state.composerMode).toBe('hidden') + expect(state.recovery).toContain('open_review') + expect(state.openReviewUrl).toBe('https://x/pull/7') }) - it('does not show no-PR copy for paused ambiguous GitHub hosted-review data', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'paused', - hostedReviewBlockedReason: null, - hasUpstream: true, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') + it('needs_sync emits a sync_branch workflow action for the renderer', () => { + const state = getChecksPanelReviewState(input({ eligibilityBlockedReason: 'needs_sync' })) + expect(state.title).toBe('Branch needs to sync') + expect(state.workflowAction).toBe('sync_branch') }) - it('keeps refresh errors for ambiguous GitHub hosted-review data', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: null, - hasUpstream: true, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Could not refresh pull request') - }) - - it('uses neutral copy for ambiguous GitHub hosted-review data before unpublished branch copy', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: undefined, - hostedReviewBlockedReason: 'no_upstream', + it('branch blocker (no_upstream) ranks above a refresh error', () => { + const state = getChecksPanelReviewState( + input({ + eligibilityBlockedReason: 'no_upstream', hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') - }) - - it('uses neutral copy for ambiguous GitHub hosted-review data before remote fallback publish copy', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: undefined, - hostedReviewBlockedReason: undefined, - hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') - }) - - it('uses neutral copy for ambiguous GitHub hosted-review data before unpushed commit copy', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: undefined, - hostedReviewBlockedReason: 'needs_push', - hasUpstream: true, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') - }) - - // Regression: a local-only branch with an ambiguous GitHub hosted-review card - // flip-flopped between "Branch not published" (during an active PR refresh) and - // "Pull request status unavailable" (while idle) as background refreshes cycled - // the status. The ambiguous copy must stay stable across the whole lifecycle. - it('keeps stable ambiguous copy while a PR refresh is queued on an unpublished branch', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'queued', - hostedReviewBlockedReason: 'no_upstream', - hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') - }) - - it('keeps stable ambiguous copy while a PR refresh is in-flight on an unpublished branch', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'in-flight', - hostedReviewBlockedReason: undefined, - hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') - }) - - it('does not fall back to unpublished-branch copy on refresh error for ambiguous data', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: 'no_upstream', - hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Could not refresh pull request') - }) - - it('attributes a 5xx outage to GitHub instead of the generic refresh error', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - prRefreshErrorType: 'server_error', - hostedReviewBlockedReason: null, - hasUpstream: true + refresh: { status: 'error', errorType: 'auth' } }) - ).toEqual({ + ) + expect(state.title).toBe('No upstream configured') + expect(state.workflowAction).toBe('publish_branch') + // Hard error concurrent with the blocker: detail appended, create suppressed. + expect(state.detail).toBe( + 'Orca also could not confirm whether this branch already has a pull request.' + ) + }) + + it('hard refresh error hides the composer', () => { + const state = getChecksPanelReviewState( + input({ refresh: { status: 'error', errorType: 'auth' } }) + ) + expect(state.title).toBe('GitHub authentication failed') + expect(state.composerMode).toBe('hidden') + expect(state.workflowAction).toBeNull() + }) + + it('hard refresh error overrides an accepted no-review', () => { + const state = getChecksPanelReviewState( + input({ reviewLookup: 'not_found', refresh: { status: 'error', errorType: 'permission' } }) + ) + expect(state.title).toBe('GitHub access denied') + }) +}) + +describe('getChecksPanelReviewState — composer preserve', () => { + it('accepted no-review with confirmed readiness opens the composer', () => { + const state = getChecksPanelReviewState( + input({ reviewLookup: 'not_found', confirmedReadiness: true }) + ) + expect(state.composerMode).toBe('confirmed_open') + expect(state.workflowAction).toBe('create') + }) + + it('transient error preserves a confirmed composer', () => { + const state = getChecksPanelReviewState( + input({ confirmedReadiness: true, refresh: { status: 'error', errorType: 'network' } }) + ) + expect(state.composerMode).toBe('confirmed_open') + expect(state.title).toBe('Could not reach GitHub') + }) + + it('attributes a transient 5xx outage to GitHub and preserves a confirmed composer', () => { + const state = getChecksPanelReviewState( + input({ confirmedReadiness: true, refresh: { status: 'error', errorType: 'server_error' } }) + ) + expect(state).toMatchObject({ title: 'GitHub is unavailable', description: - "GitHub's API is temporarily unavailable. This panel reloads automatically once it recovers." + "GitHub's API is temporarily unavailable. This panel reloads automatically once it recovers.", + composerMode: 'confirmed_open', + workflowAction: 'create' }) }) - it('attributes a network failure to GitHub in the ambiguous hosted-review branch', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - prRefreshErrorType: 'network', - hostedReviewBlockedReason: null, - hasUpstream: true, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe("Can't reach GitHub") - }) - - it('keeps the generic refresh error for non-outage error types', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - prRefreshErrorType: 'auth', - hostedReviewBlockedReason: null, - hasUpstream: true - }).title - ).toBe('Could not refresh pull request') - }) - - it('renders a single stable title across a full ambiguous refresh cycle', () => { - const cycle = ['queued', 'in-flight', undefined, 'skipped', 'paused'] as const - const titles = new Set( - cycle.map( - (prRefreshStatus) => - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus, - hostedReviewBlockedReason: 'no_upstream', - hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ) + it('transient error without confirmed readiness never opens a composer', () => { + const state = getChecksPanelReviewState( + input({ confirmedReadiness: false, refresh: { status: 'error', errorType: 'network' } }) ) - expect([...titles]).toEqual(['Pull request status unavailable']) + expect(state.composerMode).toBe('hidden') + }) + + it('needs_push with positive_unresolved never exposes Push & Create', () => { + const state = getChecksPanelReviewState( + input({ + eligibilityBlockedReason: 'needs_push', + reviewLookup: 'positive_unresolved', + confirmedReadiness: true, + confirmedNeedsPush: true + }) + ) + expect(state.composerMode).toBe('hidden') + expect(state.workflowAction).toBeNull() + }) + + it('needs_push with confirmed readiness offers Push & Create', () => { + const state = getChecksPanelReviewState( + input({ + eligibilityBlockedReason: 'needs_push', + reviewLookup: 'not_found', + confirmedReadiness: true, + confirmedNeedsPush: true + }) + ) + expect(state.composerMode).toBe('needs_push_open') + expect(state.workflowAction).toBe('push_and_create') + }) +}) + +describe('getChecksPanelReviewState — schedule', () => { + it('carries auto-retry schedule on the paused row', () => { + const state = getChecksPanelReviewState( + input({ refresh: { status: 'paused', nextAutoRetryAt: 123, retryDisabledUntil: 123 } }) + ) + expect(state.autoRetryAt).toBe(123) + expect(state.retryDisabledUntil).toBe(123) + }) + + it('carries auto-retry schedule on a transient network error', () => { + const state = getChecksPanelReviewState( + input({ refresh: { status: 'error', errorType: 'network', nextAutoRetryAt: 456 } }) + ) + expect(state.autoRetryAt).toBe(456) + expect(state.retryDisabledUntil).toBeUndefined() + }) +}) + +describe('getChecksPanelReviewState — git status', () => { + it('shows loading copy when upstream is unknown', () => { + expect( + getChecksPanelReviewState(input({ gitStatusPhase: 'loading', hasUpstream: undefined })).title + ).toBe('Checking branch status') + }) + + it('shows failed copy when the probe failed and upstream is unknown', () => { + const state = getChecksPanelReviewState( + input({ gitStatusPhase: 'error', hasUpstream: undefined }) + ) + expect(state.title).toBe('Could not check branch status') + expect(state.recovery).toContain('retry') + expect(state.composerMode).toBe('hidden') + }) +}) + +describe('getChecksPanelReviewState — active and skipped', () => { + it('queued shows checking copy', () => { + expect(getChecksPanelReviewState(input({ refresh: { status: 'queued' } })).title).toBe( + 'Checking pull request status' + ) + }) + + it('disconnected host row', () => { + expect( + getChecksPanelReviewState( + input({ refresh: { status: 'skipped', skippedReason: 'disconnected' } }) + ).title + ).toBe('Host disconnected') }) }) @@ -289,21 +271,6 @@ describe('shouldShowChecksPanelPublishBranchAction', () => { ).toBe(true) }) - it('uses remote status even when provider eligibility has a separate blocker', () => { - expect( - shouldShowChecksPanelPublishBranchAction({ - hostedReviewBlockedReason: undefined, - hasUpstream: false - }) - ).toBe(true) - expect( - shouldShowChecksPanelPublishBranchAction({ - hostedReviewBlockedReason: 'unsupported_provider', - hasUpstream: false - }) - ).toBe(true) - }) - it('does not show publish when HEAD is detached', () => { expect( shouldShowChecksPanelPublishBranchAction({ diff --git a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts index 78dd6610ae4..6679a57fa3f 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts @@ -1,40 +1,49 @@ import type { HostedReviewCreationBlockedReason } from '../../../../shared/hosted-review' -import type { PRRefreshUpstreamErrorType } from '../../../../shared/types' import { translate } from '@/i18n/i18n' -import { getGitHubUnavailableEmptyStateCopy } from './github-refresh-error-copy' +import { + autoRetrySchedule, + capitalizeReviewLabel, + confirmedComposerMode, + ELIGIBILITY_SAFETY_BLOCKERS, + isBranchBlocker, + isHardRefreshError, + isTransientRefreshFailure, + workflowActionForComposer, + type ChecksPanelReviewState, + type ChecksPanelReviewStateInput +} from './checks-panel-review-state-model' +import { + concurrentLookupDetail, + hardRefreshErrorState, + skippedRefreshState, + transientRefreshState +} from './checks-panel-review-copy' +import { branchBlockerState, safetyBlockerState } from './checks-panel-blocker-copy' -type PRRefreshStatus = 'queued' | 'in-flight' | 'paused' | 'error' | 'skipped' | undefined - -type ChecksPanelEmptyStateInput = { - operationLabel: string | null - prRefreshStatus: PRRefreshStatus - // Why: lets the error empty-state attribute a GitHub outage (5xx/network/rate - // limit) instead of showing generic "could not refresh" copy that reads like - // an Orca bug. - prRefreshErrorType?: PRRefreshUpstreamErrorType - hostedReviewBlockedReason: HostedReviewCreationBlockedReason | undefined - hasUpstream: boolean | undefined - hasCurrentBranch?: boolean - reviewLabel?: 'pull request' | 'merge request' - reviewShortLabel?: 'PR' | 'MR' - hasAmbiguousGitHubHostedReview?: boolean -} - -type ChecksPanelEmptyStateCopy = { - title: string - description: string -} +export type { + ChecksPanelComposerMode, + ChecksPanelWorkflowAction, + ChecksPanelRecoveryAction, + ChecksPanelReviewState, + ChecksPanelRefreshInput, + ChecksPanelReviewStateInput +} from './checks-panel-review-state-model' /** - * Chooses neutral empty-state copy when GitHub review status is still ambiguous. + * One exhaustive selector for the Checks-panel empty state. Copy, composer mode, + * workflow action, and recovery actions are decided together — never in separate + * branches — so an error, pause, skip, or unknown state can never be rendered as + * "No {reviewLabel} found." See docs/design/pr-panel-refresh-guidance.md. */ -export function getChecksPanelEmptyStateCopy( - input: ChecksPanelEmptyStateInput -): ChecksPanelEmptyStateCopy { - const reviewLabel = input.reviewLabel ?? 'pull request' - const reviewShortLabel = input.reviewShortLabel ?? 'PR' +export function getChecksPanelReviewState( + input: ChecksPanelReviewStateInput +): ChecksPanelReviewState { + const { reviewLabel, reviewShortLabel, providerName } = input + + // 0. Operation in progress — never overridden by refresh copy. if (input.operationLabel) { return { + renderReview: false, title: translate( 'auto.components.right.sidebar.checks.panel.empty.state.d77c513c1e', '{{value0}} in progress', @@ -44,142 +53,186 @@ export function getChecksPanelEmptyStateCopy( 'auto.components.right.sidebar.checks.panel.empty.state.05e4aec17b', '{{value0}} checks will be available after the operation completes', { value0: reviewShortLabel } - ) + ), + composerMode: 'hidden', + workflowAction: null, + recovery: [] } } - const blockedReason = input.hostedReviewBlockedReason - // Why: a GitHub hosted-review card with no cached PR is ambiguous. Resolve the - // whole empty state here so the copy stays stable across a background PR - // refresh's lifecycle (idle/queued/in-flight/paused/skipped). Previously only - // the idle statuses were handled and active ones fell through to the - // publish-branch branch, so the panel flip-flopped between "Branch not - // published" and "Pull request status unavailable" as refreshes cycled. Only a - // hard refresh error still surfaces a distinct — but equally stable — message. - if (input.hasAmbiguousGitHubHostedReview === true) { - if (input.prRefreshStatus === 'error') { - return ( - getGitHubUnavailableEmptyStateCopy(input.prRefreshErrorType) ?? { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.5f478ab3d3', - 'Could not refresh pull request' - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.2bdd7aaf2d', - 'GitHub status could not be refreshed. Existing cached data was preserved.' - ) - } - ) - } + // 1. Renderable review wins over every empty state. + if (input.reviewLookup === 'found') { return { + renderReview: true, + title: '', + description: '', + composerMode: 'hidden', + workflowAction: null, + recovery: [] + } + } + + const blockedReason = input.eligibilityBlockedReason + + // 2. Eligibility safety states own their guidance (existing_review is a hard + // create block in the positive-evidence family). + if (blockedReason && ELIGIBILITY_SAFETY_BLOCKERS.has(blockedReason)) { + return safetyBlockerState(input, blockedReason) + } + + // 3. Positive unresolved review evidence: never offer Create / Push & Create. + if (input.reviewLookup === 'positive_unresolved' && !isBranchBlocker(blockedReason)) { + return { + renderReview: false, title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.3322603418', - 'Pull request status unavailable' + 'auto.components.right.sidebar.checks.panel.review.positive.title', + '{{reviewLabelCap}} details unavailable', + { reviewLabelCap: capitalizeReviewLabel(reviewLabel) } ), description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.b597440265', - 'Refresh GitHub status for this branch to load checks and review.' - ) + 'auto.components.right.sidebar.checks.panel.review.positive.body', + 'Orca has saved {{reviewLabel}} information for this branch but could not confirm its current status.', + { reviewLabel } + ), + composerMode: 'hidden', + workflowAction: null, + recovery: input.openReviewUrl ? ['open_review', 'retry'] : ['retry'], + openReviewUrl: input.openReviewUrl } } - if ( - shouldShowChecksPanelPublishBranchAction({ - hostedReviewBlockedReason: blockedReason, - hasUpstream: input.hasUpstream, - hasCurrentBranch: input.hasCurrentBranch - }) - ) { - // Why: a local-only branch cannot have GitHub PR status yet; surfacing a - // refresh error here makes a normal pre-publish state look broken. + // 4. Actionable creation blockers (publish / sync / auth / needs_push). + if (isBranchBlocker(blockedReason)) { + return branchBlockerState( + input, + blockedReason as NonNullable + ) + } + // A ready Git status reporting no upstream is a publish state even before + // eligibility resolves. `hasUpstream === undefined` is unknown, never false. + if (input.gitStatusPhase === 'ready' && input.hasUpstream === false && input.hasCurrentBranch) { + return branchBlockerState(input, 'no_upstream') + } + + // 5. Hard refresh error with no blocker above → hide composer until cleared. + if (isHardRefreshError(input.refresh)) { + return hardRefreshErrorState(input) + } + + // 6. Accepted no-review for the exact context. + if (input.reviewLookup === 'not_found') { + const detail = isTransientRefreshFailure(input.refresh) + ? concurrentLookupDetail(input) + : undefined + const mode = confirmedComposerMode(input) return { + renderReview: false, title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.41252bc53f', - 'Branch not published' + 'auto.components.right.sidebar.checks.panel.review.no_review.title', + 'No {{reviewLabel}} found', + { reviewLabel } ), description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.f8543140cc', - 'Publish this branch before creating a {{value0}}.', - { value0: reviewLabel } - ) + 'auto.components.right.sidebar.checks.panel.review.no_review.body', + 'Create a {{reviewLabel}} to start checks and review.', + { reviewLabel } + ), + detail, + composerMode: mode, + workflowAction: workflowActionForComposer(mode), + recovery: detail ? ['retry'] : ['refresh'], + ...(detail ? autoRetrySchedule(input) : {}) } } - if (blockedReason === 'needs_push') { + // 7. Transient classified error / rate-limit pause with no accepted result. + if (isTransientRefreshFailure(input.refresh)) { + const mode = confirmedComposerMode(input) + return transientRefreshState(input, mode, workflowActionForComposer(mode)) + } + + // 8. Active refresh (queued / in-flight) with no accepted result. + if (input.refresh?.status === 'queued' || input.refresh?.status === 'in-flight') { + const mode = confirmedComposerMode(input) return { + renderReview: false, title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.76e15946a9', - 'Branch has unpushed commits' + 'auto.components.right.sidebar.checks.panel.review.active.title', + 'Checking {{reviewLabel}} status', + { reviewLabel } ), description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.6ce9d4e069', - 'Push your branch before creating a {{value0}}.', - { value0: reviewLabel } - ) + 'auto.components.right.sidebar.checks.panel.review.active.body', + 'Orca is checking {{provider}} for a {{reviewLabel}} on this branch.', + { reviewLabel, provider: providerName } + ), + composerMode: mode, + workflowAction: workflowActionForComposer(mode), + recovery: [] } } - switch (input.prRefreshStatus) { - case 'error': - return ( - getGitHubUnavailableEmptyStateCopy(input.prRefreshErrorType) ?? { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.5f478ab3d3', - 'Could not refresh pull request' - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.2bdd7aaf2d', - 'GitHub status could not be refreshed. Existing cached data was preserved.' - ) - } - ) - case 'queued': - return { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.938b5606a6', - 'Checking for pull request' - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.6ba2440770', - 'Waiting to refresh GitHub status for this branch' - ) - } - case 'in-flight': - return { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.938b5606a6', - 'Checking for pull request' - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.3d4af82ff4', - 'Refreshing GitHub status for this branch' - ) - } - case 'paused': - return { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.7c299df37b', - 'No pull request found' - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.d372072df1', - 'GitHub refresh is paused by the current rate-limit budget' - ) - } - case 'skipped': - case undefined: - return { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.13e1c7d5ed', - 'No {{value0}} found', - { value0: reviewLabel } - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.5b0cfae9a5', - 'Create a {{value0}} to start checks and review.', - { value0: reviewLabel } - ) - } + // 9. Git status loading / failure when upstream is still unknown. + if (input.gitStatusPhase === 'loading' && input.hasUpstream === undefined) { + return { + renderReview: false, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.git_loading.title', + 'Checking branch status' + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.git_loading.body', + 'Orca is checking this branch before showing create or publish actions.' + ), + composerMode: 'hidden', + workflowAction: null, + recovery: [] + } + } + if (input.gitStatusPhase === 'error' && input.hasUpstream === undefined) { + return { + renderReview: false, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.git_error.title', + 'Could not check branch status' + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.git_error.body', + "Orca could not confirm this branch's upstream from this environment. Retry before publishing or creating a {{reviewLabel}}.", + { reviewLabel } + ), + composerMode: 'hidden', + workflowAction: null, + recovery: ['retry'] + } + } + + // 10. Skipped structural reasons and missing / unknown — status unavailable. + if (input.refresh?.status === 'skipped' && input.refresh.skippedReason) { + if (input.refresh.skippedReason === 'rate-limit') { + const mode = confirmedComposerMode(input) + return transientRefreshState(input, mode, workflowActionForComposer(mode)) + } + const skipped = skippedRefreshState(input, input.refresh.skippedReason) + if (skipped) { + return skipped + } + } + return { + renderReview: false, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.unknown.title', + '{{reviewLabelCap}} status unavailable', + { reviewLabelCap: capitalizeReviewLabel(reviewLabel) } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.unknown.body', + 'Orca has not confirmed the {{reviewLabel}} status for this branch. Retry to check again.', + { reviewLabel } + ), + composerMode: 'hidden', + workflowAction: null, + recovery: ['retry'] } } diff --git a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts index 897b93b3e22..78063f3b211 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts @@ -13,6 +13,10 @@ export type ChecksPanelGitStatusContextInput = { runtimeEnvironmentId: string | null repoConnectionId: string | null pushTarget: GitPushTarget | null | undefined + // Local execution host variant (`wsl:{distro}` vs `host`) so a Windows-host + // result never authorizes a WSL context (or another distro), and vice versa. + // Null for SSH/runtime contexts, which are already scoped by their host ids. + localExecutionScope?: string | null } export type ChecksPanelGitStatusSnapshot = { @@ -60,6 +64,7 @@ export function buildChecksPanelGitStatusContextKey( linkedGiteaPR: input.linkedGiteaPR ?? null, runtimeEnvironmentId: input.runtimeEnvironmentId ?? '', repoConnectionId: input.repoConnectionId ?? '', + localExecutionScope: input.localExecutionScope ?? null, pushTarget: input.pushTarget ? { remoteName: input.pushTarget.remoteName, diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts new file mode 100644 index 00000000000..b4486bec769 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts @@ -0,0 +1,288 @@ +import type { GitHubPRRefreshSkippedReason } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { getGitHubUnavailableEmptyStateCopy } from './github-refresh-error-copy' +import { + autoRetrySchedule, + capitalizeReviewLabel, + isHardRefreshError, + isRateLimitRefresh, + type ChecksPanelRecoveryAction, + type ChecksPanelReviewState, + type ChecksPanelReviewStateInput +} from './checks-panel-review-state-model' + +type KeyedCopy = { key: string; fallback: string } + +/** + * Muted sentence appended beneath a blocker or accepted-no-review body when a + * concurrent lookup failure or positive-unresolved evidence is also current. + */ +export function concurrentLookupDetail(input: ChecksPanelReviewStateInput): string | undefined { + const { reviewLabel, providerName, refresh } = input + if (input.reviewLookup === 'positive_unresolved') { + return translate( + 'auto.components.right.sidebar.checks.panel.review.detail.positive', + 'Orca also has saved {{reviewLabel}} information that it could not verify.', + { reviewLabel } + ) + } + if (isRateLimitRefresh(refresh)) { + return translate( + 'auto.components.right.sidebar.checks.panel.review.detail.rate_limited', + 'Orca also could not check {{reviewLabel}} status because {{provider}} is temporarily limiting requests.', + { reviewLabel, provider: providerName } + ) + } + if (refresh?.errorType === 'network') { + return translate( + 'auto.components.right.sidebar.checks.panel.review.detail.network', + 'Orca also could not check {{reviewLabel}} status because this environment could not reach {{provider}}.', + { reviewLabel, provider: providerName } + ) + } + if (refresh?.status === 'error' || isHardRefreshError(refresh)) { + return translate( + 'auto.components.right.sidebar.checks.panel.review.detail.untyped', + 'Orca also could not confirm whether this branch already has a {{reviewLabel}}.', + { reviewLabel } + ) + } + return undefined +} + +export function transientRefreshState( + input: ChecksPanelReviewStateInput, + composerMode: ChecksPanelReviewState['composerMode'], + workflowAction: ChecksPanelReviewState['workflowAction'] +): ChecksPanelReviewState { + const { reviewLabel, providerName, refresh } = input + const schedule = autoRetrySchedule(input) + const base = { + renderReview: false as const, + composerMode, + workflowAction, + recovery: ['retry'] as ChecksPanelRecoveryAction[], + ...schedule + } + // Why: preserve the upstream GitHub-attributed 5xx copy in the unified state model. + const githubUnavailableCopy = + refresh?.errorType === 'server_error' + ? getGitHubUnavailableEmptyStateCopy(refresh.errorType) + : null + if (githubUnavailableCopy) { + return { ...base, ...githubUnavailableCopy } + } + if (isRateLimitRefresh(refresh)) { + return { + ...base, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.paused.title', + '{{provider}} refresh paused', + { provider: providerName } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.paused.body', + '{{provider}} is temporarily limiting requests. This can happen even when the displayed API quota is not exhausted.', + { provider: providerName } + ) + } + } + if (refresh?.errorType === 'network') { + return { + ...base, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.network.title', + 'Could not reach {{provider}}', + { provider: providerName } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.network.body', + 'This environment could not reach {{provider}}. Check its connection, then retry.', + { provider: providerName } + ) + } + } + if (refresh?.errorType === 'unknown') { + return { + ...base, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.unknown_error.title', + 'Could not check {{reviewLabel}} status', + { reviewLabel } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.unknown_error.body', + 'The lookup failed, so Orca could not confirm whether this branch already has a {{reviewLabel}}.', + { reviewLabel } + ) + } + } + return { + ...base, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.untyped.title', + '{{reviewLabelCap}} status unavailable', + { reviewLabelCap: capitalizeReviewLabel(reviewLabel) } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.untyped.body', + 'Orca could not confirm whether this branch already has a {{reviewLabel}}. Retry to check again.', + { reviewLabel } + ) + } +} + +const HARD_ERROR_COPY: Record< + 'auth' | 'permission' | 'repo_unavailable' | 'gh_unavailable', + { + title: KeyedCopy + body: KeyedCopy + } +> = { + auth: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.auth.title', + fallback: '{{provider}} authentication failed' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.auth.body', + fallback: + '{{provider}} could not authenticate the credentials available in this environment. Check the {{provider}} login or environment token, then retry.' + } + }, + permission: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.permission.title', + fallback: '{{provider}} access denied' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.permission.body', + fallback: + "The current {{provider}} credentials cannot read this repository's {{reviewLabel}}s. Check the account, token scopes, and repository access, then retry." + } + }, + repo_unavailable: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.repo.title', + fallback: '{{provider}} repository unavailable' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.repo.body', + fallback: + '{{provider}} could not resolve or access the repository for the current remote and account. Check the remote and repository access, then retry.' + } + }, + gh_unavailable: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.cli.title', + fallback: '{{provider}} CLI unavailable' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.cli.body', + fallback: + 'Orca could not run {{provider}} CLI in this environment. Set it up here, then retry.' + } + } +} + +export function hardRefreshErrorState(input: ChecksPanelReviewStateInput): ChecksPanelReviewState { + const { reviewLabel, providerName, refresh } = input + const copy = + HARD_ERROR_COPY[(refresh?.errorType as keyof typeof HARD_ERROR_COPY) ?? 'gh_unavailable'] ?? + HARD_ERROR_COPY.gh_unavailable + const vars = { provider: providerName, reviewLabel } + return { + renderReview: false, + title: translate(copy.title.key, copy.title.fallback, vars), + description: translate(copy.body.key, copy.body.fallback, vars), + composerMode: 'hidden', + workflowAction: null, + recovery: ['retry'] + } +} + +const SKIPPED_COPY: Partial< + Record< + GitHubPRRefreshSkippedReason, + { title: KeyedCopy; body: KeyedCopy; recovery: ChecksPanelRecoveryAction[] } + > +> = { + disconnected: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.disconnected.title', + fallback: 'Host disconnected' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.disconnected.body', + fallback: + "This repository's execution host is disconnected, so Orca cannot refresh {{reviewLabel}} status." + }, + recovery: ['retry'] + }, + bare: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.bare.title', + fallback: 'Bare repository' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.bare.body', + fallback: 'This repository is bare, so {{reviewLabel}} status is not available here.' + }, + recovery: [] + }, + archived: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.archived.title', + fallback: 'Repository archived' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.archived.body', + fallback: 'This repository is archived, so Orca is not refreshing {{reviewLabel}} status.' + }, + recovery: [] + }, + 'not-git': { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.not_git.title', + fallback: 'Not a Git repository' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.not_git.body', + fallback: 'Orca could not treat this folder as a Git repository for {{reviewLabel}} status.' + }, + recovery: [] + }, + remote: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.remote.title', + fallback: 'Remote-only context' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.remote.body', + fallback: + 'Orca could not refresh {{reviewLabel}} status for this remote context. Retry after the host is available.' + }, + recovery: ['retry'] + } +} + +/** Returns the skipped-reason row, or `null` to defer to the missing/unknown row. */ +export function skippedRefreshState( + input: ChecksPanelReviewStateInput, + skippedReason: GitHubPRRefreshSkippedReason +): ChecksPanelReviewState | null { + const copy = SKIPPED_COPY[skippedReason] + if (!copy) { + // `fresh` (and any unknown skip) with no accepted result → missing/unknown. + return null + } + const vars = { reviewLabel: input.reviewLabel } + return { + renderReview: false, + title: translate(copy.title.key, copy.title.fallback, vars), + description: translate(copy.body.key, copy.body.fallback, vars), + composerMode: 'hidden', + workflowAction: null, + recovery: copy.recovery + } +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts index 69d1dc32690..4a53eab87b0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts @@ -1,9 +1,33 @@ import { describe, expect, it } from 'vitest' +import type { + HostedReviewCreationBlockedReason, + HostedReviewCreationEligibility, + HostedReviewLookupOutcome +} from '../../../../shared/hosted-review' import { + computeChecksPanelConfirmedReadiness, + isChecksPanelCreateEligibilityConfirmable, + isChecksPanelHardErrorCleared, + isChecksPanelHardRefreshErrorType, resolveChecksPanelHostedReviewBaseRef, - shouldOpenChecksPanelCreateComposer + shouldOpenChecksPanelCreateComposer, + type ChecksPanelConfirmedReadinessInput } from './checks-panel-review-creation' +function eligibility( + overrides: Partial = {} +): HostedReviewCreationEligibility { + return { + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'not_found', + ...overrides + } +} + describe('resolveChecksPanelHostedReviewBaseRef', () => { it('prefers the worktree base ref over the repo default', () => { expect( @@ -14,66 +38,70 @@ describe('resolveChecksPanelHostedReviewBaseRef', () => { ).toBe('release/1.4') }) - it('falls back to the repo base ref when the worktree has no override', () => { - expect( - resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: null, - repoBaseRef: ' main ' - }) - ).toBe('main') - }) - it('returns null when both inputs are null', () => { expect( - resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: null, - repoBaseRef: null - }) + resolveChecksPanelHostedReviewBaseRef({ worktreeBaseRef: null, repoBaseRef: null }) ).toBe(null) }) +}) - it('returns null when worktree base ref is whitespace-only', () => { +describe('isChecksPanelCreateEligibilityConfirmable (shared desktop/mobile floor)', () => { + const base = { reviewLookup: 'not_found' as const, hasHardRefreshError: false } + + it('allows canCreate and needs_push when nothing hard-blocks', () => { + expect(isChecksPanelCreateEligibilityConfirmable({ ...base, eligibility: eligibility() })).toBe( + true + ) expect( - resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: ' ', - repoBaseRef: null + isChecksPanelCreateEligibilityConfirmable({ + ...base, + eligibility: eligibility({ canCreate: false, blockedReason: 'needs_push' }) }) - ).toBe(null) + ).toBe(true) }) - it('strips origin prefix from the worktree base ref', () => { + it('fails closed on every hard-block: null, existing_review, positive, hard error, unavailable', () => { + expect(isChecksPanelCreateEligibilityConfirmable({ ...base, eligibility: null })).toBe(false) expect( - resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: 'origin/main', - repoBaseRef: 'develop' + isChecksPanelCreateEligibilityConfirmable({ + ...base, + eligibility: eligibility({ canCreate: false, blockedReason: 'existing_review' }) }) - ).toBe('main') - }) - - it('strips upstream prefix from the repo base ref', () => { + ).toBe(false) expect( - resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: null, - repoBaseRef: 'upstream/develop' + isChecksPanelCreateEligibilityConfirmable({ + ...base, + eligibility: eligibility(), + reviewLookup: 'positive_unresolved' }) - ).toBe('develop') + ).toBe(false) + expect( + isChecksPanelCreateEligibilityConfirmable({ + ...base, + eligibility: eligibility(), + hasHardRefreshError: true + }) + ).toBe(false) + expect( + isChecksPanelCreateEligibilityConfirmable({ + ...base, + eligibility: eligibility({ + blockedReason: 'needs_push', + reviewLookupOutcome: 'unavailable' + }) + }) + ).toBe(false) }) }) describe('shouldOpenChecksPanelCreateComposer', () => { - it('opens for GitLab MR creation eligibility', () => { + it('opens for creation eligibility', () => { expect( shouldOpenChecksPanelCreateComposer({ activeReview: null, isFolder: false, - branch: 'feature/gitlab-mr', - hostedReviewCreation: { - provider: 'gitlab', - review: null, - canCreate: true, - blockedReason: null, - nextAction: null - } + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: true }) }) ).toBe(true) }) @@ -83,66 +111,281 @@ describe('shouldOpenChecksPanelCreateComposer', () => { shouldOpenChecksPanelCreateComposer({ activeReview: null, isFolder: false, - branch: 'feature/gitlab-mr', - hostedReviewCreation: { - provider: 'gitlab', - review: null, - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push' - } + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: false, blockedReason: 'needs_push' }) }) ).toBe(true) }) - it('does not open when an active review exists', () => { + it('does not open on positive unresolved review evidence', () => { expect( shouldOpenChecksPanelCreateComposer({ - activeReview: { provider: 'github', number: 123 }, + activeReview: null, isFolder: false, - branch: 'feature/test', - hostedReviewCreation: { - provider: 'github', - review: null, - canCreate: true, - blockedReason: null, - nextAction: null - } + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: false, blockedReason: 'needs_push' }), + reviewLookup: 'positive_unresolved' }) ).toBe(false) }) - it('does not open for folder repos', () => { + it('does not open during a hard refresh error', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: true }), + hasHardRefreshError: true + }) + ).toBe(false) + }) + + it('does not open when a review already exists', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: false, blockedReason: 'existing_review' }) + }) + ).toBe(false) + }) + + it('does not open for folders or empty branches', () => { expect( shouldOpenChecksPanelCreateComposer({ activeReview: null, isFolder: true, - branch: 'feature/test', - hostedReviewCreation: { - provider: 'github', - review: null, - canCreate: true, - blockedReason: null, - nextAction: null - } + branch: 'feature/x', + hostedReviewCreation: eligibility() }) ).toBe(false) - }) - - it('does not open when branch is empty', () => { expect( shouldOpenChecksPanelCreateComposer({ activeReview: null, isFolder: false, branch: '', - hostedReviewCreation: { - provider: 'github', - review: null, - canCreate: true, - blockedReason: null, - nextAction: null - } + hostedReviewCreation: eligibility() }) ).toBe(false) }) }) + +describe('isChecksPanelHardRefreshErrorType', () => { + it('flags the four hard error types', () => { + for (const t of ['auth', 'permission', 'repo_unavailable', 'gh_unavailable']) { + expect(isChecksPanelHardRefreshErrorType(t)).toBe(true) + } + }) + it('does not flag transient types or undefined', () => { + for (const t of ['rate_limited', 'network', 'unknown']) { + expect(isChecksPanelHardRefreshErrorType(t)).toBe(false) + } + expect(isChecksPanelHardRefreshErrorType(undefined)).toBe(false) + }) +}) + +describe('computeChecksPanelConfirmedReadiness', () => { + const NOW = 1_000_000 + function readiness( + overrides: Partial = {} + ): ChecksPanelConfirmedReadinessInput { + return { + contextKeyMatches: true, + eligibility: { canCreate: true, blockedReason: null, reviewLookupOutcome: 'not_found' }, + eligibilityCompletedAt: NOW - 1_000, + eligibilityRequestStartedAt: NOW - 2_000, + reviewLookup: 'not_found', + hardErrorObservedAt: undefined, + gitSnapshotMatches: true, + now: NOW, + ...overrides + } + } + + it('confirms fresh, matching eligibility', () => { + expect(computeChecksPanelConfirmedReadiness(readiness())).toEqual({ + confirmed: true, + needsPush: false + }) + }) + + it('confirms needs_push as the Push & Create path', () => { + expect( + computeChecksPanelConfirmedReadiness( + readiness({ + eligibility: { + canCreate: false, + blockedReason: 'needs_push', + reviewLookupOutcome: 'not_found' + } + }) + ) + ).toEqual({ confirmed: true, needsPush: true }) + }) + + it('drops confirmed when the context key does not match', () => { + expect( + computeChecksPanelConfirmedReadiness(readiness({ contextKeyMatches: false })).confirmed + ).toBe(false) + }) + + it('drops confirmed on positive unresolved and existing_review', () => { + expect( + computeChecksPanelConfirmedReadiness(readiness({ reviewLookup: 'positive_unresolved' })) + .confirmed + ).toBe(false) + expect( + computeChecksPanelConfirmedReadiness( + readiness({ + eligibility: { + canCreate: false, + blockedReason: 'existing_review', + reviewLookupOutcome: 'found' + } + }) + ).confirmed + ).toBe(false) + }) + + it('drops confirmed when eligibility is older than 5 minutes', () => { + expect( + computeChecksPanelConfirmedReadiness( + readiness({ eligibilityCompletedAt: NOW - 5 * 60_000 - 1 }) + ).confirmed + ).toBe(false) + }) + + it('drops confirmed when the git snapshot no longer matches', () => { + expect( + computeChecksPanelConfirmedReadiness(readiness({ gitSnapshotMatches: false })).confirmed + ).toBe(false) + }) + + it('preserves confirmed across a transient refresh failure once past the error', () => { + // The panel keeps the last confirmed eligibility snapshot; a transient + // failure (no hard error observed) must not drop confirmed. + expect( + computeChecksPanelConfirmedReadiness( + readiness({ hardErrorObservedAt: undefined, reviewLookup: 'not_found' }) + ) + ).toEqual({ confirmed: true, needsPush: false }) + }) + + describe('hard error clearing', () => { + it('clears when the request started strictly after the error with an accepted outcome', () => { + expect( + computeChecksPanelConfirmedReadiness( + readiness({ + hardErrorObservedAt: NOW - 3_000, + eligibilityRequestStartedAt: NOW - 2_000, + eligibility: { canCreate: true, blockedReason: null, reviewLookupOutcome: 'not_found' } + }) + ).confirmed + ).toBe(true) + }) + + it('does not clear for an in-flight request that started before the error', () => { + expect( + computeChecksPanelConfirmedReadiness( + readiness({ hardErrorObservedAt: NOW - 1_500, eligibilityRequestStartedAt: NOW - 2_000 }) + ).confirmed + ).toBe(false) + }) + + it('does not clear on a late unavailable fallback', () => { + expect( + computeChecksPanelConfirmedReadiness( + readiness({ + hardErrorObservedAt: NOW - 3_000, + eligibilityRequestStartedAt: NOW - 2_000, + eligibility: { + canCreate: false, + blockedReason: 'no_upstream', + reviewLookupOutcome: 'unavailable' + } + }) + ).confirmed + ).toBe(false) + }) + }) +}) + +describe('isChecksPanelHardErrorCleared', () => { + const NOW = 1_000_000 + function cleared( + overrides: Partial = {} + ): ChecksPanelConfirmedReadinessInput { + return { + contextKeyMatches: true, + eligibility: { canCreate: true, blockedReason: null, reviewLookupOutcome: 'not_found' }, + eligibilityCompletedAt: NOW - 1_000, + eligibilityRequestStartedAt: NOW - 2_000, + reviewLookup: 'not_found', + hardErrorObservedAt: NOW - 3_000, + gitSnapshotMatches: true, + now: NOW, + ...overrides + } + } + + it('is cleared (true) when no hard error is observed', () => { + expect(isChecksPanelHardErrorCleared(cleared({ hardErrorObservedAt: undefined }))).toBe(true) + }) + + it('stays blocked while only a queued/in-flight retry replaced the error', () => { + // No request that both started after the error and settled with an accepted + // outcome, so Create must not flap back. + expect(isChecksPanelHardErrorCleared(cleared({ eligibilityRequestStartedAt: undefined }))).toBe( + false + ) + }) + + it('stays blocked for a request that started before the error', () => { + expect( + isChecksPanelHardErrorCleared( + cleared({ hardErrorObservedAt: NOW - 1_000, eligibilityRequestStartedAt: NOW - 2_000 }) + ) + ).toBe(false) + }) + + it('stays blocked when the clearing request is for a different context', () => { + expect(isChecksPanelHardErrorCleared(cleared({ contextKeyMatches: false }))).toBe(false) + }) + + it('stays blocked on an unavailable outcome even if the request started after', () => { + expect( + isChecksPanelHardErrorCleared( + cleared({ + eligibility: { + canCreate: false, + blockedReason: 'no_upstream', + reviewLookupOutcome: 'unavailable' + } + }) + ) + ).toBe(false) + }) + + it('clears when a later request settles found or not_found', () => { + expect(isChecksPanelHardErrorCleared(cleared())).toBe(true) + expect( + isChecksPanelHardErrorCleared( + cleared({ + eligibility: { + canCreate: false, + blockedReason: 'existing_review', + reviewLookupOutcome: 'found' + } + }) + ) + ).toBe(true) + }) +}) + +// Type-only usage to keep the imports meaningful across refactors. +const _blocked: HostedReviewCreationBlockedReason = null +const _outcome: HostedReviewLookupOutcome = 'not_found' +void _blocked +void _outcome diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts index 11e42d40fe1..95c9ccfde97 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts @@ -1,5 +1,14 @@ -import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' +import type { + HostedReviewCreationBlockedReason, + HostedReviewCreationEligibility, + HostedReviewLookupOutcome +} from '../../../../shared/hosted-review' +import type { PRRefreshErrorType } from '../../../../shared/types' import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs' +import type { ChecksPanelReviewLookup } from './checks-panel-review-lookup-authority' +// Single source of truth for the hard-refresh error set, shared with the +// review-state model so the composer gate and empty-state selector cannot drift. +import { HARD_REFRESH_ERROR_TYPES } from './checks-panel-review-state-model' export function resolveChecksPanelHostedReviewBaseRef(input: { worktreeBaseRef?: string | null @@ -14,17 +23,160 @@ function normalizeChecksPanelHostedReviewBaseRef(ref: string | null | undefined) return normalizedRef || null } +/** + * Shared create-eligibility floor for the desktop confirmed gate and the + * mobile-shared composer gate. Create / Push & Create is impossible when review + * existence cannot be proven no-review: positive unresolved evidence, a current + * hard refresh error, `existing_review`, or an `unavailable` existing-review + * lookup all fail closed. Otherwise it allows the `canCreate` / `needs_push` + * paths. Both callers route through this so their hard-block semantics cannot + * drift; the desktop confirmed gate layers freshness/context checks on top. + */ +export function isChecksPanelCreateEligibilityConfirmable(input: { + eligibility: Pick< + HostedReviewCreationEligibility, + 'canCreate' | 'blockedReason' | 'reviewLookupOutcome' + > | null + reviewLookup: ChecksPanelReviewLookup + hasHardRefreshError: boolean +}): boolean { + const eligibility = input.eligibility + if (!eligibility || eligibility.blockedReason === 'existing_review') { + return false + } + if (input.reviewLookup === 'positive_unresolved' || input.hasHardRefreshError) { + return false + } + // An `unavailable` existing-review lookup could be hiding a real PR. Block even + // the Push & Create (needs_push) path, which would otherwise slip through the + // canCreate check below with review existence unproven. + if (eligibility.reviewLookupOutcome === 'unavailable') { + return false + } + return eligibility.canCreate === true || eligibility.blockedReason === 'needs_push' +} + +/** + * Confirmed-only composer gate shared with mobile. Delegates the hard-block + * decision to {@link isChecksPanelCreateEligibilityConfirmable} so the desktop + * confirmed gate and this mobile-shared gate cannot drift. Phase 1 adds no + * provisional or draft-preserve path. + */ export function shouldOpenChecksPanelCreateComposer(input: { activeReview: unknown | null isFolder: boolean branch: string hostedReviewCreation: HostedReviewCreationEligibility | null + reviewLookup?: ChecksPanelReviewLookup + hasHardRefreshError?: boolean }): boolean { - return ( - !input.activeReview && - !input.isFolder && - Boolean(input.branch) && - (input.hostedReviewCreation?.canCreate === true || - input.hostedReviewCreation?.blockedReason === 'needs_push') - ) + if (input.activeReview || input.isFolder || !input.branch) { + return false + } + return isChecksPanelCreateEligibilityConfirmable({ + eligibility: input.hostedReviewCreation, + reviewLookup: input.reviewLookup ?? 'unknown', + hasHardRefreshError: input.hasHardRefreshError === true + }) } + +const CONFIRMED_ELIGIBILITY_MAX_AGE_MS = 5 * 60_000 + +export function isChecksPanelHardRefreshErrorType(errorType: string | undefined): boolean { + return errorType != null && HARD_REFRESH_ERROR_TYPES.has(errorType as PRRefreshErrorType) +} + +export type ChecksPanelConfirmedReadiness = { + confirmed: boolean + /** The confirmed path is Push & Create rather than plain Create. */ + needsPush: boolean +} + +export type ChecksPanelConfirmedReadinessInput = { + /** The eligibility result's context key equals the panel's current context. */ + contextKeyMatches: boolean + eligibility: Pick< + HostedReviewCreationEligibility, + 'canCreate' | 'blockedReason' | 'reviewLookupOutcome' + > | null + /** Wall-clock time the eligibility result settled. */ + eligibilityCompletedAt?: number + /** Wall-clock time the eligibility request that produced the result started. */ + eligibilityRequestStartedAt?: number + reviewLookup: ChecksPanelReviewLookup + /** + * Observation time of the most recent hard refresh error for this context, or + * undefined when no hard error is current. + */ + hardErrorObservedAt?: number + /** + * Whether the Git snapshot used for the eligibility still matches current HEAD, + * branch, upstream/ahead/behind/dirty, base, and execution-host fields. + */ + gitSnapshotMatches: boolean + now: number +} + +const NOT_CONFIRMED: ChecksPanelConfirmedReadiness = { confirmed: false, needsPush: false } + +/** + * A hard error is cleared only by an eligibility request that started strictly + * after the error was observed, completed for the same exact context with a + * `found` / `not_found` lookup outcome, with no newer hard error since. A late + * `unavailable` fallback or an already-in-flight request cannot clear it. + * + * Exported so the panel can keep the hard-error block sticky across + * `queued` / `in-flight` auto-retries (which drop `status: 'error'` and would + * otherwise let Create flap back) until this predicate says it is cleared. + */ +export function isChecksPanelHardErrorCleared(input: ChecksPanelConfirmedReadinessInput): boolean { + if (input.hardErrorObservedAt === undefined) { + return true + } + const startedAt = input.eligibilityRequestStartedAt + if (startedAt === undefined || !(startedAt > input.hardErrorObservedAt)) { + return false + } + if (!input.contextKeyMatches) { + return false + } + const outcome: HostedReviewLookupOutcome | undefined = input.eligibility?.reviewLookupOutcome + return outcome === 'found' || outcome === 'not_found' +} + +/** + * Confirmed readiness for the exact context. Any failed check drops confirmed + * immediately; transient refresh failures never appear here (the caller only + * passes a hard error observation, never a transient one). + */ +export function computeChecksPanelConfirmedReadiness( + input: ChecksPanelConfirmedReadinessInput +): ChecksPanelConfirmedReadiness { + const eligibility = input.eligibility + if (!input.contextKeyMatches) { + return NOT_CONFIRMED + } + // Share the hard-block floor with the mobile-shared gate; an uncleared hard + // error is folded in as the `hasHardRefreshError` input so the two cannot drift. + if ( + !isChecksPanelCreateEligibilityConfirmable({ + eligibility, + reviewLookup: input.reviewLookup, + hasHardRefreshError: !isChecksPanelHardErrorCleared(input) + }) + ) { + return NOT_CONFIRMED + } + // Freshness: eligibility must be recent and its Git snapshot must still match. + if ( + input.eligibilityCompletedAt === undefined || + input.now - input.eligibilityCompletedAt > CONFIRMED_ELIGIBILITY_MAX_AGE_MS || + !input.gitSnapshotMatches + ) { + return NOT_CONFIRMED + } + return { confirmed: true, needsPush: eligibility?.blockedReason === 'needs_push' } +} + +// Re-exported for callers that need the blocker type alongside the gate. +export type { HostedReviewCreationBlockedReason } diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts new file mode 100644 index 00000000000..36a814a930c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { + isRenderableChecksPanelReviewDetails, + normalizeTrustedReviewUrl, + resolveChecksPanelReviewLookup +} from './checks-panel-review-lookup-authority' + +const EMPTY = { + pr: null, + prCachedHasPR: null, + hostedReview: null +} as const + +describe('resolveChecksPanelReviewLookup', () => { + it('returns found with a trusted URL when PR details are renderable', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + pr: { number: 42, url: 'https://github.com/o/r/pull/42' } + }) + ).toEqual({ state: 'found', openReviewUrl: 'https://github.com/o/r/pull/42' }) + }) + + it('does not treat a summary without a positive number as found', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + pr: { number: 0, url: 'https://github.com/o/r/pull/0' }, + hostedReview: { provider: 'github', url: 'https://github.com/o/r/pull/1', number: 1 } + }).state + ).toBe('positive_unresolved') + }) + + it('classifies a github hosted-review card with no PR cache as positive_unresolved', () => { + // Replaces the old hasAmbiguousGitHubHostedReview boolean. + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + prCachedHasPR: null, + hostedReview: { provider: 'github', number: 7, url: 'https://github.com/o/r/pull/7' } + }) + ).toEqual({ state: 'positive_unresolved', openReviewUrl: 'https://github.com/o/r/pull/7' }) + }) + + it('keeps positive evidence over an accepted null no-PR entry (conflict)', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + prCachedHasPR: false, + linkedReviewNumber: 5 + }).state + ).toBe('positive_unresolved') + }) + + it('returns not_found for an accepted no-PR cache entry', () => { + expect(resolveChecksPanelReviewLookup({ ...EMPTY, prCachedHasPR: false })).toEqual({ + state: 'not_found', + openReviewUrl: null + }) + }) + + it('returns not_found for eligibility not_found', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + eligibilityReviewLookupOutcome: 'not_found' + }).state + ).toBe('not_found') + }) + + it('treats eligibility found as positive_unresolved when no renderable details', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + eligibilityReviewLookupOutcome: 'found', + eligibilityReview: { url: 'https://gitlab.com/o/r/-/merge_requests/3' } + }) + ).toEqual({ + state: 'positive_unresolved', + openReviewUrl: 'https://gitlab.com/o/r/-/merge_requests/3' + }) + }) + + it('returns unknown for a missing entry and unavailable eligibility', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + prCachedHasPR: null, + eligibilityReviewLookupOutcome: 'unavailable' + }).state + ).toBe('unknown') + }) + + it('does not offer Open Review for a non-http url', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + hostedReview: { provider: 'github', number: 9, url: 'javascript:alert(1)' } + }) + ).toEqual({ state: 'positive_unresolved', openReviewUrl: null }) + }) +}) + +describe('normalizeTrustedReviewUrl', () => { + it('accepts http and https', () => { + expect(normalizeTrustedReviewUrl(' https://x.test/pull/1 ')).toBe('https://x.test/pull/1') + expect(normalizeTrustedReviewUrl('http://x.test/pull/1')).toBe('http://x.test/pull/1') + }) + + it('rejects empty and non-web schemes', () => { + expect(normalizeTrustedReviewUrl(null)).toBe(null) + expect(normalizeTrustedReviewUrl('')).toBe(null) + expect(normalizeTrustedReviewUrl('ftp://x.test')).toBe(null) + expect(normalizeTrustedReviewUrl('file:///etc/passwd')).toBe(null) + expect(normalizeTrustedReviewUrl('not a url')).toBe(null) + }) + + it('rejects credential-bearing URLs so tokens never reach the browser/shell', () => { + expect(normalizeTrustedReviewUrl('https://user:token@git.test/repo/pull/1')).toBe(null) + expect(normalizeTrustedReviewUrl('https://user@git.test/repo/pull/1')).toBe(null) + }) +}) + +describe('isRenderableChecksPanelReviewDetails', () => { + it('requires a positive number', () => { + expect(isRenderableChecksPanelReviewDetails({ number: 1 })).toBe(true) + expect(isRenderableChecksPanelReviewDetails({ number: 0 })).toBe(false) + expect(isRenderableChecksPanelReviewDetails(null)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.ts new file mode 100644 index 00000000000..55d24fefd39 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.ts @@ -0,0 +1,120 @@ +import type { HostedReviewLookupOutcome } from '../../../../shared/hosted-review' +import { isPositiveHostedReviewNumber } from '../../../../shared/hosted-review' + +/** + * Four-state review evidence model for the Checks panel. Replaces the single + * `hasAmbiguousGitHubHostedReview` Boolean, which collapsed materially different + * states (missing lookup, failed lookup, positive-but-unrenderable evidence). + * + * - `found`: current review details are renderable — leave the empty state. + * - `positive_unresolved`: a review is known to exist but details are not + * renderable. Never offer Create / Push & Create; prefer trusted Open Review. + * - `not_found`: an accepted no-review result for the exact context. + * - `unknown`: no accepted positive or no-review result (missing entry, failed + * or skipped lookup, in-flight). + */ +export type ChecksPanelReviewLookup = 'found' | 'positive_unresolved' | 'not_found' | 'unknown' + +type ReviewLookupNumberLike = number | null | undefined + +export type ChecksPanelReviewLookupInput = { + /** + * Renderable review details for the exact context (GitHub `PRInfo` payload or + * equivalent). Treated as renderable only when it carries a usable identity. + */ + pr: { number?: ReviewLookupNumberLike; url?: string | null } | null | undefined + /** + * Tri-state from the PR cache entry: `true` = has PR, `false` = accepted + * no-PR, `null` = no accepted entry (missing / never fetched). Distinguishes an + * accepted no-review outcome from a missing lookup. + */ + prCachedHasPR: boolean | null + /** Positive hosted-review cache for the exact context, when present. */ + hostedReview: + | { provider?: string | null; number?: ReviewLookupNumberLike; url?: string | null } + | null + | undefined + /** Durable linked review number for the exact context. */ + linkedReviewNumber?: ReviewLookupNumberLike + /** Settled eligibility lookup outcome for the exact context. */ + eligibilityReviewLookupOutcome?: HostedReviewLookupOutcome | null + /** Eligibility review summary (may be summary-only). */ + eligibilityReview?: { number?: ReviewLookupNumberLike; url?: string | null } | null +} + +export type ChecksPanelReviewLookupResult = { + state: ChecksPanelReviewLookup + /** Trusted http(s) URL for Open Review, when one is known; else null. */ + openReviewUrl: string | null +} + +/** + * Returns the URL only when it is a plain http(s) link with no embedded + * credentials, so a credential-bearing or non-web value never reaches an Open + * Review affordance (and never lands in browser history / shell logs). + */ +export function normalizeTrustedReviewUrl(url: string | null | undefined): string | null { + if (!url) { + return null + } + const trimmed = url.trim() + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + return null + } + // Reject non-web schemes and any userinfo (`user:token@host`) — a prefix regex + // alone would forward embedded credentials to the browser/shell. + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + if (parsed.username !== '' || parsed.password !== '') { + return null + } + return trimmed +} + +/** + * Review details render the normal review chrome only with a usable identity: a + * positive review number. A provider-neutral summary without renderable details + * maps to `positive_unresolved`, not `found`. + */ +export function isRenderableChecksPanelReviewDetails( + pr: { number?: ReviewLookupNumberLike } | null | undefined +): boolean { + return pr != null && isPositiveHostedReviewNumber(pr.number) +} + +export function resolveChecksPanelReviewLookup( + input: ChecksPanelReviewLookupInput +): ChecksPanelReviewLookupResult { + if (isRenderableChecksPanelReviewDetails(input.pr)) { + return { + state: 'found', + openReviewUrl: normalizeTrustedReviewUrl(input.pr?.url) + } + } + + // Positive evidence wins over an accepted null no-PR entry: a conflict must + // stay `positive_unresolved` until a newer accepted result clears it, never + // collapse to "no PR." + const openReviewUrl = + normalizeTrustedReviewUrl(input.hostedReview?.url) ?? + normalizeTrustedReviewUrl(input.eligibilityReview?.url) + const hasPositiveEvidence = + isPositiveHostedReviewNumber(input.linkedReviewNumber) || + isPositiveHostedReviewNumber(input.hostedReview?.number) || + isPositiveHostedReviewNumber(input.eligibilityReview?.number) || + input.eligibilityReviewLookupOutcome === 'found' || + (input.hostedReview != null && openReviewUrl !== null) + if (hasPositiveEvidence) { + return { state: 'positive_unresolved', openReviewUrl } + } + + if (input.prCachedHasPR === false || input.eligibilityReviewLookupOutcome === 'not_found') { + return { state: 'not_found', openReviewUrl: null } + } + + return { state: 'unknown', openReviewUrl: null } +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts new file mode 100644 index 00000000000..4b43615ebd9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts @@ -0,0 +1,165 @@ +import type { HostedReviewCreationBlockedReason } from '../../../../shared/hosted-review' +import type { GitHubPRRefreshSkippedReason, PRRefreshErrorType } from '../../../../shared/types' +import type { ChecksPanelReviewLookup } from './checks-panel-review-lookup-authority' + +/** + * Composer visibility the panel should render. Phase 1 never *opens* a new + * composer from a refresh failure — `confirmed_open` / `needs_push_open` mean an + * already-confirmed composer stays open. (Phase 2's disabled draft-preserve mode + * is intentionally absent until that phase ships.) + */ +export type ChecksPanelComposerMode = 'hidden' | 'confirmed_open' | 'needs_push_open' + +export type ChecksPanelWorkflowAction = + | 'create' + | 'push_and_create' + | 'publish_branch' + | 'sync_branch' + | 'authenticate' + | null + +export type ChecksPanelRecoveryAction = 'retry' | 'refresh' | 'open_review' + +export type ChecksPanelReviewState = { + /** True only for `reviewLookup: found` — the caller renders review chrome. */ + renderReview: boolean + title: string + description: string + /** One muted sentence appended beneath the body (concurrent lookup failure). */ + detail?: string + composerMode: ChecksPanelComposerMode + workflowAction: ChecksPanelWorkflowAction + recovery: ChecksPanelRecoveryAction[] + openReviewUrl?: string | null + /** Drives "Orca will retry at {time}"; only when a future auto-retry exists. */ + autoRetryAt?: number + /** Retry button stays disabled while now < retryDisabledUntil. */ + retryDisabledUntil?: number +} + +export type ChecksPanelRefreshInput = { + status?: 'queued' | 'in-flight' | 'paused' | 'error' | 'skipped' + errorType?: PRRefreshErrorType + skippedReason?: GitHubPRRefreshSkippedReason + nextAutoRetryAt?: number + retryDisabledUntil?: number +} + +export type ChecksPanelReviewStateInput = { + operationLabel: string | null + reviewLabel: 'pull request' | 'merge request' + reviewShortLabel: 'PR' | 'MR' + /** Localized provider display name, e.g. "GitHub". */ + providerName: string + /** GitHub gets typed error rows; other providers use neutral copy. */ + isGitHubProvider: boolean + reviewLookup: ChecksPanelReviewLookup + openReviewUrl: string | null + eligibilityBlockedReason: HostedReviewCreationBlockedReason | undefined + /** Confirmed readiness held for the exact context (see review-creation). */ + confirmedReadiness: boolean + /** The confirmed path is the Push & Create workflow rather than plain Create. */ + confirmedNeedsPush: boolean + refresh?: ChecksPanelRefreshInput + gitStatusPhase: 'loading' | 'ready' | 'error' + hasUpstream: boolean | undefined + hasCurrentBranch: boolean +} + +export const HARD_REFRESH_ERROR_TYPES = new Set([ + 'auth', + 'permission', + 'repo_unavailable', + 'gh_unavailable' +]) + +/** Eligibility blockers that own their own guidance and outrank refresh copy. */ +export const ELIGIBILITY_SAFETY_BLOCKERS = new Set>([ + 'detached_head', + 'dirty', + 'default_branch', + 'existing_review', + 'fork_head_unsupported', + 'base_not_on_remote', + 'unsupported_provider' +]) + +export function isHardRefreshError(refresh: ChecksPanelRefreshInput | undefined): boolean { + return ( + refresh?.status === 'error' && + refresh.errorType != null && + HARD_REFRESH_ERROR_TYPES.has(refresh.errorType) + ) +} + +export function isRateLimitRefresh(refresh: ChecksPanelRefreshInput | undefined): boolean { + return ( + refresh?.status === 'paused' || + refresh?.errorType === 'rate_limited' || + (refresh?.status === 'skipped' && refresh.skippedReason === 'rate-limit') + ) +} + +export function isTransientRefreshFailure(refresh: ChecksPanelRefreshInput | undefined): boolean { + if (!refresh) { + return false + } + if (isRateLimitRefresh(refresh)) { + return true + } + if (refresh.status === 'error') { + // Untyped and typed-transient errors only pause background refresh; they do + // not prove a fresh user-initiated lookup would fail. + return ( + refresh.errorType == null || + refresh.errorType === 'network' || + refresh.errorType === 'server_error' || + refresh.errorType === 'unknown' + ) + } + return false +} + +export function isBranchBlocker(reason: HostedReviewCreationBlockedReason | undefined): boolean { + return ( + reason === 'no_upstream' || + reason === 'needs_sync' || + reason === 'auth_required' || + reason === 'needs_push' + ) +} + +export function confirmedComposerMode( + input: Pick +): ChecksPanelComposerMode { + if (!input.confirmedReadiness) { + return 'hidden' + } + return input.confirmedNeedsPush ? 'needs_push_open' : 'confirmed_open' +} + +export function workflowActionForComposer( + mode: ChecksPanelComposerMode +): ChecksPanelWorkflowAction { + if (mode === 'confirmed_open') { + return 'create' + } + if (mode === 'needs_push_open') { + return 'push_and_create' + } + return null +} + +export function autoRetrySchedule(input: ChecksPanelReviewStateInput): { + autoRetryAt?: number + retryDisabledUntil?: number +} { + return { + autoRetryAt: input.refresh?.nextAutoRetryAt, + retryDisabledUntil: input.refresh?.retryDisabledUntil + } +} + +export function capitalizeReviewLabel(value: string): string { + return value.length > 0 ? value[0].toUpperCase() + value.slice(1) : value +} diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index 4f14097388d..753ebaaa750 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -7,7 +7,6 @@ import { createPrIntentRunTokenMatches, getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, - isCreatePrIntentSyncConflictError, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep } from './source-control-create-pr-intent-flow' @@ -190,7 +189,8 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'no_upstream', - nextAction: 'publish' + nextAction: 'publish', + reviewLookupOutcome: 'not_found' } }) ).toBe('publish') @@ -204,7 +204,8 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) ).toBe('push') @@ -224,15 +225,16 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) ).toBe('force_push') }) - it('syncs behind-only branches, blocks diverged and unpublished-without-commits branches', () => { - // Genuinely diverged (local + non-equivalent remote commits): auto-syncing - // would merge without consent, so the intent flow keeps the explicit stop. + it('fast-forwards behind-only branches, blocks diverged and unpublished-without-commits branches', () => { + // Genuinely diverged (local + non-equivalent remote commits): auto-merging + // would reconcile without consent, so the intent flow keeps the explicit stop. expect( resolveCreatePrIntentRemoteStep({ upstreamStatus: { hasUpstream: true, ahead: 1, behind: 1 }, @@ -242,12 +244,13 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) ).toBe('blocked') - // Behind with no local commits (pure fast-forward case) auto-syncs. + // Behind with no local commits: pure --ff-only (never plain merge sync). expect( resolveCreatePrIntentRemoteStep({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 }, @@ -257,10 +260,11 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) - ).toBe('sync') + ).toBe('fast_forward') expect( resolveCreatePrIntentRemoteStep({ @@ -272,7 +276,8 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'no_upstream', - nextAction: 'publish' + nextAction: 'publish', + reviewLookupOutcome: 'not_found' } }) ).toBe('blocked') @@ -296,81 +301,4 @@ describe('source-control Create PR intent flow helpers', () => { }) ).toBe('localized Pre-commit hook failed.') }) - - it('treats only a genuine merge-conflict sync failure as a conflict for notice copy', () => { - // Pull/merge conflict from this sync: the user must resolve conflicts. - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - syncPushStage: false, - rawError: 'CONFLICT (content): Merge conflict in src/app.ts' - }) - ).toBe(true) - // An unconcluded prior merge also counts as a conflict to resolve. - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - rawError: 'error: you have not concluded your merge (MERGE_HEAD exists).' - }) - ).toBe(true) - - // A fetch/network/auth failure during sync is NOT a conflict, even though it - // is not marked as the push stage — it must fall to the generic copy. - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - syncPushStage: false, - rawError: 'fatal: Authentication failed for remote' - }) - ).toBe(false) - // No raw error at all is not a conflict. - expect(isCreatePrIntentSyncConflictError({ kind: 'sync' })).toBe(false) - - // Push-stage rejection during sync is a push failure, not a conflict. - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - syncPushStage: true, - rawError: 'CONFLICT (content): Merge conflict in src/app.ts' - }) - ).toBe(false) - - // Non-sync remote failures never use the conflict copy. - expect(isCreatePrIntentSyncConflictError({ kind: 'push' })).toBe(false) - expect( - isCreatePrIntentSyncConflictError({ - kind: 'force_push', - syncPushStage: false, - rawError: 'CONFLICT (content): Merge conflict in src/app.ts' - }) - ).toBe(false) - expect(isCreatePrIntentSyncConflictError(null)).toBe(false) - expect(isCreatePrIntentSyncConflictError(undefined)).toBe(false) - }) - - it('recognizes every merge-conflict wording git can emit during sync', () => { - // The fresh-conflict pattern's other alternatives: "Automatic merge failed" - // and "fix conflicts" are the most common real pull output. - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - syncPushStage: false, - rawError: 'Automatic merge failed; fix conflicts and then commit the result.' - }) - ).toBe(true) - // The unconcluded-merge pattern's other alternatives: "unmerged files" and - // "needs merge". - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - rawError: 'error: Pulling is not possible because you have unmerged files.' - }) - ).toBe(true) - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - rawError: "error: path 'src/app.ts' needs merge" - }) - ).toBe(true) - }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts index 000fc19db21..bafba4c8504 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts @@ -1,12 +1,13 @@ -import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' +import { + isBehindOnlyUpstream, + shouldForcePushWithLeaseForUpstream +} from '../../../../shared/git-upstream-status' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types' -import { isMergeConflictErrorMessage } from '../../lib/source-control-remote-error' -import type { SourceControlActionError } from './source-control-action-error' import { summarizeCommitFailure } from './commit-failure-summary' import { getStageAllPaths } from './discard-all-sequence' @@ -14,7 +15,7 @@ export type CreatePrIntentRemoteStep = | 'publish' | 'push' | 'force_push' - | 'sync' + | 'fast_forward' | 'blocked' | 'none' @@ -150,36 +151,17 @@ export function resolveCreatePrIntentRemoteStep({ if (shouldForcePushWithLeaseForUpstream(upstreamStatus)) { return 'force_push' } - // Why: auto-sync only a behind-only branch, where the pull is a fast-forward - // that cannot create a merge commit or strand the worktree mid-merge. A - // genuinely diverged branch keeps the explicit sync-first stop so Create PR - // never merges without consent. - return upstreamStatus?.hasUpstream && upstreamStatus.ahead === 0 && upstreamStatus.behind > 0 - ? 'sync' - : 'blocked' + // Why: auto-prepare only a behind-only branch, and only via `--ff-only`. + // Plain sync/merge could create a merge commit if the branch diverges mid + // flight or the user has pull.ff=no; --ff-only enforces the no-consent- + // merge invariant at execution time. Genuinely diverged branches keep the + // explicit sync-first stop. + return isBehindOnlyUpstream(upstreamStatus) ? 'fast_forward' : 'blocked' } return 'none' } -export type CreatePrIntentRemoteFailure = - | (Pick & - Partial>) - | null - | undefined - -export function isCreatePrIntentSyncConflictError(error: CreatePrIntentRemoteFailure): boolean { - // Why: only a genuine merge conflict earns the "resolve conflicts" copy. A - // sync push-stage rejection is a push failure, and a fetch/network/auth - // failure during sync is neither a conflict nor push-stage — both must fall - // to the generic remote-failed notice, so match the raw git output rather - // than assuming every non-push-stage sync failure is a conflict. - if (error?.kind !== 'sync' || error.syncPushStage === true) { - return false - } - return isMergeConflictErrorMessage(error.rawError ?? '') -} - export function getCreatePrIntentCommitFailureNoticeMessage( commitError: string | null | undefined, copy: { diff --git a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts index 10419bad3ea..e2bd937f1c2 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts @@ -14,6 +14,7 @@ function eligibility( canCreate: false, blockedReason: 'needs_push', nextAction: 'push', + reviewLookupOutcome: 'not_found', ...overrides } } diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 86af5bafdb7..d97707dd273 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -183,7 +183,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) ) @@ -360,7 +361,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) @@ -671,7 +673,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) ) @@ -693,7 +696,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) ) @@ -719,7 +723,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) ) @@ -747,7 +752,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'auth_required', - nextAction: 'authenticate' + nextAction: 'authenticate', + reviewLookupOutcome: 'not_found' } }) ) @@ -766,7 +772,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'auth_required', - nextAction: 'authenticate' + nextAction: 'authenticate', + reviewLookupOutcome: 'not_found' } }) ) diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts index ae6dceadc7c..13d7bc5dac9 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts @@ -32,7 +32,7 @@ describe('resolveHostedReviewCreationProviderForTarget', () => { }) describe('buildLocalBlockerHostedReviewCreationEligibility', () => { - it('reports dirty when there are uncommitted changes', () => { + it('reports dirty without offering create intent when the review lookup failed', () => { const eligibility = buildLocalBlockerHostedReviewCreationEligibility('github', { ...featureBranch, hasUncommittedChanges: true, @@ -40,9 +40,12 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { ahead: 0, behind: 0 }) - expect(eligibility).toMatchObject({ blockedReason: 'dirty', nextAction: 'commit' }) - // The synthesized blocker must drive the actionable Create PR intent so a - // failed remote probe still offers commit/publish preparation. + expect(eligibility).toMatchObject({ + blockedReason: 'dirty', + nextAction: 'commit', + reviewLookupOutcome: 'unavailable' + }) + // Why: branch guidance can remain specific, but a failed lookup cannot authorize creation. expect( resolveCreatePrIntentEligibility({ stagedCount: 1, @@ -53,7 +56,7 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { hostedReviewCreation: eligibility, branchCommitsAhead: 0 }) - ).toEqual({ eligible: true, kind: 'dirty' }) + ).toEqual({ eligible: false, kind: null }) }) it('prefers dirty over no_upstream when both apply, matching main-process ordering', () => { diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts index f951af458f0..0857f94f6a0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts @@ -13,7 +13,9 @@ export function buildLoadingHostedReviewCreationEligibility( review: null, canCreate: false, blockedReason: null, - nextAction: null + nextAction: null, + // Why: a loading placeholder has no authoritative existing-review result. + reviewLookupOutcome: 'unavailable' } } @@ -36,8 +38,8 @@ export function resolveHostedReviewCreationProviderForTarget( /** * Local-status-only eligibility used when the remote creation probe fails or - * times out, so a failed probe still offers the actionable commit/publish/sync - * preparation action instead of leaving an inert disabled button. + * times out, so the UI can show branch guidance without treating the failed + * lookup as authority to create a review. * * Mirrors the main process's own lookup-failure fallback exactly — both its * `canReturnLocalBlocker` guard and its blocker ordering @@ -77,7 +79,9 @@ export function buildLocalBlockerHostedReviewCreationEligibility( review: null, canCreate: false as const, defaultBaseRef: null, - head: branch + head: branch, + // Why: local Git blockers cannot prove that a hosted review does not exist. + reviewLookupOutcome: 'unavailable' as const } if (status.hasUncommittedChanges) { return { ...base, blockedReason: 'dirty', nextAction: 'commit' } diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts index 086f86565ed..c56892b71ec 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts @@ -39,7 +39,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'no_upstream', - nextAction: 'publish' + nextAction: 'publish', + reviewLookupOutcome: 'not_found' } }) ) @@ -63,7 +64,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) ) @@ -71,7 +73,7 @@ describe('resolvePrimaryAction Create PR intent', () => { expect(result.disabled).toBe(false) }) - it('returns Create PR intent for a behind-only branch (fast-forward sync)', () => { + it('returns Create PR intent for a behind-only branch (fast-forward prepare)', () => { const input = inputs({ upstreamStatus: { hasUpstream: true, @@ -84,7 +86,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) const result = resolvePrimaryAction(input) @@ -106,7 +109,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) const result = resolvePrimaryAction(input) @@ -137,7 +141,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) ) @@ -159,7 +164,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) ) @@ -181,7 +187,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) const result = resolvePrimaryAction(input) @@ -206,7 +213,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'default_branch', - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) @@ -229,7 +237,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) ) @@ -250,7 +259,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) ) @@ -270,7 +280,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) @@ -299,7 +310,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) ) @@ -321,7 +333,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'no_upstream', - nextAction: 'publish' + nextAction: 'publish', + reviewLookupOutcome: 'not_found' } }) @@ -354,7 +367,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) @@ -387,7 +401,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) @@ -409,7 +424,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) @@ -430,7 +446,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' }, isHostedReviewCreationLoading: true }) @@ -452,7 +469,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' }, isHostedReviewCreationLoading: true }) @@ -474,7 +492,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' }, isHostedReviewCreationLoading: true }) @@ -498,7 +517,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'no_upstream', - nextAction: 'publish' + nextAction: 'publish', + reviewLookupOutcome: 'not_found' } }) ) diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts index b71ae52185d..f03f1e490d7 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts @@ -429,7 +429,8 @@ describe('resolvePrimaryAction', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) @@ -455,7 +456,8 @@ describe('resolvePrimaryAction', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) @@ -542,7 +544,8 @@ describe('resolvePrimaryAction', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) @@ -563,7 +566,8 @@ describe('resolvePrimaryAction', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) @@ -586,7 +590,8 @@ describe('resolvePrimaryAction', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts index d09fc41cc32..8017aea4445 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts @@ -51,6 +51,7 @@ function createEligibility( canCreate: true, blockedReason: null, nextAction: null, + reviewLookupOutcome: 'not_found', defaultBaseRef: 'refs/remotes/origin/main', title: 'Review title', body: 'Review body', diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 08a3b311de6..815a18abba3 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -48,6 +48,11 @@ type UseCreatePullRequestDialogFieldsOptions = { submitting: boolean prCreationDefaults?: SourceControlAiPrCreationDefaults sourceControlAiActionsVisible?: boolean + // When the composer is hidden by a temporary policy (a hard refresh error) for + // the same context rather than dismissed, retain the in-memory draft so + // recovery does not discard the user's title/body/base edits. Reopening a + // different context still reseeds (guarded by the eligibility key). + retainDraftWhenClosed?: boolean onBranchChangedByGeneration?: () => Promise generation?: { generating: boolean @@ -131,6 +136,7 @@ export function useCreatePullRequestDialogFields({ submitting, prCreationDefaults, sourceControlAiActionsVisible = true, + retainDraftWhenClosed = false, onBranchChangedByGeneration, generation }: UseCreatePullRequestDialogFieldsOptions) { @@ -254,12 +260,18 @@ export function useCreatePullRequestDialogFields({ } } generateInFlightRef.current = false - generationSeedRef.current = null - initializedFromEligibilityRef.current = null - syncedDefaultBaseRef.current = null - baseEditedByUserRef.current = false - setInitializedEligibilityKey(null) - autoGeneratedForKeyRef.current = null + // Why: retain the draft + init marker across a temporary policy hide so a + // hard-error recovery reopening the same context does not reseed and wipe + // edits. A genuine context change still reseeds (the eligibility key on + // reopen differs from the retained marker). + if (!retainDraftWhenClosed) { + generationSeedRef.current = null + initializedFromEligibilityRef.current = null + syncedDefaultBaseRef.current = null + baseEditedByUserRef.current = false + setInitializedEligibilityKey(null) + autoGeneratedForKeyRef.current = null + } setGenerating(false) setGenerateError(null) } @@ -316,6 +328,7 @@ export function useCreatePullRequestDialogFields({ repoId, resolvedDefaultBaseRef, resolvedPrDefaults.draft, + retainDraftWhenClosed, worktreeId, worktreePath ]) diff --git a/src/renderer/src/components/settings/BrowserUsePane.tsx b/src/renderer/src/components/settings/BrowserUsePane.tsx index 8f2dea14ee4..9871615acdf 100644 --- a/src/renderer/src/components/settings/BrowserUsePane.tsx +++ b/src/renderer/src/components/settings/BrowserUsePane.tsx @@ -128,7 +128,8 @@ export function BrowserUseSetup({ const cookiesImported = !!defaultProfile?.source const cliEnabled = isOrcaCliAvailableOnPath(cliStatus) - const cliPathNeedsAttention = cliStatus?.state === 'installed' && !cliStatus.pathConfigured + const cliPathNeedsAttention = + cliStatus?.state === 'installed' && cliStatus.pathConfigured === false const cliSupported = cliStatus?.supported ?? false const { diff --git a/src/renderer/src/components/settings/CliSection.test.tsx b/src/renderer/src/components/settings/CliSection.test.tsx index 2c34b299d8b..7db93106fe4 100644 --- a/src/renderer/src/components/settings/CliSection.test.tsx +++ b/src/renderer/src/components/settings/CliSection.test.tsx @@ -1,5 +1,8 @@ +// @vitest-environment happy-dom + import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { CliSection } from './CliSection' @@ -12,6 +15,9 @@ const capturedPanel = vi.hoisted(() => ({ }, useInstalledAgentSkill: vi.fn() })) +const toastError = vi.hoisted(() => vi.fn()) + +vi.mock('sonner', () => ({ toast: { error: toastError, success: vi.fn() } })) vi.mock('@/hooks/useInstalledAgentSkills', () => ({ GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['global'], @@ -25,6 +31,12 @@ capturedPanel.useInstalledAgentSkill.mockReturnValue({ refresh: vi.fn() }) +afterEach(() => { + cleanup() + toastError.mockReset() + vi.unstubAllGlobals() +}) + vi.mock('./AgentSkillSetupPanel', () => ({ AgentSkillSetupPanel: function AgentSkillSetupPanel(props: { command: string @@ -98,4 +110,40 @@ describe('CliSection project runtime defaults', () => { expect(getWslInstallStatus).toHaveBeenCalledWith({ distro: 'Ubuntu' }) expect(getWslInstallStatus).toHaveBeenCalledTimes(2) }) + + it('renders an inline unknown PATH state without offering a mutation', async () => { + const getInstallStatus = vi.fn().mockResolvedValue({ + platform: 'win32', + commandName: 'orca', + commandPath: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', + pathDirectory: 'C:\\Program Files\\Orca\\resources\\bin', + pathConfigured: null, + launcherPath: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', + installMethod: 'wrapper', + supported: true, + state: 'installed', + currentTarget: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', + unsupportedReason: null, + detail: 'Orca could not read the Windows user PATH registry value.' + }) + Object.assign(window, { + api: { + cli: { + getInstallStatus, + getWslInstallStatus: vi.fn(), + install: vi.fn(), + remove: vi.fn() + }, + shell: { openPath: vi.fn() } + } + }) + + render() + + expect(await screen.findByText(/could not read the Windows user PATH/i)).toBeDefined() + const registrationSwitch = screen.getByRole('switch') as HTMLButtonElement + expect(registrationSwitch.disabled).toBe(true) + expect(registrationSwitch.getAttribute('aria-checked')).toBe('false') + expect(toastError).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index eae2ce2a76b..5f61d1e8ef3 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -155,7 +155,8 @@ export function CliSection({ void refreshStatus() }, [refreshStatus]) - const isEnabled = status?.state === 'installed' + const pathStatusUnknown = currentPlatform === 'win32' && status?.pathConfigured === null + const isEnabled = status?.state === 'installed' && !pathStatusUnknown const isSupported = status?.supported ?? false const isBrowserManaged = status?.unsupportedReason === 'launch_mode_unavailable' const revealLabel = getRevealLabel(currentPlatform) @@ -251,7 +252,9 @@ export function CliSection({ -

+

{loading ? translate( 'auto.components.settings.CliSection.d363e5929b', @@ -286,11 +289,11 @@ export function CliSection({ - ) : ( - - {translate( - 'auto.components.settings.MobilePairingConnectionOptions.unavailable', - 'Unavailable' - )} - - )} -

- ) : null} - +

{description}

+
+
) } @@ -168,9 +113,38 @@ export function MobilePairingConnectionOptions({ const authStatus = useAppStore((state) => state.orcaProfileAuthStatus) const connecting = useAppStore((state) => state.orcaProfileConnecting) const connect = useAppStore((state) => state.connectCurrentOrcaProfile) + const fetchAuthStatus = useAppStore((state) => state.fetchOrcaProfileAuthStatus) const [relayStatus, setRelayStatus] = useState('offline') const signedIn = authStatus?.state === 'connected' + const reconnectRequired = authStatus?.state === 'reconnect-required' + // Why: an unconfigured build has no Relay endpoint to sign into, so a Sign in + // CTA would be dead. Treat that case as unavailable (matching the prior UI) + // and only offer Sign in when the build can actually reach Relay. const configured = authStatus?.configured !== false + const needsSignIn = value === 'automatic' && !signedIn && configured + const relayUnavailable = value === 'automatic' && !signedIn && !configured + const optionRefs = useRef>({ + automatic: null, + 'local-only': null + }) + + // Why: ARIA radiogroups move selection with the arrow keys; wrap between the + // two options and move focus so keyboard users get standard behavior. + const handleArrowKeys = (event: React.KeyboardEvent): void => { + if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { + return + } + event.preventDefault() + const next: MobilePairingConnectionMode = value === 'automatic' ? 'local-only' : 'automatic' + onChange(next) + optionRefs.current[next]?.focus() + } + + useEffect(() => { + if (!authStatus) { + void fetchAuthStatus() + } + }, [authStatus, fetchAuthStatus]) useEffect(() => { let receivedEvent = false @@ -195,54 +169,112 @@ export function MobilePairingConnectionOptions({ } }, []) - if (compact) { - return ( - - ) - } - return ( -
- - {!signedIn ? ( -
- {configured ? ( - - ) : ( - - {translate( - 'auto.components.settings.MobilePairingConnectionOptions.unavailable', - 'Unavailable' - )} - +
+
+ { + optionRefs.current.automatic = el + }} + onSelect={() => onChange('automatic')} + title={translate( + 'auto.components.settings.MobilePairingConnectionOptions.anywhereTitle', + 'Orca Relay' )} + description={translate( + 'auto.components.settings.MobilePairingConnectionOptions.anywhereDescription', + 'Phone can be on cellular or any Wi‑Fi. Sign-in required.' + )} + trailing={ + signedIn && value === 'automatic' ? ( + + {relayStatusLabel(relayStatus)} + + ) : null + } + /> +
+ { + optionRefs.current['local-only'] = el + }} + onSelect={() => onChange('local-only')} + title={translate( + 'auto.components.settings.MobilePairingConnectionOptions.localTitle', + 'Local network' + )} + description={translate( + 'auto.components.settings.MobilePairingConnectionOptions.localDescription', + 'Phone must be on this Wi‑Fi or your Tailscale. No sign-in.' + )} + /> +
+ + {needsSignIn ? ( +
+

+ {translate( + 'auto.components.settings.MobilePairingConnectionOptions.signInRequired', + 'Sign in to use Orca Mobile Relay.' + )} +

+
) : null} -
+ + {relayUnavailable ? ( +
+

+ {translate( + 'auto.components.settings.MobilePairingConnectionOptions.relayUnavailable', + 'Orca Relay isn’t available in this build. Use Local network.' + )} +

+ + {translate( + 'auto.components.settings.MobilePairingConnectionOptions.unavailable', + 'Unavailable' + )} + +
+ ) : null} +
) } diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx index cce8bdce341..59ed1b9a3c9 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.test.tsx @@ -3,7 +3,7 @@ import '@testing-library/jest-dom/vitest' import React from 'react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MobilePairingSetupSection } from './MobilePairingSetupSection' @@ -23,7 +23,7 @@ function renderSection( const onGenerateQr = vi.fn() const props: React.ComponentProps = { connectionMode: 'local-only', - relayConnectionControl: null, + connectionPathControl:
path
, networkInterfaces: [LAN, TAILNET], selectedAddress: TAILNET.address, onSelectedAddressChange, @@ -44,18 +44,37 @@ function renderSection( } describe('MobilePairingSetupSection', () => { - it('keeps local settings visible for local-only pairing', () => { - renderSection() - expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)') - expect(screen.getByText(/connects only through the local network address/i)).toBeVisible() + beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + shell: { openUrl: vi.fn().mockResolvedValue(undefined) } + } + }) }) - it('keeps local settings visible for automatic direct-first pairing', () => { + it('shows connection, address, and generate in a compact flow', () => { + renderSection() + expect(screen.getByText('Pair a phone')).toBeVisible() + expect(screen.getByText('Connection')).toBeVisible() + expect(screen.getByText('This computer’s address')).toBeVisible() + expect(screen.getByTestId('path-control')).toBeVisible() + expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)') + expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeVisible() + expect(screen.getByText(/must be able to reach this address/i)).toBeVisible() + }) + + it('describes address role when Anywhere is selected', () => { renderSection({ connectionMode: 'automatic' }) - expect(screen.getByRole('combobox')).toBeVisible() - expect( - screen.getByText(/includes direct access and encrypted Orca Relay fallback/i) - ).toBeVisible() + expect(screen.getByText(/faster direct path when nearby/i)).toBeVisible() + }) + + it('disables generate when sign-in is required', () => { + // The sign-in explanation lives in the connection panel above, not here, so + // this section only gates the button rather than repeating the copy. + renderSection({ canGenerate: false }) + expect(screen.getByRole('button', { name: 'Generate QR code' })).toBeDisabled() + expect(screen.queryByText(/Sign in above first/i)).toBeNull() }) it('commits an OS interface picked from the list', async () => { @@ -65,9 +84,9 @@ describe('MobilePairingSetupSection', () => { expect(onSelectedAddressChange).toHaveBeenCalledWith('192.168.1.24') }) - it('generates a pairing code with the selected mode', async () => { + it('generates a pairing code', async () => { const { user, onGenerateQr } = renderSection() - await user.click(screen.getByRole('button', { name: 'Generate QR Code' })) + await user.click(screen.getByRole('button', { name: 'Generate QR code' })) expect(onGenerateQr).toHaveBeenCalledOnce() }) }) diff --git a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx index caf1f6b6873..2f5453013b9 100644 --- a/src/renderer/src/components/settings/MobilePairingSetupSection.tsx +++ b/src/renderer/src/components/settings/MobilePairingSetupSection.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react' -import { ExternalLink, Loader2, QrCode, RefreshCw } from 'lucide-react' -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' +import { Loader2, QrCode, RefreshCw } from 'lucide-react' import { Button } from '../ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { translate } from '@/i18n/i18n' @@ -8,11 +7,11 @@ import { NetworkInterfacePicker } from '../mobile/NetworkInterfacePicker' import type { MobileNetworkInterface } from './mobile-network-interface-selection' import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' -const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download' - type MobilePairingSetupSectionProps = { connectionMode: MobilePairingConnectionMode - relayConnectionControl: ReactNode + /** False when Anywhere is selected but Relay cannot be committed yet. */ + canGenerate?: boolean + connectionPathControl: ReactNode networkInterfaces: MobileNetworkInterface[] selectedAddress: string | undefined onSelectedAddressChange: (address: string) => void @@ -25,7 +24,8 @@ type MobilePairingSetupSectionProps = { export function MobilePairingSetupSection({ connectionMode, - relayConnectionControl, + canGenerate = true, + connectionPathControl, networkInterfaces, selectedAddress, onSelectedAddressChange, @@ -35,60 +35,38 @@ export function MobilePairingSetupSection({ hasQrCode, onGenerateQr }: MobilePairingSetupSectionProps): React.JSX.Element { - return ( -
-

- {translate('auto.components.settings.MobilePairingSetupSection.title', 'Pair a phone')} -

-

- {connectionMode === 'automatic' - ? translate( - 'auto.components.settings.MobilePairingSetupSection.automaticDescription', - 'The pairing code includes direct access and encrypted Orca Relay fallback.' - ) - : translate( - 'auto.components.settings.MobilePairingSetupSection.localDescription', - 'The pairing code connects only through the local network address below.' - )} -

-
{relayConnectionControl}
- + const usingRelay = connectionMode === 'automatic' + const generateDisabled = loading || !selectedAddress || !canGenerate -
-
-

- {translate( - 'auto.components.settings.MobilePairingSetupSection.localSettings', - 'Local connection settings' - )} -

-

- {translate( - 'auto.components.settings.MobilePairingSetupSection.localAddressDescription', - 'Choose the LAN or private-network address that Orca Mobile can use to reach this computer directly.' - )} -

-
-
+ return ( +
+
+

+ {translate('auto.components.settings.MobilePairingSetupSection.title', 'Pair a phone')} +

+

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.overview', + 'Generate a QR code, then scan it in Orca Mobile under Pair Desktop.' + )} +

+
+ +
+

+ {translate('auto.components.settings.MobilePairingSetupSection.step1Title', 'Connection')} +

+ {connectionPathControl} +
+ +
+

+ {translate( + 'auto.components.settings.MobilePairingSetupSection.step2Title', + 'This computer’s address' + )} +

+
+

+ {usingRelay + ? translate( + 'auto.components.settings.MobilePairingSetupSection.step2RelayDescription', + 'Used for a faster direct path when nearby. Relay covers remote access.' + ) + : translate( + 'auto.components.settings.MobilePairingSetupSection.step2LocalDescription', + 'The phone must be able to reach this address on Wi‑Fi or Tailscale.' + )} +

- - - - {translate( - 'auto.components.settings.MobilePairingSetupSection.tailnet', - 'Connect with your own tailnet' - )} - - -

- {translate( - 'auto.components.settings.MobilePairingSetupSection.tailnetDescription', - 'Install Tailscale on this computer and your phone, sign in to the same tailnet, then select its 100.x.y.z address above.' +

+ - - - + +
) } diff --git a/src/renderer/src/components/settings/MobilePane.test.tsx b/src/renderer/src/components/settings/MobilePane.test.tsx index 3a7eb23eff0..2b7986f7959 100644 --- a/src/renderer/src/components/settings/MobilePane.test.tsx +++ b/src/renderer/src/components/settings/MobilePane.test.tsx @@ -1,12 +1,17 @@ // @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' + import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { _resetPairedMobileDevicesCacheForTests, type PairedMobileDevice } from '../mobile/paired-mobile-devices' +import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' type PairedDevice = PairedMobileDevice @@ -17,62 +22,351 @@ type PairedDevicesProps = { } type StoreState = { + orcaProfileAuthStatus: { state: 'connected' | 'local' } settings: { mobileAutoRestoreFitMs: number | null + mobilePairingConnectionMode?: MobilePairingConnectionMode } - updateSettings: (settings: { mobileAutoRestoreFitMs: number | null }) => void + updateSettings: (patch: Record) => Promise + recordFeatureInteraction: (feature: string) => void } -const mocks = vi.hoisted(() => ({ - latestPairedDevicesProps: null as PairedDevicesProps | null, - listDevices: vi.fn(), - listNetworkInterfaces: vi.fn(), - revokeDevice: vi.fn(), - toastError: vi.fn(), - toastSuccess: vi.fn(), - updateSettings: vi.fn() -})) +const mocks = vi.hoisted(() => { + const holder: { state: StoreState } = { state: {} as StoreState } + const useAppStore = Object.assign( + (selector: (state: StoreState) => unknown) => selector(holder.state), + { getState: () => holder.state } + ) + return { + holder, + useAppStore, + latestPairedDevicesProps: null as PairedDevicesProps | null, + getPairingQR: vi.fn(), + listDevices: vi.fn(), + listNetworkInterfaces: vi.fn(), + revokeDevice: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), + updateSettings: vi.fn() + } +}) -vi.mock('../../store', () => ({ - useAppStore: (selector: (state: StoreState) => unknown) => - selector({ - settings: { mobileAutoRestoreFitMs: null }, - updateSettings: mocks.updateSettings - }) -})) +vi.mock('@/store', () => ({ useAppStore: mocks.useAppStore })) +vi.mock('../../store', () => ({ useAppStore: mocks.useAppStore })) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess } })) +vi.mock('./mobile-pairing-device-polling', () => ({ useMobilePairingDevicePolling: vi.fn() })) -vi.mock('./MobileNetworkInterfaceSection', () => ({ - MobileNetworkInterfaceSection: () => null -})) - +// Stub the child sections so the test targets MobilePane's own connection-mode +// safety wiring (effective mode, canGenerate gate, persistence) in isolation. vi.mock('./MobilePairingSetupSection', () => ({ - MobilePairingSetupSection: () => null + MobilePairingSetupSection: (props: { + connectionMode: MobilePairingConnectionMode + canGenerate?: boolean + loading: boolean + connectionPathControl: React.ReactNode + onGenerateQr: () => void + }) => ( +
+ {props.connectionMode} + {String(props.canGenerate)} + {String(props.loading)} + {props.connectionPathControl} + {/* Mirror the real Generate gate (loading/canGenerate) so a stuck + loading flag surfaces as a disabled control the tests can catch. */} + +
+ ) +})) +vi.mock('./MobilePairingConnectionOptions', () => ({ + MobilePairingConnectionOptions: (props: { + onChange: (mode: MobilePairingConnectionMode) => void + }) => ( +
+ + +
+ ) })) - vi.mock('./MobilePairingQrSection', () => ({ - MobilePairingQrSection: () => null + MobilePairingQrSection: (props: { qrDataUrl: string | null }) => ( + {props.qrDataUrl ?? 'none'} + ) })) - -vi.mock('./MobileAutoRestoreFitSection', () => ({ - MobileAutoRestoreFitSection: () => null -})) - vi.mock('./MobilePairedDevicesSection', () => ({ MobilePairedDevicesSection: (props: PairedDevicesProps) => { mocks.latestPairedDevicesProps = props return
{props.devices.map((d) => d.deviceId).join(',')}
} })) +vi.mock('./MobileAutoRestoreFitSection', () => ({ MobileAutoRestoreFitSection: () =>
})) +vi.mock('../mobile/WindowsFirewallNotice', () => ({ WindowsFirewallNotice: () =>
})) import { MobilePane } from './MobilePane' +describe('MobilePane pairing connection mode', () => { + const getPairingQR = mocks.getPairingQR + const updateSettings = mocks.updateSettings + + beforeEach(() => { + vi.clearAllMocks() + _resetPairedMobileDevicesCacheForTests() + mocks.latestPairedDevicesProps = null + getPairingQR.mockReset().mockResolvedValue({ + available: true, + qrDataUrl: 'data:image/png;base64,qr', + pairingUrl: 'orca://pair', + endpoint: 'ws://host', + connectionMode: 'automatic' + }) + mocks.listDevices.mockReset().mockResolvedValue({ devices: [] }) + mocks.listNetworkInterfaces.mockReset().mockResolvedValue({ interfaces: [] }) + mocks.revokeDevice.mockReset().mockResolvedValue({ revoked: true }) + updateSettings.mockReset().mockResolvedValue(undefined) + mocks.holder.state = { + orcaProfileAuthStatus: { state: 'connected' }, + settings: { mobileAutoRestoreFitMs: null }, + updateSettings, + recordFeatureInteraction: vi.fn() + } + Object.defineProperty(window, 'api', { + configurable: true, + value: { + mobile: { + getPairingQR, + listDevices: mocks.listDevices, + listNetworkInterfaces: mocks.listNetworkInterfaces, + revokeDevice: mocks.revokeDevice + } + } + }) + }) + + afterEach(() => { + cleanup() + _resetPairedMobileDevicesCacheForTests() + document.body.innerHTML = '' + }) + + it('defaults to Anywhere and issues an automatic QR when signed in', async () => { + const user = userEvent.setup() + render() + expect(screen.getByTestId('mode')).toHaveTextContent('automatic') + expect(screen.getByTestId('can-generate')).toHaveTextContent('true') + + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' })) + }) + + it('keeps Anywhere selected but blocks generation when signed out', async () => { + mocks.holder.state.orcaProfileAuthStatus = { state: 'local' } + const user = userEvent.setup() + render() + expect(screen.getByTestId('mode')).toHaveTextContent('automatic') + // Why: the signed-out desktop cannot serve Relay, so Generate is gated off + // and no misleading local-only QR is minted under the Relay label. + expect(screen.getByTestId('can-generate')).toHaveTextContent('false') + expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'Generate' })) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(getPairingQR).not.toHaveBeenCalled() + }) + + it('flags an Anywhere mint that degraded to a local-only code', async () => { + getPairingQR.mockResolvedValue({ + available: true, + qrDataUrl: 'data:image/png;base64,qr', + pairingUrl: 'orca://pair#degraded', + endpoint: 'ws://host', + // Relay provisioning failed server-side; the offer encodes local-only. + connectionMode: 'local-only' + }) + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => + expect(screen.getByTestId('relay-degraded-notice')).toHaveTextContent( + 'only works on your local network' + ) + ) + + // Switching to Local network clears the mismatch along with the QR. + await user.click(screen.getByRole('button', { name: 'choose-local' })) + await waitFor(() => + expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() + ) + }) + + it('does not flag an honest Relay mint', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr')) + expect(screen.queryByTestId('relay-degraded-notice')).not.toBeInTheDocument() + }) + + it('persists the chosen path when the mode changes', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'choose-local' })) + expect(updateSettings).toHaveBeenCalledWith({ mobilePairingConnectionMode: 'local-only' }) + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + }) + + it('restores a saved local-only preference without user interaction', () => { + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingConnectionMode: 'local-only' + } + render() + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + }) + + it('discards a Relay QR that resolves after signing out mid-generate', async () => { + const user = userEvent.setup() + let resolveQr: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveQr = resolve + }) + ) + const { rerender } = render() + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' })) + + // Sign out while the Relay mint is still in flight. + mocks.holder.state.orcaProfileAuthStatus = { state: 'local' } + rerender() + + // The superseded response arrives; it must not paint a QR on a desktop that + // can no longer serve Relay. + resolveQr?.({ + available: true, + qrDataUrl: 'data:image/png;base64,relay', + pairingUrl: 'orca://relay', + endpoint: 'ws://relay' + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(screen.getByTestId('qr')).toHaveTextContent('none') + }) + + it('drops loading and re-enables Generate after signing out mid-generate', async () => { + const user = userEvent.setup() + let resolveQr: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveQr = resolve + }) + ) + const { rerender } = render() + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' })) + // The hung generate holds the spinner up. + expect(screen.getByTestId('loading')).toHaveTextContent('true') + + // Sign out while the Relay mint is still in flight; the superseded request + // must drop loading so Generate isn't wedged disabled forever. + mocks.holder.state.orcaProfileAuthStatus = { state: 'local' } + rerender() + await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')) + + // The late response resolves but must not resurrect the spinner. + resolveQr?.({ + available: true, + qrDataUrl: 'data:image/png;base64,relay', + pairingUrl: 'orca://relay', + endpoint: 'ws://relay' + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(screen.getByTestId('loading')).toHaveTextContent('false') + + // Switching to Local network re-enables Generate (no signed-in gate). + await user.click(screen.getByRole('button', { name: 'choose-local' })) + expect(screen.getByRole('button', { name: 'Generate' })).toBeEnabled() + }) + + it('drops loading after switching path mid-generate', async () => { + const user = userEvent.setup() + getPairingQR.mockImplementationOnce(() => new Promise(() => {})) + render() + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' })) + expect(screen.getByTestId('loading')).toHaveTextContent('true') + + // Switch to Local network before the mint resolves; loading must clear so + // Generate can be used again for the new path. + await user.click(screen.getByRole('button', { name: 'choose-local' })) + await waitFor(() => expect(screen.getByTestId('loading')).toHaveTextContent('false')) + expect(screen.getByRole('button', { name: 'Generate' })).toBeEnabled() + }) + + it('clears a shown QR when another window changes the saved path', async () => { + const user = userEvent.setup() + const { rerender } = render() + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(screen.getByTestId('qr')).toHaveTextContent('base64,qr')) + + // Another window persists a new path; the shared hook syncs it in without + // routing through changeConnectionMode. + mocks.holder.state.settings = { + mobileAutoRestoreFitMs: null, + mobilePairingConnectionMode: 'local-only' + } + rerender() + + await waitFor(() => expect(screen.getByTestId('mode')).toHaveTextContent('local-only')) + expect(screen.getByTestId('qr')).toHaveTextContent('none') + }) + + it('discards a QR that resolves after switching path mid-generate', async () => { + const user = userEvent.setup() + let resolveQr: ((value: Record) => void) | undefined + getPairingQR.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveQr = resolve + }) + ) + render() + await user.click(screen.getByRole('button', { name: 'Generate' })) + await waitFor(() => expect(getPairingQR).toHaveBeenCalledWith({ connectionMode: 'automatic' })) + + // Switch to Local network before the Relay mint resolves. + await user.click(screen.getByRole('button', { name: 'choose-local' })) + + resolveQr?.({ + available: true, + qrDataUrl: 'data:image/png;base64,relay', + pairingUrl: 'orca://relay', + endpoint: 'ws://relay' + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(screen.getByTestId('qr')).toHaveTextContent('none') + expect(screen.getByTestId('mode')).toHaveTextContent('local-only') + }) +}) + const mountedRoots: Root[] = [] function pairedDevice(deviceId: string): PairedDevice { @@ -105,23 +399,40 @@ async function unmountMobilePaneRoots(): Promise { describe('MobilePane', () => { beforeEach(() => { vi.clearAllMocks() - mocks.latestPairedDevicesProps = null _resetPairedMobileDevicesCacheForTests() + mocks.latestPairedDevicesProps = null + mocks.getPairingQR.mockReset().mockResolvedValue({ + available: true, + qrDataUrl: 'data:image/png;base64,qr', + pairingUrl: 'orca://pair', + endpoint: 'ws://host' + }) + mocks.listDevices.mockReset() + mocks.listNetworkInterfaces.mockReset().mockResolvedValue({ interfaces: [] }) + mocks.revokeDevice.mockReset() + mocks.updateSettings.mockReset().mockResolvedValue(undefined) + mocks.holder.state = { + orcaProfileAuthStatus: { state: 'connected' }, + settings: { mobileAutoRestoreFitMs: null }, + updateSettings: mocks.updateSettings, + recordFeatureInteraction: vi.fn() + } Object.defineProperty(window, 'api', { configurable: true, value: { mobile: { + getPairingQR: mocks.getPairingQR, listDevices: mocks.listDevices, listNetworkInterfaces: mocks.listNetworkInterfaces, revokeDevice: mocks.revokeDevice } } }) - mocks.listNetworkInterfaces.mockResolvedValue({ interfaces: [] }) }) afterEach(async () => { await unmountMobilePaneRoots() + _resetPairedMobileDevicesCacheForTests() document.body.innerHTML = '' }) diff --git a/src/renderer/src/components/settings/MobilePane.tsx b/src/renderer/src/components/settings/MobilePane.tsx index 0d001142cd6..35b0e1cbc05 100644 --- a/src/renderer/src/components/settings/MobilePane.tsx +++ b/src/renderer/src/components/settings/MobilePane.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { CircleAlert } from 'lucide-react' import { toast } from 'sonner' import { useAppStore } from '../../store' import { useMountedRef } from '@/hooks/useMountedRef' @@ -19,7 +20,11 @@ import { MobilePairingConnectionOptions } from './MobilePairingConnectionOptions import { MobilePairingSetupSection } from './MobilePairingSetupSection' import { WindowsFirewallNotice } from '../mobile/WindowsFirewallNotice' import { translate } from '@/i18n/i18n' -import type { MobilePairingConnectionMode } from '../../../../shared/mobile-pairing-connection-mode' +import { + canMintMobilePairingOffer, + type MobilePairingConnectionMode +} from '../../../../shared/mobile-pairing-connection-mode' +import { useMobilePairingConnectionMode } from '../mobile/use-mobile-pairing-connection-mode' export { getMobilePaneSearchEntries } from './mobile-pane-search' export function MobilePane(): React.JSX.Element { @@ -27,6 +32,9 @@ export function MobilePane(): React.JSX.Element { const updateSettings = useAppStore((s) => s.updateSettings) const [qrDataUrl, setQrDataUrl] = useState(null) const [pairingUrl, setPairingUrl] = useState(null) + // Mode the displayed QR actually encodes; can be 'local-only' under an + // Anywhere selection when Relay provisioning degraded server-side. + const [qrEncodedMode, setQrEncodedMode] = useState(null) const [endpoint, setEndpoint] = useState(null) const [loading, setLoading] = useState(false) const [qrEnlarged, setQrEnlarged] = useState(false) @@ -36,12 +44,23 @@ export function MobilePane(): React.JSX.Element { const [codeCopied, setCodeCopied] = useState(false) const [deviceCountAtQr, setDeviceCountAtQr] = useState(null) const signedIn = useAppStore((state) => state.orcaProfileAuthStatus?.state === 'connected') - // Why: Relay is opt-in while compatible mobile builds are limited to the - // TestFlight preview and Android APK. - const [connectionMode, setConnectionMode] = useState('local-only') + const [connectionMode, setConnectionMode] = useMobilePairingConnectionMode() const [rotateNextQr, setRotateNextQr] = useState(false) - const wasSignedInRef = useRef(signedIn) const codeCopiedResetTimerRef = useRef(null) + const wasSignedInRef = useRef(signedIn) + // Why: monotonically bumped per pairing request so a late getPairingQR + // response cannot paint a stale QR after sign-out, a mode switch, or an + // address change invalidated the request that produced it. + const pairingRequestIdRef = useRef(0) + // Tracks the mode we last acted on so the connectionMode effect can tell a + // cross-window preference sync apart from our own path change. + const handledModeRef = useRef(connectionMode) + // Latest address without stale-closure risk inside loadNetworkInterfaces. + const selectedAddressRef = useRef(selectedAddress) + // Ref mirrors of QR-visible / loading so invalidatePairing stays stable and + // cannot make loadNetworkInterfaces re-fetch on every generate. + const qrDisplayedRef = useRef(false) + const loadingRef = useRef(false) const mountedRef = useMountedRef() const { devices, @@ -49,6 +68,50 @@ export function MobilePane(): React.JSX.Element { refresh: refreshDevices } = usePairedMobileDevices({ refreshOnMount: false }) + useEffect(() => { + qrDisplayedRef.current = qrDataUrl != null + }, [qrDataUrl]) + + useEffect(() => { + loadingRef.current = loading + }, [loading]) + + // Why: an offer encodes a specific policy + endpoint. When the selection that + // produced it changes, drop any displayed QR and invalidate the in-flight + // request so a late response can't restore it; arm rotation so the next mint + // issues a fresh credential rather than the discarded pending one. + const invalidatePairing = useCallback((opts: { armRotate?: boolean } = {}): void => { + pairingRequestIdRef.current += 1 + const hadPending = qrDisplayedRef.current || loadingRef.current + setQrDataUrl(null) + setPairingUrl(null) + setQrEncodedMode(null) + setEndpoint(null) + // Why: a superseded in-flight generate no longer clears loading in its + // finally (the epoch bump skips it), so drop the spinner here or Generate + // stays disabled forever after a mid-flight path/sign-out/address change. + loadingRef.current = false + setLoading(false) + // armRotate:false for path changes — the main process rotates exactly once + // when the requested mode differs from the pending token's minted mode, so + // an extra renderer rotate would only race other windows off the new token. + if (hadPending && opts.armRotate !== false) { + setRotateNextQr(true) + } + }, []) + + // Why: a Relay QR minted while signed in must not linger on a now-signed-out + // desktop — Generate is disabled in that state. Invalidate any pending relay + // mint too, not just a displayed QR, so a late response can't paint a Relay + // code after sign-out. Anywhere stays selected. + useEffect(() => { + const wasSignedIn = wasSignedInRef.current + wasSignedInRef.current = signedIn + if (wasSignedIn && !signedIn && connectionMode === 'automatic') { + invalidatePairing() + } + }, [signedIn, connectionMode, invalidatePairing]) + const clearCodeCopiedResetTimer = useCallback((): void => { if (codeCopiedResetTimerRef.current !== null) { window.clearTimeout(codeCopiedResetTimerRef.current) @@ -71,9 +134,17 @@ export function MobilePane(): React.JSX.Element { const result = await window.api.mobile.listNetworkInterfaces() if (mountedRef.current) { setNetworkInterfaces(result.interfaces) - setSelectedAddress((currentAddress) => - selectRefreshedNetworkAddress(currentAddress, result.interfaces) + const nextAddress = selectRefreshedNetworkAddress( + selectedAddressRef.current, + result.interfaces ) + if (nextAddress !== selectedAddressRef.current) { + selectedAddressRef.current = nextAddress + setSelectedAddress(nextAddress) + // A refresh moved the active interface; invalidate so a shown QR + // can't keep encoding the previous endpoint. + invalidatePairing() + } } } catch { if (opts.notifyOnError && mountedRef.current) { @@ -90,23 +161,38 @@ export function MobilePane(): React.JSX.Element { } } }, - [mountedRef] + [mountedRef, invalidatePairing] ) const generateQR = useCallback( async (opts: { rotate?: boolean } = {}) => { + // Why: refuse signed-out Anywhere rather than degrading to a local-only QR + // under the Relay label (canMint is the shared honesty gate). + if (!canMintMobilePairingOffer({ connectionMode, signedIn })) { + return + } + const requestId = ++pairingRequestIdRef.current setLoading(true) try { const result = await window.api.mobile.getPairingQR({ ...(selectedAddress ? { address: selectedAddress } : {}), + // canMint already requires sign-in for Anywhere, so the preferred path + // is the honest encoded mode. connectionMode, ...(opts.rotate || rotateNextQr ? { rotate: true } : {}) }) + // Why: sign-out, a mode switch, or an address change bump the epoch. + // A response for a superseded request must not paint a QR that no + // longer matches the current selection. + if (requestId !== pairingRequestIdRef.current) { + return + } if (result.available) { useAppStore.getState().recordFeatureInteraction('mobile-pairing') if (mountedRef.current) { setQrDataUrl(result.qrDataUrl) setPairingUrl(result.pairingUrl) + setQrEncodedMode(result.connectionMode) setEndpoint(result.endpoint) setDeviceCountAtQr(getPairedMobileDevicesSnapshot().length) clearCodeCopiedResetTimer() @@ -125,7 +211,7 @@ export function MobilePane(): React.JSX.Element { } } } catch { - if (mountedRef.current) { + if (mountedRef.current && requestId === pairingRequestIdRef.current) { toast.error( translate( 'auto.components.settings.MobilePane.e3c427e020', @@ -134,7 +220,7 @@ export function MobilePane(): React.JSX.Element { ) } } finally { - if (mountedRef.current) { + if (mountedRef.current && requestId === pairingRequestIdRef.current) { setLoading(false) } } @@ -145,7 +231,8 @@ export function MobilePane(): React.JSX.Element { loadDevices, mountedRef, rotateNextQr, - selectedAddress + selectedAddress, + signedIn ] ) @@ -154,19 +241,40 @@ export function MobilePane(): React.JSX.Element { if (nextMode === connectionMode) { return } + // Why: remember the path so reopening Settings keeps the user's choice + // instead of snapping back to the default. + handledModeRef.current = nextMode setConnectionMode(nextMode) - if (qrDataUrl) { - // Why: a displayed code encodes the old connection policy. Hide it and - // rotate its pending credential before showing a code for the new mode. - setQrDataUrl(null) - setPairingUrl(null) - setEndpoint(null) - setRotateNextQr(true) - } + void updateSettings({ mobilePairingConnectionMode: nextMode }) + // A displayed or in-flight code encodes the old connection policy. The + // main process rotates on the mode mismatch, so don't arm a second rotate. + invalidatePairing({ armRotate: false }) }, - [connectionMode, qrDataUrl] + [connectionMode, invalidatePairing, updateSettings, setConnectionMode] ) + const handleSelectedAddressChange = useCallback( + (address: string): void => { + setSelectedAddress(address) + selectedAddressRef.current = address + // Switching endpoints: a shown QR now encodes the old address. + invalidatePairing() + }, + [invalidatePairing] + ) + + // Why: another window can persist a different path; the shared hook syncs + // connectionMode here without routing through changeConnectionMode. Treat + // that external change like a user path change so a QR for the old policy + // can't linger. No updateSettings call here — avoids a cross-window loop. + useEffect(() => { + if (connectionMode === handledModeRef.current) { + return + } + handledModeRef.current = connectionMode + invalidatePairing({ armRotate: false }) + }, [connectionMode, invalidatePairing]) + useEffect(() => { void loadNetworkInterfaces() }, [loadNetworkInterfaces]) @@ -179,14 +287,6 @@ export function MobilePane(): React.JSX.Element { } }, [devicesLoaded, loadDevices]) - useEffect(() => { - const wasSignedIn = wasSignedInRef.current - wasSignedInRef.current = signedIn - if (wasSignedIn && !signedIn) { - changeConnectionMode('local-only') - } - }, [changeConnectionMode, signedIn]) - useMobilePairingDevicePolling({ deviceCountAtQr, currentDeviceCount: devices.length, @@ -226,12 +326,13 @@ export function MobilePane(): React.JSX.Element {
} networkInterfaces={networkInterfaces} selectedAddress={selectedAddress} - onSelectedAddressChange={setSelectedAddress} + onSelectedAddressChange={handleSelectedAddressChange} refreshingNetworkInterfaces={refreshingNetworkInterfaces} onRefreshNetworkInterfaces={() => void loadNetworkInterfaces({ notifyOnError: true })} loading={loading} @@ -239,6 +340,23 @@ export function MobilePane(): React.JSX.Element { onGenerateQr={() => void generateQR({ rotate: qrDataUrl != null })} /> + {qrDataUrl != null && connectionMode === 'automatic' && qrEncodedMode === 'local-only' ? ( + // Why: an Anywhere mint can degrade server-side when Relay provisioning + // fails; say so instead of letting the Relay label overclaim the code. +
+ +

+ {translate( + 'auto.components.settings.MobilePane.relayDegradedNotice', + 'Relay couldn’t be reached — this code only works on your local network. Regenerate to try again.' + )} +

+
+ ) : null} + - - {translate('auto.components.settings.MobileRelayBetaAvailability.beta', 'Beta')} - - - - {translate( - 'auto.components.settings.MobileRelayBetaAvailability.availability', - 'Available on' - )} - - - - - - ) -} diff --git a/src/renderer/src/components/settings/MobileRelayBetaNotice.tsx b/src/renderer/src/components/settings/MobileRelayBetaNotice.tsx new file mode 100644 index 00000000000..5002689c10e --- /dev/null +++ b/src/renderer/src/components/settings/MobileRelayBetaNotice.tsx @@ -0,0 +1,12 @@ +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +// Why: Relay ships in the public builds but is still beta; keep a quiet +// qualifier wherever the Relay path is offered. +export function MobileRelayBetaNotice({ className }: { className?: string }): React.JSX.Element { + return ( +

+ {translate('auto.components.settings.MobileRelayBetaNotice.notice', 'Orca Relay is in beta.')} +

+ ) +} diff --git a/src/renderer/src/components/settings/MobileSettingsPane.tsx b/src/renderer/src/components/settings/MobileSettingsPane.tsx index a6b72d3a6c0..36e1b33e068 100644 --- a/src/renderer/src/components/settings/MobileSettingsPane.tsx +++ b/src/renderer/src/components/settings/MobileSettingsPane.tsx @@ -8,6 +8,7 @@ import { } from './mobile-settings-search' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' +import { MobileRelayBetaNotice } from './MobileRelayBetaNotice' export { getMobileSettingsPaneSearchEntries } const ORCA_IOS_APP_STORE_URL = 'https://apps.apple.com/app/orca-ide/id6766130217' @@ -29,33 +30,39 @@ export function MobileSettingsPane(): React.JSX.Element { keywords={getMobileOverviewSearchEntry().keywords} className="space-y-3 py-2" > -

- {translate( - 'auto.components.settings.MobileSettingsPane.c8491c17ef', - 'Control Orca from your phone by scanning a QR code. Get the iOS app from the' - )}{' '} - {' '} - {translate( - 'auto.components.settings.MobileSettingsPane.b0088412a1', - 'or the Android APK from' - )}{' '} - - . -

+
+

+ {translate( + 'auto.components.settings.MobileSettingsPane.installIntro', + 'Install Orca Mobile from the' + )}{' '} + + {' · '} + + {translate( + 'auto.components.settings.MobileSettingsPane.installOutro', + ', then pair below.' + )} +

+ +
void onActionAgentArgsChange: (actionId: SourceControlActionId, value: string) => void onAppendVariable: (actionId: SourceControlActionId, variable: string) => void - isSaving: boolean + /** Per-action saving state for CLI args + command template (matches global recipes). */ + savingActionIds: Partial> actionDirtyById: Record onActionDiscard: (actionId: SourceControlActionId) => void onActionSave: (actionId: SourceControlActionId) => void @@ -67,7 +68,7 @@ export function RepositorySourceControlAiActionRows({ onActionTemplateChange, onActionAgentArgsChange, onAppendVariable, - isSaving, + savingActionIds, actionDirtyById, onActionDiscard, onActionSave @@ -103,6 +104,7 @@ export function RepositorySourceControlAiActionRows({ const agentWarningText = getSourceControlActionAgentWarningText(actionId, effectiveAgent) const agentSupportText = getSourceControlActionAgentSupportText(actionId) const actionDirty = actionDirtyById[actionId] + const isSavingAction = savingActionIds[actionId] === true return (
{hasOverride ? ( -
+

{actionDirty ? translate( - 'auto.components.settings.RepositorySourceControlAiSection.e57dde9d93', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.817128d94e', 'Unsaved changes' ) : translate( - 'auto.components.settings.RepositorySourceControlAiSection.ccb07dd027', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.9d3cc627f8', 'Saved' )}

@@ -268,10 +270,10 @@ export function RepositorySourceControlAiActionRows({ variant="ghost" size="xs" onClick={() => onActionDiscard(actionId)} - disabled={isSaving} + disabled={isSavingAction} > {translate( - 'auto.components.settings.RepositorySourceControlAiSection.67b3ff5467', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.b3914ecbbc', 'Discard' )} @@ -281,15 +283,15 @@ export function RepositorySourceControlAiActionRows({ variant="secondary" size="xs" onClick={() => onActionSave(actionId)} - disabled={!actionDirty || isSaving} + disabled={!actionDirty || isSavingAction} > - {isSaving + {isSavingAction ? translate( - 'auto.components.settings.RepositorySourceControlAiSection.57e6e9d4b1', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.4f549a5fa8', 'Saving...' ) : translate( - 'auto.components.settings.RepositorySourceControlAiSection.152268c295', + 'auto.components.settings.SourceControlAiActionRecipeDefaults.d18d665e12', 'Save' )} diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx index 6a22472b2ee..e3e6e32cf6b 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiCustomCommand.tsx @@ -1,4 +1,5 @@ import type React from 'react' +import { useState } from 'react' import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types' import { Input } from '../ui/input' import { Label } from '../ui/label' @@ -12,19 +13,27 @@ import { translate } from '@/i18n/i18n' type RepositorySourceControlAiCustomCommandProps = { value: string | undefined source: SourceControlAiSettings + // onChange drafts the value locally (per keystroke); onCommit persists it (on blur / mode change). onChange: (value: string | undefined) => void + onCommit: (value: string | undefined) => void +} + +function isRepoCommand(value: string | undefined): boolean { + return typeof value === 'string' && value.trim().length > 0 } export function RepositorySourceControlAiCustomCommand({ value, source, - onChange + onChange, + onCommit }: RepositorySourceControlAiCustomCommandProps): React.JSX.Element { - // Why: value only counts as a repo command when it is a non-empty trimmed string; - // empty/nullish values make hasRepoCommand select CUSTOM_COMMAND_MODE_INHERIT - // instead of CUSTOM_COMMAND_MODE_REPO, so clearing the input switches mode. - const hasRepoCommand = typeof value === 'string' && value.trim().length > 0 - const mode = hasRepoCommand ? CUSTOM_COMMAND_MODE_REPO : CUSTOM_COMMAND_MODE_INHERIT + // Why: selecting "Repository command" with an empty global/repo value would otherwise snap the + // Select back to inherit (empty is not a repo command). Keep a local intent so the user can type. + const [forceRepoMode, setForceRepoMode] = useState(false) + const hasRepoCommand = isRepoCommand(value) + const mode = + hasRepoCommand || forceRepoMode ? CUSTOM_COMMAND_MODE_REPO : CUSTOM_COMMAND_MODE_INHERIT return (
@@ -45,13 +54,23 @@ export function RepositorySourceControlAiCustomCommand({