Merge branch 'main' into perf/single-pass-terminal-checkpoint-byte-length

This commit is contained in:
Neil
2026-07-30 01:03:04 -07:00
committed by GitHub
792 changed files with 77552 additions and 8182 deletions
+5
View File
@@ -0,0 +1,5 @@
# Product source remains owned by its normal reviewers; localization inputs need focused review.
/src/renderer/src/i18n/locales/ @brennanb2025
/config/scripts/*localization*.mjs @brennanb2025
/config/scripts/*locale*.mjs @brennanb2025
/i18next.config.ts @brennanb2025
@@ -6,6 +6,10 @@ inputs:
description: Native runtime to prepare after the script-free install (none, node, or electron).
required: false
default: none
node-version:
description: Node.js version override; defaults to the version declared in package.json.
required: false
default: ''
runs:
using: composite
@@ -17,11 +21,19 @@ runs:
run_install: false
- name: Setup Node.js
if: inputs.node-version == ''
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Setup requested Node.js
if: inputs.node-version != ''
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
- name: Validate native runtime
shell: bash
env:
+2
View File
@@ -9,6 +9,7 @@ on:
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.test.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/computer-use-modifier-safety.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
- 'config/scripts/computer-use-smoke.test.mjs'
@@ -84,6 +85,7 @@ jobs:
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/computer-use-modifier-safety.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
src/main/computer/computer-provider-lifecycle.test.ts
+10 -2
View File
@@ -62,6 +62,11 @@ jobs:
- name: Verify localization catalog
run: pnpm run verify:localization-catalog
# Why: extraction writes sorted evidence to an isolated temporary path,
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
- name: Verify localization extraction
run: pnpm run verify:localization-extraction
- name: Verify localization coverage
run: pnpm run verify:localization-coverage
@@ -179,12 +184,14 @@ jobs:
src/shared/posix-command-path-lookup.test.ts
test:
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shard_total: [16]
steps:
- name: Checkout
@@ -195,6 +202,7 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
@@ -208,7 +216,7 @@ jobs:
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
package:
name: package
+99
View File
@@ -0,0 +1,99 @@
name: Terminal IME E2E
on:
pull_request:
paths:
- '.github/workflows/terminal-ime-e2e.yml'
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
- 'tests/e2e/terminal-ime-*.ts'
workflow_dispatch:
schedule:
- cron: '30 9 * * *'
permissions:
contents: read
jobs:
linux-x11:
name: Linux X11 terminal IME
runs-on: ubuntu-22.04
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install native build and IME tools
run: >-
sudo apt-get update &&
sudo apt-get install -y
build-essential
dbus-x11
dconf-gsettings-backend
ibus
ibus-hangul
libglib2.0-bin
python3
xdotool
xfwm4
xvfb
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Use external node-gyp to avoid pnpm bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron app for E2E
run: pnpm exec electron-vite build --mode e2e
- name: Run deterministic terminal IME boundary tests
run: >-
xvfb-run --auto-servernum
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
pnpm run test:e2e --
tests/e2e/terminal-ime-exact-byte.spec.ts
--workers=1
- name: Run native IBus Hangul exact-byte tests
env:
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload terminal IME evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: terminal-ime-evidence
path: test-results/
retention-days: 7
if-no-files-found: ignore
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
---
+1
View File
@@ -21,6 +21,7 @@ type OutputChunk = Rollup.OutputChunk
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'main-thread-hang-watchdog-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
+1
View File
@@ -153,6 +153,7 @@ module.exports = {
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/main-thread-hang-watchdog-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
+23 -7
View File
@@ -52,11 +52,27 @@ Sync catalog keys after adding or removing `translate(...)` calls:
pnpm run sync:localization-catalog
```
The sync command adds missing `en.json` entries from each call's string fallback,
copies untranslated English placeholders into other locale catalogs to keep
parity, removes locale entries whose English key was deleted, and repairs
placeholder mismatches. Run the machine-translation bootstrap commands only when
refreshing real translations, not for ordinary UI copy changes.
The sync command adds missing `en.json` entries from each call's string fallback.
It never edits target catalogs: missing values remain absent and use the existing
runtime English fallback. Existing placeholder mismatches fail validation until
a localization PR fixes or retires the target entry.
Run maintained source extraction without committing a second English catalog:
```sh
pnpm run verify:localization-extraction
```
Extraction fails when a statically extracted key is absent from `en.json` or an
inline default has incompatible placeholders. Existing unreferenced English keys
and wording-only fallback drift are reported as migration debt; the permanent
bilingual translation source will reconcile them without a large disposition
database. Reviewed and stale translation state is likewise deferred to that
source rather than inferred permanently from Git history.
The legacy free-endpoint bootstrap and whole-catalog repair scripts intentionally
have no package-script entry points. Ordinary product and localization work must
not invoke tools that can overwrite an entire target catalog.
The coverage gate compares current candidates against
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
@@ -97,8 +113,8 @@ Recommended migration order:
The final gate should combine three checks:
1. Scanner coverage: no unclassified localizable candidates remain.
2. Catalog coverage: every supported locale has the same keys as English, with
matching interpolation variables.
2. Catalog correctness: existing translations have matching interpolation
variables; missing target entries are reported rather than rejected.
3. Runtime coverage: pseudo-localization and real locale smoke tests show no
obvious English leftovers or layout clipping in core screens.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
expect(triggerPaths).toEqual(
expect.arrayContaining([
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.mjs',
'config/scripts/computer-use-smoke.test.mjs',
@@ -72,6 +73,7 @@ describe('computer-use e2e workflow', () => {
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.test.mjs',
'src/main/computer/computer-provider-lifecycle.test.ts',
@@ -0,0 +1,72 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
function source(path) {
return readFileSync(join(projectDir, path), 'utf8')
}
function sourceBetween(contents, startMarker, endMarker) {
const start = contents.indexOf(startMarker)
const end = contents.indexOf(endMarker, start + startMarker.length)
if (start < 0 || end < 0) {
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
}
return contents.slice(start, end)
}
describe('computer-use modifier safety', () => {
it('uses mouse-event flags instead of held modifier keys on macOS', () => {
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
const clickInput = sourceBetween(macOS, 'static func click(', 'static func scroll(')
const mouseInput = sourceBetween(
macOS,
'private static func mouse(',
'private static func keyEvent('
)
expect(mouseInput).toContain('event.flags = flags')
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
expect(clickInput).not.toContain('down: true')
})
it('submits each modified Windows click in a closed, timed SendInput batch', () => {
const windows = source('native/computer-use-windows/runtime.ps1')
const modifiedClick = sourceBetween(
windows,
'public static void SendModifiedClick',
'private static INPUT KeyboardInput'
)
const mouseClick = sourceBetween(
windows,
'function Send-OrcaMouseClick',
'function Send-OrcaDrag'
)
expect(modifiedClick).toContain('SendInput((uint)values.Length, values')
expect(modifiedClick).toContain('SendInput((uint)releaseValues.Length, releaseValues')
expect(modifiedClick).toContain('if (sent != (uint)values.Length)')
expect(modifiedClick).toContain('releases.Add(MouseInput(mouseInput, mouseUp))')
expect(modifiedClick).not.toContain('int count')
expect(mouseClick).toMatch(
/for \(\$i = 0; \$i -lt \$clickCount; \$i\+\+\) \{\s+\[OrcaDesktopWin32\]::SendModifiedClick\(/
)
expect(mouseClick).toContain('if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }')
expect(windows).not.toContain('keybd_event')
})
it('keeps Linux modifier release in the xdotool sequence and a fallback', () => {
const linux = source('native/computer-use-linux/runtime.py')
const modifiedClick = sourceBetween(linux, 'def modified_click_at(', 'def scroll_at(')
expect(modifiedClick).toContain('command.extend(["keyup", modifier])')
expect(modifiedClick).toContain('is_wayland')
expect(modifiedClick).toContain('modified clicks require xdotool on an X11 session')
expect(modifiedClick).toContain('finally:')
expect(modifiedClick).toContain('check=False')
expect(modifiedClick).toContain('timeout=5')
expect(modifiedClick).toContain('timeout=2')
})
})
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
const projectDir = resolve(import.meta.dirname, '../..')
// Why: computer-use now ships a hybrid discovery stub, so its version-sensitive command
@@ -8,6 +9,7 @@ const projectDir = resolve(import.meta.dirname, '../..')
// installable stub projection is checked separately below.
const guidePath = join(projectDir, 'skill-guides', 'computer-use.md')
const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
describe('computer-use skill guidance', () => {
it('keeps web-app targeting on the computer-use surface', () => {
@@ -44,6 +46,15 @@ describe('computer-use skill guidance', () => {
expect(skill).toContain('`result.snapshot.treeText`')
expect(skill).not.toContain('`result.elements`')
})
it('requires atomic modifier-click actions in the source and bundled guide', () => {
expect(bundledGuide).toBeDefined()
for (const skill of [readFileSync(guidePath, 'utf8'), bundledGuide]) {
expect(skill).toContain('click --modifiers <chord>')
expect(skill).toContain('Never synthesize separate modifier-down and modifier-up commands')
}
})
})
describe('computer-use install stub', () => {
@@ -166,9 +166,11 @@ describe('electron-builder config', () => {
)
})
it('keeps the worker-thread hang watchdog inside app.asar', () => {
expect(electronBuilderConfig.asarUnpack).not.toContain(
'out/main/main-thread-hang-watchdog-entry.js'
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
it('unpacks the forked main-thread hang-watchdog entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
)
})
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
describe('localization package scripts', () => {
const scripts = JSON.parse(readFileSync('package.json', 'utf8')).scripts
it('keeps safe catalog and extraction verification available', () => {
expect(scripts['verify:localization-catalog']).toBeDefined()
expect(scripts['sync:localization-catalog']).toBeDefined()
expect(scripts['verify:localization-extraction']).toBeDefined()
})
it('does not expose whole-catalog translation and repair commands', () => {
expect(scripts['bootstrap:locale-catalog']).toBeUndefined()
expect(scripts['bootstrap:zh-catalog']).toBeUndefined()
expect(scripts['bootstrap:ko-catalog']).toBeUndefined()
expect(scripts['bootstrap:ja-catalog']).toBeUndefined()
expect(scripts['bootstrap:es-catalog']).toBeUndefined()
expect(scripts['repair:locale-catalog']).toBeUndefined()
})
})
@@ -37,13 +37,19 @@ describe('PR workflow parallelism', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
it('shards the general test suite across sixteen runners', () => {
it('shards the general test suite across Node 24 and Node 26', () => {
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
Array.from({ length: 16 }, (_, index) => index + 1)
)
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
const installStep = workflow.jobs.test.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
@@ -95,9 +101,15 @@ describe('PR workflow parallelism', () => {
const steps = dependencyAction.runs.steps
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
const requestedNodeIndex = steps.findIndex((step) => step.name === 'Setup requested Node.js')
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}')
expect(steps[requestedNodeIndex].with.cache).toBe('pnpm')
})
it('restores Electron downloads before preparing the package runtime', () => {
@@ -0,0 +1,306 @@
import { spawn, spawnSync } from 'node:child_process'
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const projectDir = path.resolve(import.meta.dirname, '../..')
const scriptPath = import.meta.filename
const insideSessionFlag = '--inside-session'
const processStopTimeoutMs = 5_000
const processKillTimeoutMs = 1_000
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function waitForExit(child) {
return new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
})
}
function processGroupMembers(processGroupId) {
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
encoding: 'utf8'
})
if (result.status !== 0) {
return []
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
async function stopOwnedProcessGroup(processGroupId) {
let members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
console.error(
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
)
try {
process.kill(-processGroupId, 'SIGTERM')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const deadline = Date.now() + processStopTimeoutMs
while (Date.now() < deadline) {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
}
try {
process.kill(-processGroupId, 'SIGKILL')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const killDeadline = Date.now() + processKillTimeoutMs
do {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
} while (Date.now() < killDeadline)
return members
}
function commandOutput(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
}
function configureHangulEngine() {
for (const [key, value] of [
['initial-input-mode', 'hangul'],
['hangul-keyboard', '2']
]) {
const result = spawnSync(
'gsettings',
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
}
}
}
async function waitForHangulEngine(ibusProcess) {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
if (ibusProcess.exitCode !== null) {
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
}
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
if (result.status === 0) {
return
}
await delay(100)
}
throw new Error('Timed out while selecting the IBus Hangul engine')
}
async function runInsideSession(evidenceDir) {
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
const ibusLogFd = openSync(ibusLogPath, 'w')
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
const evidence = {
display: process.env.DISPLAY ?? null,
ibusDaemonPid: null,
ibusGroupBeforeCleanup: [],
ibusGroupAfterCleanup: [],
playwrightPid: null,
windowManagerPid: null,
windowManagerGroupAfterCleanup: []
}
let ibusProcess
let windowManagerProcess
let testExitCode = 1
try {
configureHangulEngine()
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
detached: true,
env: process.env,
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
})
if (!windowManagerProcess.pid) {
throw new Error('xfwm4 did not return a PID')
}
evidence.windowManagerPid = windowManagerProcess.pid
console.error(`[terminal-ime] started xfwm4 PID ${windowManagerProcess.pid}`)
ibusProcess = spawn(
'ibus-daemon',
['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'],
{
detached: true,
env: process.env,
stdio: ['ignore', ibusLogFd, ibusLogFd]
}
)
if (!ibusProcess.pid) {
throw new Error('ibus-daemon did not return a PID')
}
evidence.ibusDaemonPid = ibusProcess.pid
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
await waitForHangulEngine(ibusProcess)
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
console.error(
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'initial-input-mode'
])}`
)
console.error(
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'hangul-keyboard'
])}`
)
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
const testProcess = spawn(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'run',
'test:e2e:headful',
'--workers=1',
'--',
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
],
{
cwd: projectDir,
env: {
...process.env,
ORCA_E2E_FORWARD_APP_LOGS: '1',
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
},
stdio: 'inherit'
}
)
if (!testProcess.pid) {
throw new Error('Playwright did not return a PID')
}
evidence.playwrightPid = testProcess.pid
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
testExitCode = await waitForExit(testProcess)
} finally {
if (ibusProcess?.pid) {
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
evidence.ibusGroupAfterCleanup = await stopOwnedProcessGroup(ibusProcess.pid)
}
if (windowManagerProcess?.pid) {
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
windowManagerProcess.pid
)
}
closeSync(ibusLogFd)
closeSync(windowManagerLogFd)
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
copyFileSync(
ibusLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
)
copyFileSync(
windowManagerLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
)
writeFileSync(
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
`${JSON.stringify(evidence, null, 2)}\n`
)
}
if (evidence.ibusGroupAfterCleanup.length > 0) {
throw new Error(
`Owned IBus processes survived cleanup: ${evidence.ibusGroupAfterCleanup.join('; ')}`
)
}
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
throw new Error(
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
)
}
return testExitCode
}
async function runOuter() {
if (process.platform !== 'linux') {
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
}
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
const runtimeDir = path.join(evidenceDir, 'runtime')
mkdirSync(runtimeDir, { mode: 0o700 })
mkdirSync(path.join(evidenceDir, 'config'))
mkdirSync(path.join(evidenceDir, 'cache'))
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
const sessionProcess = spawn(
'xvfb-run',
[
'--auto-servernum',
'dbus-run-session',
'--',
process.execPath,
scriptPath,
insideSessionFlag,
evidenceDir
],
{
cwd: projectDir,
detached: true,
env: {
...process.env,
GTK_IM_MODULE: 'ibus',
IBUS_ENABLE_SYNC_MODE: '1',
LANG: process.env.LANG || 'C.UTF-8',
QT_IM_MODULE: 'ibus',
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
XDG_RUNTIME_DIR: runtimeDir,
XMODIFIERS: '@im=ibus'
},
stdio: 'inherit'
}
)
if (!sessionProcess.pid) {
throw new Error('xvfb-run did not return a PID')
}
console.error(`[terminal-ime] started isolated X11 session PID ${sessionProcess.pid}`)
const exitCode = await waitForExit(sessionProcess)
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
if (remaining.length > 0) {
throw new Error(`Owned X11 session processes survived cleanup: ${remaining.join('; ')}`)
}
return exitCode
}
const insideSession = process.argv[2] === insideSessionFlag
try {
if (insideSession && !process.argv[3]) {
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
}
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
} catch (error) {
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
describe('terminal IME e2e workflow', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
)
it('runs for xterm patch and terminal IME regression changes', () => {
expect(workflow.on.pull_request.paths).toEqual(
expect.arrayContaining([
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
'src/renderer/src/components/terminal-pane/pty-connection.ts',
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
'src/renderer/src/components/terminal-pane/terminal-ime-*',
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
'tests/e2e/terminal-ime-*.ts'
])
)
})
it('installs native IBus Hangul and X11 input tools', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const installRun = runs.find((run) => run.includes('apt-get install'))
expect(installRun).toBeDefined()
expect(installRun).toContain('ibus-hangul')
expect(installRun).toContain('xdotool')
expect(installRun).toContain('xfwm4')
expect(installRun).toContain('xvfb')
expect(installRun).toContain('dbus-x11')
expect(installRun).toContain('dconf-gsettings-backend')
expect(installRun).toContain('libglib2.0-bin')
})
it('runs deterministic boundaries before the real IBus suite', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const deterministicIndex = runs.findIndex((run) =>
run.includes('terminal-ime-exact-byte.spec.ts')
)
const nativeIndex = runs.findIndex((run) => run.includes('test:e2e:terminal-ime-native'))
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
})
it('keeps IBus lifecycle scoped to owned processes', () => {
const runner = readFileSync(
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
'utf8'
)
expect(runner).toContain(
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
)
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
expect(runner).toContain("['initial-input-mode', 'hangul']")
expect(runner).toContain("['hangul-keyboard', '2']")
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
expect(runner).toMatch(
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
)
expect(runner).not.toContain("'--replace'")
expect(runner).not.toContain('killall')
expect(runner).not.toContain('pkill')
})
it('bounds blocking native input commands', () => {
const nativeSpec = readFileSync(
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
'utf8'
)
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3)
})
})
+14 -90
View File
@@ -227,44 +227,6 @@ function setCatalogEntry(catalog, key, value) {
cursor[parts.at(-1)] = value
}
function deleteCatalogEntry(catalog, key) {
const parts = key.split('.')
const stack = []
let cursor = catalog
for (const part of parts.slice(0, -1)) {
if (
typeof cursor?.[part] !== 'object' ||
cursor[part] === null ||
Array.isArray(cursor[part])
) {
return false
}
stack.push([cursor, part])
cursor = cursor[part]
}
const leafKey = parts.at(-1)
if (!Object.hasOwn(cursor, leafKey)) {
return false
}
delete cursor[leafKey]
for (let index = stack.length - 1; index >= 0; index -= 1) {
const [parent, part] = stack[index]
const child = parent[part]
if (
typeof child === 'object' &&
child !== null &&
!Array.isArray(child) &&
Object.keys(child).length === 0
) {
delete parent[part]
}
}
return true
}
function collectLocaleParityIssues(enCatalog, localeCatalog) {
const enEntries = flattenCatalogEntries(enCatalog)
const localeEntries = flattenCatalogEntries(localeCatalog)
@@ -286,30 +248,6 @@ function collectLocaleParityIssues(enCatalog, localeCatalog) {
return { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches }
}
function repairLocaleParity(enCatalog, localeCatalog) {
const { enEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
let changed = 0
for (const key of missingInLocale) {
setCatalogEntry(localeCatalog, key, enEntries.get(key))
changed += 1
}
for (const key of extraInLocale) {
if (deleteCatalogEntry(localeCatalog, key)) {
changed += 1
}
}
for (const key of interpolationMismatches) {
setCatalogEntry(localeCatalog, key, enEntries.get(key))
changed += 1
}
return changed
}
function referencesMissingFallbacks(missing) {
return missing.filter((reference) => typeof reference.fallback !== 'string')
}
@@ -344,23 +282,18 @@ function applyMissingEnglishEntries(catalog, missing) {
return changed
}
function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
const { localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
if (
missingInLocale.length > 0 ||
extraInLocale.length > 0 ||
interpolationMismatches.length > 0
) {
console.error(`Locale catalog parity failed for ${localeName}.json.`)
if (missingInLocale.length > 0) {
console.error('')
console.error(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
if (missingInLocale.length > 20) {
console.error(`...and ${missingInLocale.length - 20} more missing keys`)
}
}
// Why: feature PRs own English declarations; absent target leaves deliberately
// use i18next's existing English fallback until a localization PR supplies them.
console.log(
`${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.`
)
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
console.error(`Locale catalog validation failed for ${localeName}.json.`)
if (extraInLocale.length > 0) {
console.error('')
console.error(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
@@ -380,7 +313,7 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
return 1
}
console.log(`Verified locale parity for ${localeName}.json (${localeEntries.size} keys).`)
console.log(`Verified ${localeEntries.size} existing ${localeName}.json entries.`)
return 0
}
@@ -527,19 +460,10 @@ export async function main(root = process.cwd(), options = parseArgs(process.arg
const localeName = fileName.replace(/\.json$/, '')
const localeCatalogPath = path.join(localesDir, fileName)
const localeCatalog = JSON.parse(await fs.readFile(localeCatalogPath, 'utf8'))
if (options.fix) {
const repaired = repairLocaleParity(catalog, localeCatalog)
if (repaired > 0) {
await fs.writeFile(localeCatalogPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
console.log(`Repaired ${fileName} parity (${repaired} key update(s)).`)
}
}
const exitCode = verifyLocaleParity(catalog, localeName, localeCatalog)
const exitCode = verifyLocaleCatalog(catalog, localeName, localeCatalog)
if (exitCode !== 0) {
if (!options.fix) {
console.error('')
console.error('Run `pnpm run sync:localization-catalog` to repair locale parity.')
}
console.error('')
console.error('Fix or retire the existing target entry in a localization PR.')
return exitCode
}
}
@@ -33,7 +33,7 @@ function makeProject({ sourceText, enCatalog = {}, esCatalog = {} }) {
}
describe('verify-localization-catalog', () => {
it('bootstraps missing catalog entries from string fallbacks', async () => {
it('bootstraps English entries without fabricating target translations', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n"
@@ -45,12 +45,10 @@ describe('verify-localization-catalog', () => {
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({})
})
it('repairs stale locale keys and interpolation mismatches', async () => {
it('never overwrites mismatched translations or removes target-only entries', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
@@ -63,13 +61,29 @@ describe('verify-localization-catalog', () => {
}
})
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
auto: {
example: { greeting: 'Hola' },
stale: { removed: 'Viejo' }
}
})
})
it('accepts sparse target catalogs when existing placeholders match', async () => {
const { root } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
enCatalog: {
auto: { example: { greeting: 'Hello {{name}}', untranslated: 'English only' } }
},
esCatalog: { auto: { example: { greeting: 'Hola {{name}}' } } }
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
})
it('does not invent values for keys without string fallbacks', async () => {
const { root, localesDir } = makeProject({
sourceText:
@@ -0,0 +1,123 @@
import { execFile } from 'node:child_process'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
import { pathToFileURL } from 'node:url'
const execFileAsync = promisify(execFile)
const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
function flattenCatalog(value, prefix = '', entries = new Map()) {
if (typeof value === 'string') {
entries.set(prefix, value)
return entries
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return entries
}
for (const [key, child] of Object.entries(value)) {
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
}
return entries
}
function placeholders(value) {
return [...(value.match(PLACEHOLDER_RE) ?? [])].sort().join('|')
}
export function compareExtraction(extractedCatalog, englishCatalog) {
const extracted = flattenCatalog(extractedCatalog)
const english = flattenCatalog(englishCatalog)
const dynamicDefaults = [...extracted.entries()]
.filter(([, value]) => value.length === 0)
.map(([key]) => key)
const missingFromEnglish = [...extracted.keys()].filter((key) => !english.has(key))
const orphans = [...english.keys()].filter((key) => !extracted.has(key))
const fallbackDrift = []
const placeholderMismatches = []
for (const [key, extractedValue] of extracted) {
const englishValue = english.get(key)
if (
extractedValue.length === 0 ||
englishValue === undefined ||
englishValue === extractedValue
) {
continue
}
fallbackDrift.push(key)
if (placeholders(extractedValue) !== placeholders(englishValue)) {
placeholderMismatches.push(key)
}
}
return {
extracted,
dynamicDefaults,
missingFromEnglish,
orphans,
fallbackDrift,
placeholderMismatches
}
}
function printKeys(label, keys) {
if (keys.length === 0) {
return
}
console.error(`${label}:`)
for (const key of keys.slice(0, 20)) {
console.error(` ${key}`)
}
if (keys.length > 20) {
console.error(` ...and ${keys.length - 20} more`)
}
}
async function extractToTemporaryCatalog(root, tempDir) {
const cliPath = path.join(root, 'node_modules', 'i18next-cli', 'dist', 'esm', 'cli.js')
const outputPattern = path.join(tempDir, '{{language}}.json')
// Why: extraction output is evidence for this check, not another committed
// catalog that feature authors must keep synchronized.
await execFileAsync(process.execPath, [cliPath, 'extract', '--sync-primary', '--quiet'], {
cwd: root,
env: {
...process.env,
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
}
})
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
}
export async function main(root = process.cwd()) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'orca-i18next-extraction-'))
try {
const [extractedCatalog, englishCatalog] = await Promise.all([
extractToTemporaryCatalog(root, tempDir),
fs.readFile(path.join(root, EN_CATALOG_PATH), 'utf8').then(JSON.parse)
])
const result = compareExtraction(extractedCatalog, englishCatalog)
console.log(
`Extracted ${result.extracted.size} keys; ${result.dynamicDefaults.length} dynamic defaults are report-only, ${result.orphans.length} existing English entries are not statically referenced, and ${result.fallbackDrift.length} inline defaults differ.`
)
if (result.missingFromEnglish.length > 0 || result.placeholderMismatches.length > 0) {
printKeys('Extracted keys missing from en.json', result.missingFromEnglish)
printKeys('Extracted defaults with incompatible placeholders', result.placeholderMismatches)
return 1
}
return 0
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { compareExtraction } from './verify-localization-extraction.mjs'
describe('verify-localization-extraction', () => {
it('reports legacy drift without requiring a committed disposition database', () => {
const result = compareExtraction(
{ menu: { open: 'Open now {{name}}' } },
{ menu: { open: 'Open {{name}}', legacy: 'Legacy copy' } }
)
expect(result.orphans).toEqual(['menu.legacy'])
expect(result.fallbackDrift).toEqual(['menu.open'])
expect(result.placeholderMismatches).toEqual([])
})
it('reports dynamic defaults without treating empty extractor values as catalog copy', () => {
const result = compareExtraction(
{ menu: { dynamic: '' } },
{ menu: { dynamic: '{{count}} items' } }
)
expect(result.dynamicDefaults).toEqual(['menu.dynamic'])
expect(result.fallbackDrift).toEqual([])
expect(result.placeholderMismatches).toEqual([])
})
it('rejects undeclared keys even when their defaults are dynamic', () => {
const result = compareExtraction({ menu: { dynamic: '' } }, {})
expect(result.missingFromEnglish).toEqual(['menu.dynamic'])
})
it('rejects missing English declarations and incompatible placeholders', () => {
const result = compareExtraction(
{
menu: {
missing: 'Missing',
open: 'Open {{name}}'
}
},
{ menu: { open: 'Open {{path}}' } }
)
expect(result.missingFromEnglish).toEqual(['menu.missing'])
expect(result.placeholderMismatches).toEqual(['menu.open'])
})
})
+3
View File
@@ -9,7 +9,9 @@
"../src/main/agent-hooks/hooks-json-read.ts",
"../src/main/agent-hooks/installer-utils.ts",
"../src/main/agent-hooks/installer-utils-remote.ts",
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
@@ -68,6 +70,7 @@
"../src/main/kimi/kimi-hook-config-toml.ts",
"../src/main/openclaude/hook-service.ts",
"../src/main/rolling-file-backup.ts",
"../src/main/startup/hydrate-shell-path.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/win32-utils.ts"
],
+2
View File
@@ -15,6 +15,8 @@ export default defineConfig({
},
test: {
environment: 'node',
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
execArgv: ['--no-experimental-webstorage'],
include: [
'src/**/*.test.ts',
'src/**/*.test.tsx',
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -208,8 +208,9 @@ export const electronViteConfig: UserConfig = {
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
// without paying for another Electron process.
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
// main thread (macOS 26 AppKit scene-update deadlock) and can record
// the stall for the next launch to report.
'main-thread-hang-watchdog-entry': resolve(
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
),
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'i18next-cli'
const output =
process.env.ORCA_I18N_EXTRACTION_OUTPUT ?? 'tmp/localization-extraction/{{language}}.json'
export default defineConfig({
locales: ['en'],
extract: {
input: ['src/**/*.{js,jsx,ts,tsx,mts,cts}'],
ignore: [
'**/*.test.*',
'**/*.spec.*',
'**/__tests__/**',
'**/__snapshots__/**',
'**/assets/**'
],
output,
defaultNS: false,
functions: ['t', '*.t', 'translate', 'translateMain'],
useTranslationNames: ['useTranslation'],
sort: true,
disablePlurals: true,
removeUnusedKeys: true
}
})
+18 -25
View File
@@ -15,12 +15,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
import {
displayHostEndpoint,
endpointPort,
endpointScheme,
normalizeHostEndpoint
} from '../../../src/transport/host-endpoint'
import { displayHostEndpoint } from '../../../src/transport/host-endpoint'
import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit'
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
import type { HostProfile } from '../../../src/transport/types'
@@ -68,27 +64,24 @@ export default function EditHostScreen() {
void load()
}, [load])
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
const normalizedEndpoint = useMemo(
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
[address, fallbackPort, fallbackScheme]
const endpointEdit = useMemo(
() => (host ? resolveHostEndpointEdit(host.endpoint, address) : null),
[address, host]
)
const nameTrimmed = name.trim()
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
const endpointChanged =
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
const endpointChanged = endpointEdit?.kind === 'changed'
const canSave =
host != null &&
endpointEdit != null &&
nameTrimmed.length > 0 &&
normalizedEndpoint.ok &&
endpointEdit.kind !== 'invalid' &&
(nameChanged || endpointChanged) &&
!saving
async function handleSave() {
if (!host || !hostId || savingRef.current) {
if (!host || !hostId || !endpointEdit || savingRef.current) {
return
}
const nextName = name.trim()
@@ -96,14 +89,14 @@ export default function EditHostScreen() {
setSaveError('Enter a name.')
return
}
if (!normalizedEndpoint.ok) {
setSaveError(normalizedEndpoint.error)
if (endpointEdit.kind === 'invalid') {
setSaveError(endpointEdit.error)
return
}
const willRename = nextName !== host.name
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
if (!willRename && !willUpdateEndpoint) {
const nextEndpoint = endpointEdit.kind === 'changed' ? endpointEdit.endpoint : undefined
if (!willRename && nextEndpoint === undefined) {
router.back()
return
}
@@ -117,7 +110,7 @@ export default function EditHostScreen() {
// other, and a host removed mid-edit throws instead of no-oping.
await updateHostNameAndEndpoint(host.id, {
...(willRename ? { name: nextName } : {}),
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
...(nextEndpoint !== undefined ? { endpoint: nextEndpoint } : {})
})
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
@@ -140,7 +133,7 @@ export default function EditHostScreen() {
setSaving(false)
router.back()
if (willUpdateEndpoint) {
if (nextEndpoint !== undefined) {
// Why: reconnect is a follow-on side effect of a save that already
// committed — its failure or a hang must not be reported as a save
// failure or block navigating back.
@@ -247,12 +240,12 @@ export default function EditHostScreen() {
(or 6768).
</Text>
{normalizedEndpoint.ok ? (
{endpointEdit == null ? null : endpointEdit.kind !== 'invalid' ? (
<Text style={styles.preview} numberOfLines={2}>
Connects to {normalizedEndpoint.endpoint}
Connects to {endpointEdit.endpoint}
</Text>
) : address.trim().length > 0 ? (
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
<Text style={styles.previewError}>{endpointEdit.error}</Text>
) : null}
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
+3 -1
View File
@@ -12,6 +12,7 @@ import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
import { handleMockGitRequest } from './mock-server-git-state'
import { handleMockAccountRequest } from './mock-server-account-rpc'
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture'
import {
createMockTerminals,
FAKE_SCROLLBACK,
@@ -124,7 +125,8 @@ export function handleRequest(
handleMockGitRequest(request, respond, success) ||
handleMockFilePreviewRequest(request, respond, success, error) ||
handleMockAccountRequest(request, respond, success, error) ||
handleMockNativeChatRequest(request, respond, success, error, ws)
handleMockNativeChatRequest(request, respond, success, error, ws) ||
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId)
) {
return
}
@@ -0,0 +1,69 @@
import { randomUUID } from 'node:crypto'
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
// Why: the client's snapshot-acceptance gate keys on the publisher epoch, so it
// must stay stable for the process and change on restart like a real publisher —
// hence a uuid, not a clock read two restarts could land on.
// The `mobile-local:` prefix is reserved for phone-local writes — never use it.
const PUBLICATION_EPOCH = `mock-server:${randomUUID()}`
const GROUP_ID = 'group-1'
const PARENT_TAB_ID = 'tab-1'
// The host only ever publishes terminal-layout UUIDs here; pane-key parsing
// rejects any other shape, so a placeholder would mask pane-attribution bugs.
const LEAF_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
// The host publishes terminal surfaces as `${parentTabId}::${leafId}`.
const SURFACE_TAB_ID = `${PARENT_TAB_ID}::${LEAF_ID}`
/** One ready terminal tab bound to the `term-1` fixture. Mirrors the full
* `session.tabs.list` contract so mock-server repros of tab, split-pane, and
* pane-attribution bugs aren't shape-incomplete. */
function createMockSessionTabs(worktreeId: string): RuntimeMobileSessionTabsResult {
return {
worktree: worktreeId,
publicationEpoch: PUBLICATION_EPOCH,
snapshotVersion: 1,
activeGroupId: GROUP_ID,
activeTabId: SURFACE_TAB_ID,
activeTabType: 'terminal',
// Groups track top-level tabs, so they carry parentTabId, not surface ids.
tabGroups: [
{
id: GROUP_ID,
activeTabId: PARENT_TAB_ID,
tabOrder: [PARENT_TAB_ID],
recentTabIds: [PARENT_TAB_ID]
}
],
tabs: [
{
type: 'terminal',
id: SURFACE_TAB_ID,
title: 'zsh',
parentTabId: PARENT_TAB_ID,
leafId: LEAF_ID,
status: 'ready',
terminal: 'term-1',
isActive: true
}
]
}
}
/** Default session-tabs backend: without it the session screen hangs on
* 'Loading tabs'. Returns false for methods it does not own. */
export function handleMockSessionTabsRequest(
request: RpcRequest,
respond: (response: RpcResponse) => void,
success: (id: string, result: unknown, streaming?: boolean) => RpcResponse,
// Shared with `terminal.list` so both surfaces agree on which worktree an
// absent or `id:`-prefixed selector means.
resolveWorktreeId: (selector: unknown) => string | undefined
): boolean {
if (request.method !== 'session.tabs.list') {
return false
}
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
respond(success(request.id, createMockSessionTabs(worktreeId)))
return true
}
+5
View File
@@ -149,8 +149,13 @@ describe('edit host handleSave', () => {
})
it('rename-only save updates only the name and does not reconnect', async () => {
const storedEndpoint = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
dependencies.loadHosts.mockResolvedValueOnce([{ ...HOST_FIXTURE, endpoint: storedEndpoint }])
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Address', ' wss://%64esk.example.com:443 ')
setFieldValue(renderer, 'Name', 'Home Desk')
expect(findText(renderer, `Connects to ${storedEndpoint}`)).toBe(true)
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { WebSocket } from 'ws'
import {
handleRequest,
type RpcRequest,
type RpcResponse
} from '../scripts/mock-server-rpc-handlers'
function callRpc(method: string, params?: Record<string, unknown>): RpcResponse {
let response: RpcResponse | undefined
const request: RpcRequest = { id: 'request-1', method, ...(params ? { params } : {}) }
handleRequest(
request,
(nextResponse) => {
response = nextResponse
},
{} as WebSocket
)
expect(response).toBeDefined()
return response!
}
function listSessionTabs(worktree: string): RpcResponse {
return callRpc('session.tabs.list', { worktree })
}
describe('mock server session tabs fixture', () => {
it('returns a contract-complete terminal surface for the requested worktree', () => {
const response = listSessionTabs('id:repo-1::worktree-1')
expect(response.result).toEqual({
worktree: 'repo-1::worktree-1',
publicationEpoch: expect.stringMatching(/^mock-server:/),
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
activeTabType: 'terminal',
tabGroups: [
{
id: 'group-1',
activeTabId: 'tab-1',
tabOrder: ['tab-1'],
recentTabIds: ['tab-1']
}
],
tabs: [
{
type: 'terminal',
id: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
title: 'zsh',
parentTabId: 'tab-1',
leafId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
status: 'ready',
terminal: 'term-1',
isActive: true
}
]
})
})
it('passes a bare worktree selector through unprefixed', () => {
const response = listSessionTabs('repo-1::worktree-1')
expect((response.result as { worktree: string }).worktree).toBe('repo-1::worktree-1')
})
it('falls back to the same worktree terminal.list uses when no selector is sent', () => {
const terminals = callRpc('terminal.list').result as {
terminals: { worktreeId: string }[]
}
const expected = terminals.terminals[0]?.worktreeId
expect(expected).toBeTruthy()
const tabs = callRpc('session.tabs.list').result as { worktree: string }
expect(tabs.worktree).toBe(expected)
})
})
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import { displayHostEndpoint } from './host-endpoint'
import { resolveHostEndpointEdit } from './host-endpoint-edit'
function persistedAfterEdit(stored: string, input = displayHostEndpoint(stored)) {
const edit = resolveHostEndpointEdit(stored, input)
return edit.kind === 'changed' ? edit.endpoint : stored
}
describe('resolveHostEndpointEdit', () => {
it.each([
['implicit secure DNS proxy', 'wss://desk.example.com'],
['explicit secure default', 'wss://desk.example.com:443'],
['explicit secure custom', 'wss://desk.example.com:8443'],
['implicit secure IPv6 proxy', 'wss://[2001:db8::1]'],
['explicit secure IPv6 default', 'wss://[2001:db8::1]:443'],
['implicit LAN websocket', 'ws://desk.local'],
['explicit websocket default', 'ws://desk.local:80'],
['explicit websocket custom', 'ws://desk.local:6768'],
['secure path-routed proxy', 'wss://desk.example.com/orca'],
['secure query-routed proxy', 'wss://desk.example.com/orca?route=runtime'],
['legacy bare hostname', 'desk.local'],
['legacy bare host and port', 'desk.local:7777'],
['legacy unparsable endpoint', 'not-a-url']
])('preserves an untouched %s endpoint', (_label, stored) => {
const edit = resolveHostEndpointEdit(stored, displayHostEndpoint(stored))
expect(edit).toEqual({ kind: 'unchanged', endpoint: stored })
expect(persistedAfterEdit(stored)).toBe(stored)
})
it.each([
['wss://old.example.com', 'new.example.com', 'wss://new.example.com:443'],
['wss://old.example.com:8443', 'new.example.com', 'wss://new.example.com:8443'],
['ws://old.local', 'new.local', 'ws://new.local:6768'],
['ws://old.local:80', 'new.local', 'ws://new.local:80'],
['wss://[2001:db8::1]', '[2001:db8::2]', 'wss://[2001:db8::2]:443']
])('uses the current endpoint semantics for an address edit', (stored, input, expected) => {
const edit = resolveHostEndpointEdit(stored, input)
expect(edit).toEqual({ kind: 'changed', endpoint: expected })
})
it.each([
['surrounding whitespace', ' desk.example.com '],
['hostname case', 'DESK.EXAMPLE.COM'],
['explicit default port', 'desk.example.com:443'],
['percent-encoded hostname', 'wss://%64esk.example.com:443']
])('preserves routed endpoint bytes for equivalent %s', (_label, input) => {
const stored = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
expect(resolveHostEndpointEdit(stored, input)).toEqual({
kind: 'unchanged',
endpoint: stored
})
})
it('preserves the hidden route when the authority changes', () => {
expect(
resolveHostEndpointEdit('wss://old.example.com/%6Fruntime?route=%72ed', 'new.example.com')
).toEqual({
kind: 'changed',
endpoint: 'wss://new.example.com:443/%6Fruntime?route=%72ed'
})
})
it('discriminates invalid input without a candidate endpoint', () => {
expect(
resolveHostEndpointEdit('wss://desk.example.com/orca', 'https://desk.example.com')
).toEqual({
kind: 'invalid',
error: 'Use ws:// or wss:// (or host:port).'
})
})
it('stays stable through repeated name-only edits', () => {
const stored = 'wss://desk.example.com/orca?route=runtime'
const once = persistedAfterEdit(stored)
expect(once).toBe(stored)
expect(persistedAfterEdit(once)).toBe(stored)
})
})
@@ -0,0 +1,57 @@
import {
displayHostEndpoint,
endpointPort,
endpointScheme,
normalizeHostEndpoint
} from './host-endpoint'
export type HostEndpointEditResolution =
| { kind: 'unchanged'; endpoint: string }
| { kind: 'changed'; endpoint: string }
| { kind: 'invalid'; error: string }
export function resolveHostEndpointEdit(
storedEndpoint: string,
input: string
): HostEndpointEditResolution {
const displayedEndpoint = displayHostEndpoint(storedEndpoint)
if (input.trim() === displayedEndpoint) {
return { kind: 'unchanged', endpoint: storedEndpoint }
}
const fallbackScheme = endpointScheme(storedEndpoint)
const fallbackPort =
endpointPort(storedEndpoint) ?? (fallbackScheme === 'wss' ? '443' : undefined)
const options = { fallbackPort, fallbackScheme }
const normalizedInput = normalizeHostEndpoint(input, options)
if (!normalizedInput.ok) {
return { kind: 'invalid', error: normalizedInput.error }
}
const normalizedDisplay = normalizeHostEndpoint(displayedEndpoint, options)
if (
sameEndpointAuthority(normalizedInput.endpoint, storedEndpoint) ||
(normalizedDisplay.ok &&
sameEndpointAuthority(normalizedInput.endpoint, normalizedDisplay.endpoint))
) {
return { kind: 'unchanged', endpoint: storedEndpoint }
}
return {
kind: 'changed',
endpoint: normalizedInput.endpoint + endpointRouteSuffix(storedEndpoint)
}
}
function sameEndpointAuthority(left: string, right: string): boolean {
try {
return new URL(left).origin === new URL(right).origin
} catch {
return false
}
}
function endpointRouteSuffix(endpoint: string): string {
const match = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]*(.*)$/.exec(endpoint)
return match?.[1] ?? ''
}
+46 -3
View File
@@ -822,19 +822,60 @@ def require_non_empty_string(value, name):
return str(value)
def click_at(x, y, button, count):
def click_at(x, y, button, count, modifiers=None):
button = (button or "left").lower()
buttons = {"left": ("b1p", "b1r"), "right": ("b3p", "b3r"), "middle": ("b2p", "b2r")}
if button not in buttons:
raise RuntimeError(f"unsupported mouse button: {button}")
parsed_count = require_positive_integer(1 if count is None else count, "click_count")
modifier_keys = click_modifier_keys(modifiers)
if modifier_keys:
modified_click_at(x, y, button, parsed_count, modifier_keys)
return
down, up = buttons[button]
for _ in range(require_positive_integer(1 if count is None else count, "click_count")):
for _ in range(parsed_count):
Atspi.generate_mouse_event(round(x), round(y), "abs")
Atspi.generate_mouse_event(round(x), round(y), down)
time.sleep(0.03)
Atspi.generate_mouse_event(round(x), round(y), up)
def click_modifier_keys(raw):
if raw is None:
return []
aliases = {
"ctrl": "ctrl", "control": "ctrl", "cmdorctrl": "ctrl", "commandorcontrol": "ctrl",
"shift": "shift", "alt": "alt", "option": "alt",
"meta": "super", "super": "super", "win": "super", "cmd": "super", "command": "super",
}
parts = [part.strip().lower() for part in str(raw).split("+")]
if not parts or any(not part or part not in aliases for part in parts):
raise RuntimeError("click modifiers require modifier keys only")
return list(dict.fromkeys(aliases[part] for part in parts))
def modified_click_at(x, y, button, count, modifier_keys):
xdotool = shutil.which("xdotool")
is_wayland = os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
if not xdotool or is_wayland:
raise RuntimeError("modified clicks require xdotool on an X11 session")
button_number = {"left": "1", "middle": "2", "right": "3"}[button]
command = [xdotool, "mousemove", "--sync", str(round(x)), str(round(y))]
for modifier in modifier_keys:
command.extend(["keydown", modifier])
command.extend(["click", "--repeat", str(count), "--delay", "35", button_number])
for modifier in reversed(modifier_keys):
command.extend(["keyup", modifier])
try:
subprocess.run(command, check=True, timeout=5)
finally:
subprocess.run(
[xdotool, *[item for modifier in reversed(modifier_keys) for item in ("keyup", modifier)]],
check=False,
timeout=2,
)
def scroll_at(x, y, direction, pages):
if direction is None or str(direction).strip() == "":
raise RuntimeError("direction is required")
@@ -1024,12 +1065,14 @@ def run_operation(operation):
if operation.get("click_count") is not None
else 1
)
handled = operation.get("mouse_button", "left") == "left" and click_count <= 1 and perform_action(node, preferred)
has_modifiers = bool(str(operation.get("modifiers") or "").strip())
handled = not has_modifiers and operation.get("mouse_button", "left") == "left" and click_count <= 1 and perform_action(node, preferred)
if not handled:
click_at(
*screen_point(bounds, saved, operation.get("x"), operation.get("y"), node),
operation.get("mouse_button", "left"),
click_count,
operation.get("modifiers"),
)
action = {"path": "synthetic", "actionName": None, "fallbackReason": "actionUnsupported"}
else:
@@ -729,12 +729,13 @@ final class Provider {
let snapshot = try currentSnapshot(params: params)
let button = params["mouseButton"]?.string ?? "left"
let count = try positiveInteger(params["clickCount"]?.number, defaultValue: 1, name: "clickCount")
let modifiers = try KeyMap.parseModifiers(params["modifiers"]?.string)
// Why: agents expect a click into a target app to make the next
// keyboard action safe, even when the click uses an AX action path.
recoverWindow(snapshot.app)
if let elementIndex = try optionalInteger(params, "elementIndex") {
let record = try element(snapshot, elementIndex)
if count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
if modifiers.isEmpty, count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
return actionMetadata(path: "accessibility", actionName: actionName)
}
if let point = center(record.localFrame, in: snapshot.windowBounds) {
@@ -742,7 +743,8 @@ final class Provider {
pid: snapshot.app.pid,
at: point,
button: mouseButton(button),
count: count
count: count,
modifiers: modifiers
)
return actionMetadata(path: "synthetic", fallbackReason: "actionUnsupported")
}
@@ -753,7 +755,8 @@ final class Provider {
pid: snapshot.app.pid,
at: point,
button: mouseButton(button),
count: count
count: count,
modifiers: modifiers
)
return actionMetadata(path: "synthetic")
}
@@ -2282,14 +2285,23 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? {
}
private enum Input {
static func click(pid: pid_t, at point: CGPoint, button: MouseButton, count: Int) throws {
static func click(
pid: pid_t,
at point: CGPoint,
button: MouseButton,
count: Int,
modifiers: [KeyModifier]
) throws {
guard let source = CGEventSource(stateID: .combinedSessionState) else {
throw ProviderError.coded("accessibility_error", "failed to create event source")
}
let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in
result.insert(modifier.flag)
}
for _ in 0..<max(count, 1) {
try mouse(.mouseMoved, source: source, point: point, button: button.cgButton, pid: pid)
try mouse(button.downEvent, source: source, point: point, button: button.cgButton, pid: pid)
try mouse(button.upEvent, source: source, point: point, button: button.cgButton, pid: pid)
try mouse(.mouseMoved, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
try mouse(button.downEvent, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
try mouse(button.upEvent, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
}
}
@@ -2343,16 +2355,20 @@ private enum Input {
static func pressKey(_ key: String, pid: pid_t) throws {
let parsed = try KeyMap.parse(key)
var flags = CGEventFlags()
var pressedModifiers: [KeyModifier] = []
defer {
for modifier in pressedModifiers.reversed() {
flags.remove(modifier.flag)
try? keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
}
}
for modifier in parsed.modifiers {
flags.insert(modifier.flag)
try keyEvent(modifier.keyCode, down: true, flags: flags, pid: pid)
pressedModifiers.append(modifier)
}
try keyEvent(parsed.keyCode, down: true, flags: flags, pid: pid)
try keyEvent(parsed.keyCode, down: false, flags: flags, pid: pid)
for modifier in parsed.modifiers.reversed() {
try keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
flags.remove(modifier.flag)
}
}
static func pasteText(_ text: String, pid: pid_t) throws {
@@ -2377,10 +2393,18 @@ private enum Input {
try pressKey("cmd+v", pid: pid)
}
private static func mouse(_ type: CGEventType, source: CGEventSource, point: CGPoint, button: CGMouseButton, pid: pid_t) throws {
private static func mouse(
_ type: CGEventType,
source: CGEventSource,
point: CGPoint,
button: CGMouseButton,
flags: CGEventFlags = [],
pid: pid_t
) throws {
guard let event = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: point, mouseButton: button) else {
throw ProviderError.coded("accessibility_error", "failed to create mouse event")
}
event.flags = flags
event.postToPid(pid)
}
@@ -2497,16 +2521,9 @@ private enum KeyMap {
var modifiers: [KeyModifier] = []
var keyName: String?
for part in parts {
switch part {
case "cmd", "command", "meta", "super", "cmdorctrl", "commandorcontrol":
modifiers.append(KeyModifier(keyCode: 55, flag: .maskCommand))
case "ctrl", "control":
modifiers.append(KeyModifier(keyCode: 59, flag: .maskControl))
case "alt", "option":
modifiers.append(KeyModifier(keyCode: 58, flag: .maskAlternate))
case "shift":
modifiers.append(KeyModifier(keyCode: 56, flag: .maskShift))
default:
if let modifier = modifier(part) {
modifiers.append(modifier)
} else {
keyName = part
}
}
@@ -2516,6 +2533,38 @@ private enum KeyMap {
return ParsedKey(keyCode: keyCode, modifiers: modifiers)
}
static func parseModifiers(_ spec: String?) throws -> [KeyModifier] {
guard let spec else {
return []
}
let parts = spec.split(separator: "+", omittingEmptySubsequences: false)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
guard !parts.isEmpty, !parts.contains(where: \.isEmpty) else {
throw ProviderError.coded("invalid_argument", "click modifiers require modifier keys only")
}
return try parts.map { part in
guard let modifier = modifier(part) else {
throw ProviderError.coded("invalid_argument", "unsupported click modifier '\(part)'")
}
return modifier
}
}
private static func modifier(_ part: String) -> KeyModifier? {
switch part {
case "cmd", "command", "meta", "super", "win", "cmdorctrl", "commandorcontrol":
return KeyModifier(keyCode: 55, flag: .maskCommand)
case "ctrl", "control":
return KeyModifier(keyCode: 59, flag: .maskControl)
case "alt", "option":
return KeyModifier(keyCode: 58, flag: .maskAlternate)
case "shift":
return KeyModifier(keyCode: 56, flag: .maskShift)
default:
return nil
}
}
private static let codes: [String: CGKeyCode] = [
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9,
"b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17, "1": 18, "2": 19,
@@ -18,4 +18,32 @@ final class AgentEntrypointSourceSafetyTests: XCTestCase {
XCTAssertFalse(source.contains("unlink(tokenPath)"))
XCTAssertFalse(source.contains("unlink(socketPath)"))
}
func testSyntheticModifiersHaveGuaranteedReleaseAndModifiedClicksUseFlags() throws {
let source = try agentEntrypointSource()
XCTAssertTrue(source.contains("var pressedModifiers: [KeyModifier] = []"))
XCTAssertTrue(source.contains(
"""
defer {
for modifier in pressedModifiers.reversed() {
flags.remove(modifier.flag)
try? keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
"""
))
XCTAssertTrue(source.contains("event.flags = flags\n event.postToPid(pid)"))
}
private func agentEntrypointSource() throws -> String {
let testFile = URL(fileURLWithPath: #filePath)
let packageRoot = testFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let mainPath = packageRoot
.appendingPathComponent("Sources")
.appendingPathComponent("OrcaComputerUseMacOS")
.appendingPathComponent("main.swift")
return try String(contentsOf: mainPath, encoding: .utf8)
}
}
+113 -7
View File
@@ -16,6 +16,7 @@ Add-Type -AssemblyName System.Windows.Forms
Add-Type -TypeDefinition @"
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
public static class OrcaDesktopWin32 {
@@ -33,6 +34,39 @@ public static class OrcaDesktopWin32 {
public int Y;
}
[StructLayout(LayoutKind.Sequential)]
public struct INPUT {
public uint type;
public INPUTUNION data;
}
[StructLayout(LayoutKind.Explicit)]
public struct INPUTUNION {
[FieldOffset(0)]
public MOUSEINPUT mouse;
[FieldOffset(0)]
public KEYBDINPUT keyboard;
}
[StructLayout(LayoutKind.Sequential)]
public struct MOUSEINPUT {
public int dx;
public int dy;
public uint mouseData;
public uint flags;
public uint time;
public UIntPtr extraInfo;
}
[StructLayout(LayoutKind.Sequential)]
public struct KEYBDINPUT {
public ushort virtualKey;
public ushort scanCode;
public uint flags;
public uint time;
public UIntPtr extraInfo;
}
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
@@ -56,6 +90,54 @@ public static class OrcaDesktopWin32 {
[DllImport("user32.dll")]
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, int dwData, UIntPtr dwExtraInfo);
[DllImport("user32.dll")]
public static extern uint SendInput(uint count, INPUT[] inputs, int size);
public static void SendModifiedClick(byte[] modifiers, uint mouseDown, uint mouseUp) {
const uint keyboardInput = 1;
const uint mouseInput = 0;
const uint keyUp = 0x0002;
var inputs = new List<INPUT>();
foreach (var modifier in modifiers) {
inputs.Add(KeyboardInput(keyboardInput, modifier, 0));
}
inputs.Add(MouseInput(mouseInput, mouseDown));
inputs.Add(MouseInput(mouseInput, mouseUp));
for (var index = modifiers.Length - 1; index >= 0; index--) {
inputs.Add(KeyboardInput(keyboardInput, modifiers[index], keyUp));
}
var values = inputs.ToArray();
var sent = SendInput((uint)values.Length, values, Marshal.SizeOf(typeof(INPUT)));
if (sent != (uint)values.Length) {
var releases = new List<INPUT>();
releases.Add(MouseInput(mouseInput, mouseUp));
for (var index = modifiers.Length - 1; index >= 0; index--) {
releases.Add(KeyboardInput(keyboardInput, modifiers[index], keyUp));
}
var releaseValues = releases.ToArray();
SendInput((uint)releaseValues.Length, releaseValues, Marshal.SizeOf(typeof(INPUT)));
throw new InvalidOperationException("SendInput did not complete the modified click");
}
}
private static INPUT KeyboardInput(uint type, byte virtualKey, uint flags) {
return new INPUT {
type = type,
data = new INPUTUNION {
keyboard = new KEYBDINPUT { virtualKey = virtualKey, flags = flags }
}
};
}
private static INPUT MouseInput(uint type, uint flags) {
return new INPUT {
type = type,
data = new INPUTUNION {
mouse = new MOUSEINPUT { flags = flags }
}
};
}
}
"@
@@ -912,7 +994,7 @@ function Get-OrcaElementScreenPoint($Element) {
$null
}
function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count) {
function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count, [string]$Modifiers) {
[void][OrcaDesktopWin32]::SetForegroundWindow($WindowHandle)
[void][OrcaDesktopWin32]::SetCursorPos($ScreenX, $ScreenY)
$buttonName = if ([string]::IsNullOrWhiteSpace($Button)) { "left" } else { $Button.ToLowerInvariant() }
@@ -923,10 +1005,23 @@ function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY
default { throw "unsupported mouse button: $Button" }
}
for ($i = 0; $i -lt (Get-OrcaPositiveInteger $Count "click_count"); $i++) {
[OrcaDesktopWin32]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 35
[OrcaDesktopWin32]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
$modifierKeys = @(Get-OrcaClickModifierVirtualKeys $Modifiers)
$clickCount = Get-OrcaPositiveInteger $Count "click_count"
if ($modifierKeys.Count -eq 0) {
for ($i = 0; $i -lt $clickCount; $i++) {
[OrcaDesktopWin32]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 35
[OrcaDesktopWin32]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
}
return
}
for ($i = 0; $i -lt $clickCount; $i++) {
[OrcaDesktopWin32]::SendModifiedClick(
[byte[]]$modifierKeys,
[uint32]$down,
[uint32]$up
)
if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }
}
}
@@ -990,6 +1085,16 @@ function Get-OrcaModifierVirtualKey([string]$Modifier) {
}
}
function Get-OrcaClickModifierVirtualKeys([string]$Modifiers) {
if ([string]::IsNullOrWhiteSpace($Modifiers)) { return @() }
$parts = @($Modifiers.Split("+") | ForEach-Object { $_.Trim() })
$emptyParts = @($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) })
if ($parts.Count -eq 0 -or $emptyParts.Count -gt 0) {
throw "Click modifiers require modifier keys only"
}
@($parts | ForEach-Object { Get-OrcaModifierVirtualKey $_ })
}
function Send-OrcaHotkey([IntPtr]$WindowHandle, [string]$KeySpec) {
$parts = @($KeySpec.Split("+") | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($parts.Count -eq 0) { throw "Unsupported key: $KeySpec" }
@@ -1106,13 +1211,14 @@ function Invoke-OrcaOperation($Operation) {
Restore-OrcaWindow $process
$handledByPattern = $false
$clickCount = Get-OrcaPositiveInteger $Operation.click_count "click_count"
if ($null -ne $element -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and $clickCount -le 1) {
$hasModifiers = -not [string]::IsNullOrWhiteSpace([string]$Operation.modifiers)
if (-not $hasModifiers -and $null -ne $element -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and $clickCount -le 1) {
$handledByPattern = Invoke-OrcaPrimaryAction $element
}
if (-not $handledByPattern) {
$point = Get-OrcaElementScreenPoint $element
if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame }
Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button $clickCount
Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button $clickCount $Operation.modifiers
$action = [pscustomobject]@{ path = "synthetic"; actionName = $null; fallbackReason = "actionUnsupported" }
} else {
$action = [pscustomobject]@{ path = "accessibility"; actionName = "primaryAction"; fallbackReason = $null }
+5 -7
View File
@@ -11,7 +11,7 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
@@ -60,12 +60,7 @@
"verify:cli-bin": "node config/scripts/verify-cli-bin.mjs",
"verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs",
"sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix",
"bootstrap:locale-catalog": "node config/scripts/bootstrap-locale-catalog.mjs",
"bootstrap:zh-catalog": "node config/scripts/bootstrap-zh-catalog.mjs",
"bootstrap:ko-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale ko",
"bootstrap:ja-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale ja",
"bootstrap:es-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale es",
"repair:locale-catalog": "node config/scripts/repair-locale-catalog.mjs",
"verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs",
"verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check",
"audit:localization": "node config/scripts/audit-localization-coverage.mjs",
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs",
@@ -105,11 +100,13 @@
"win-crash-survival-e2e": "node tools/win-crash-survival-e2e/run.mjs",
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
"bench:worktree-deletion": "node tools/benchmarks/worktree-deletion-dev-bench.mjs",
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
"bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-reveal-bench.mjs",
@@ -197,6 +194,7 @@
"happy-dom": "^20.9.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"i18next-cli": "1.65.0",
"katex": "^0.16.45",
"lint-staged": "^16.4.0",
"lowlight": "^3.3.0",
+507 -41
View File
@@ -19,7 +19,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3
hash: 082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@@ -46,7 +46,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -206,25 +206,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
version: 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -248,7 +248,7 @@ importers:
version: 26.15.3(dmg-builder@26.15.3)
electron-vite:
specifier: ^5.0.0
version: 5.0.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
version: 5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
emoji-picker-react:
specifier: ^4.19.1
version: 4.19.1(react@19.2.7)
@@ -264,6 +264,9 @@ importers:
husky:
specifier: ^9.1.7
version: 9.1.7
i18next-cli:
specifier: 1.65.0
version: 1.65.0(@types/node@25.9.5)(react-dom@19.2.7(react@19.2.7))(typescript@7.0.2)
katex:
specifier: ^0.16.45
version: 0.16.45
@@ -575,6 +578,12 @@ packages:
'@chevrotain/types@11.1.2':
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
'@croct/json5-parser@0.2.2':
resolution: {integrity: sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw==}
'@croct/json@2.1.0':
resolution: {integrity: sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==}
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@@ -867,6 +876,15 @@ packages:
resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
'@inquirer/checkbox@5.2.1':
resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/confirm@6.1.1':
resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
@@ -885,10 +903,100 @@ packages:
'@types/node':
optional: true
'@inquirer/editor@5.2.2':
resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/expand@5.1.1':
resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/external-editor@3.0.3':
resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@2.0.7':
resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
'@inquirer/input@5.1.2':
resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/number@4.1.1':
resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/password@5.1.1':
resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/prompts@8.5.2':
resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/rawlist@5.3.1':
resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/search@4.2.1':
resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/select@5.2.1':
resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/type@4.0.7':
resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
@@ -2429,6 +2537,99 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/core-darwin-arm64@1.15.46':
resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==}
engines: {node: '>=10'}
cpu: [arm64]
os: [darwin]
'@swc/core-darwin-x64@1.15.46':
resolution: {integrity: sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==}
engines: {node: '>=10'}
cpu: [x64]
os: [darwin]
'@swc/core-linux-arm-gnueabihf@1.15.46':
resolution: {integrity: sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==}
engines: {node: '>=10'}
cpu: [arm]
os: [linux]
'@swc/core-linux-arm64-gnu@1.15.46':
resolution: {integrity: sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.46':
resolution: {integrity: sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-ppc64-gnu@1.15.46':
resolution: {integrity: sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==}
engines: {node: '>=10'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@swc/core-linux-s390x-gnu@1.15.46':
resolution: {integrity: sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==}
engines: {node: '>=10'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-gnu@1.15.46':
resolution: {integrity: sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.15.46':
resolution: {integrity: sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.46':
resolution: {integrity: sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [win32]
'@swc/core-win32-ia32-msvc@1.15.46':
resolution: {integrity: sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==}
engines: {node: '>=10'}
cpu: [ia32]
os: [win32]
'@swc/core-win32-x64-msvc@1.15.46':
resolution: {integrity: sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==}
engines: {node: '>=10'}
cpu: [x64]
os: [win32]
'@swc/core@1.15.46':
resolution: {integrity: sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==}
engines: {node: '>=10'}
peerDependencies:
'@swc/helpers': '>=0.5.17'
peerDependenciesMeta:
'@swc/helpers':
optional: true
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/types@0.1.27':
resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
'@szmarczak/http-timer@4.0.6':
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
@@ -3461,6 +3662,13 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
chardet@2.2.0:
resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@@ -4316,6 +4524,10 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
@@ -4473,6 +4685,15 @@ packages:
engines: {node: '>=18'}
hasBin: true
i18next-cli@1.65.0:
resolution: {integrity: sha512-sak+2Ry4P7wtl7xMAZg2sWG2vup1lRHFBKA7h5IeEqFUog51QEgeYUhHxd2x85+MvS4BhVOZZs833clnd1WgYA==}
engines: {node: '>=22'}
hasBin: true
i18next-resources-for-ts@2.1.0:
resolution: {integrity: sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow==}
hasBin: true
i18next@26.3.1:
resolution: {integrity: sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==}
peerDependencies:
@@ -4515,6 +4736,15 @@ packages:
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
inquirer@14.0.2:
resolution: {integrity: sha512-VsSx1JneSNp3ld1veMTLe+UDcUD8Tw2/jjOthhkX3/IX2q+xHhVELifeb/hsb1fBw31pabEPNUf/xUOyb+KZjA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
inspect-webkit@0.0.5:
resolution: {integrity: sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw==}
hasBin: true
@@ -5390,6 +5620,10 @@ packages:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
@@ -5693,6 +5927,10 @@ packages:
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
recast@0.23.11:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
@@ -5856,6 +6094,10 @@ packages:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
run-async@4.0.6:
resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==}
engines: {node: '>=0.12.0'}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -6543,6 +6785,11 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
@@ -6819,6 +7066,12 @@ snapshots:
'@chevrotain/types@11.1.2': {}
'@croct/json5-parser@0.2.2':
dependencies:
'@croct/json': 2.1.0
'@croct/json@2.1.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
dependencies:
react: 19.2.7
@@ -7094,8 +7347,16 @@ snapshots:
'@iconify/types': 2.0.0
mlly: 1.8.2
'@inquirer/ansi@2.0.7':
optional: true
'@inquirer/ansi@2.0.7': {}
'@inquirer/checkbox@5.2.1(@types/node@25.9.5)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@25.9.5)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/confirm@6.1.1(@types/node@25.9.5)':
dependencies:
@@ -7103,7 +7364,6 @@ snapshots:
'@inquirer/type': 4.0.7(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
optional: true
'@inquirer/core@11.2.1(@types/node@25.9.5)':
dependencies:
@@ -7116,15 +7376,95 @@ snapshots:
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 25.9.5
optional: true
'@inquirer/figures@2.0.7':
optional: true
'@inquirer/editor@5.2.2(@types/node@25.9.5)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.9.5)
'@inquirer/external-editor': 3.0.3(@types/node@25.9.5)
'@inquirer/type': 4.0.7(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/expand@5.1.1(@types/node@25.9.5)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.9.5)
'@inquirer/type': 4.0.7(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/external-editor@3.0.3(@types/node@25.9.5)':
dependencies:
chardet: 2.2.0
iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/figures@2.0.7': {}
'@inquirer/input@5.1.2(@types/node@25.9.5)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.9.5)
'@inquirer/type': 4.0.7(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/number@4.1.1(@types/node@25.9.5)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.9.5)
'@inquirer/type': 4.0.7(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/password@5.1.1(@types/node@25.9.5)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@25.9.5)
'@inquirer/type': 4.0.7(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/prompts@8.5.2(@types/node@25.9.5)':
dependencies:
'@inquirer/checkbox': 5.2.1(@types/node@25.9.5)
'@inquirer/confirm': 6.1.1(@types/node@25.9.5)
'@inquirer/editor': 5.2.2(@types/node@25.9.5)
'@inquirer/expand': 5.1.1(@types/node@25.9.5)
'@inquirer/input': 5.1.2(@types/node@25.9.5)
'@inquirer/number': 4.1.1(@types/node@25.9.5)
'@inquirer/password': 5.1.1(@types/node@25.9.5)
'@inquirer/rawlist': 5.3.1(@types/node@25.9.5)
'@inquirer/search': 4.2.1(@types/node@25.9.5)
'@inquirer/select': 5.2.1(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/rawlist@5.3.1(@types/node@25.9.5)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.9.5)
'@inquirer/type': 4.0.7(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/search@4.2.1(@types/node@25.9.5)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@25.9.5)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/select@5.2.1(@types/node@25.9.5)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@25.9.5)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@25.9.5)
optionalDependencies:
'@types/node': 25.9.5
'@inquirer/type@4.0.7(@types/node@25.9.5)':
optionalDependencies:
'@types/node': 25.9.5
optional: true
'@isaacs/fs-minipass@4.0.1':
dependencies:
@@ -8446,6 +8786,66 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@swc/core-darwin-arm64@1.15.46':
optional: true
'@swc/core-darwin-x64@1.15.46':
optional: true
'@swc/core-linux-arm-gnueabihf@1.15.46':
optional: true
'@swc/core-linux-arm64-gnu@1.15.46':
optional: true
'@swc/core-linux-arm64-musl@1.15.46':
optional: true
'@swc/core-linux-ppc64-gnu@1.15.46':
optional: true
'@swc/core-linux-s390x-gnu@1.15.46':
optional: true
'@swc/core-linux-x64-gnu@1.15.46':
optional: true
'@swc/core-linux-x64-musl@1.15.46':
optional: true
'@swc/core-win32-arm64-msvc@1.15.46':
optional: true
'@swc/core-win32-ia32-msvc@1.15.46':
optional: true
'@swc/core-win32-x64-msvc@1.15.46':
optional: true
'@swc/core@1.15.46':
dependencies:
'@swc/counter': 0.1.3
'@swc/types': 0.1.27
optionalDependencies:
'@swc/core-darwin-arm64': 1.15.46
'@swc/core-darwin-x64': 1.15.46
'@swc/core-linux-arm-gnueabihf': 1.15.46
'@swc/core-linux-arm64-gnu': 1.15.46
'@swc/core-linux-arm64-musl': 1.15.46
'@swc/core-linux-ppc64-gnu': 1.15.46
'@swc/core-linux-s390x-gnu': 1.15.46
'@swc/core-linux-x64-gnu': 1.15.46
'@swc/core-linux-x64-musl': 1.15.46
'@swc/core-win32-arm64-msvc': 1.15.46
'@swc/core-win32-ia32-msvc': 1.15.46
'@swc/core-win32-x64-msvc': 1.15.46
'@swc/counter@0.1.3': {}
'@swc/types@0.1.27':
dependencies:
'@swc/counter': 0.1.3
'@szmarczak/http-timer@4.0.6':
dependencies:
defer-to-connect: 2.0.1
@@ -9179,39 +9579,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)': {}
abbrev@4.0.0: {}
@@ -9487,6 +9887,12 @@ snapshots:
character-reference-invalid@2.0.1: {}
chardet@2.2.0: {}
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
chownr@3.0.0: {}
chromium-pickle-js@0.2.0: {}
@@ -9512,8 +9918,7 @@ snapshots:
slice-ansi: 8.0.0
string-width: 8.2.1
cli-width@4.1.0:
optional: true
cli-width@4.1.0: {}
cliui@6.0.0:
dependencies:
@@ -10002,7 +10407,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-vite@5.0.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)):
electron-vite@5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)):
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7)
@@ -10011,6 +10416,8 @@ snapshots:
magic-string: 0.30.21
picocolors: 1.1.1
vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)
optionalDependencies:
'@swc/core': 1.15.46
transitivePeerDependencies:
- supports-color
@@ -10252,20 +10659,17 @@ snapshots:
merge2: 1.4.1
micromatch: 4.0.8
fast-string-truncated-width@3.0.3:
optional: true
fast-string-truncated-width@3.0.3: {}
fast-string-width@3.0.2:
dependencies:
fast-string-truncated-width: 3.0.3
optional: true
fast-uri@3.1.4: {}
fast-wrap-ansi@0.2.2:
dependencies:
fast-string-width: 3.0.2
optional: true
fastq@1.20.1:
dependencies:
@@ -10417,6 +10821,12 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@13.0.6:
dependencies:
minimatch: 10.2.5
minipass: 7.1.3
path-scurry: 2.0.2
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@@ -10672,6 +11082,41 @@ snapshots:
husky@9.1.7: {}
i18next-cli@1.65.0(@types/node@25.9.5)(react-dom@19.2.7(react@19.2.7))(typescript@7.0.2):
dependencies:
'@croct/json5-parser': 0.2.2
'@swc/core': 1.15.46
chokidar: 5.0.0
commander: 14.0.3
execa: 9.6.1
glob: 13.0.6
i18next: 26.3.1(typescript@7.0.2)
i18next-resources-for-ts: 2.1.0
inquirer: 14.0.2(@types/node@25.9.5)
jiti: 2.7.0
jsonc-parser: 3.3.1
magic-string: 0.30.21
minimatch: 10.2.5
ora: 9.4.0
react: 19.2.7
react-i18next: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)
yaml: 2.9.0
transitivePeerDependencies:
- '@swc/helpers'
- '@types/node'
- react-dom
- react-native
- typescript
i18next-resources-for-ts@2.1.0:
dependencies:
'@babel/runtime': 7.29.7
'@swc/core': 1.15.46
chokidar: 5.0.0
yaml: 2.9.0
transitivePeerDependencies:
- '@swc/helpers'
i18next@26.3.1(typescript@7.0.2):
optionalDependencies:
typescript: 7.0.2
@@ -10704,6 +11149,17 @@ snapshots:
inline-style-parser@0.2.7: {}
inquirer@14.0.2(@types/node@25.9.5):
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@25.9.5)
'@inquirer/prompts': 8.5.2(@types/node@25.9.5)
'@inquirer/type': 4.0.7(@types/node@25.9.5)
mute-stream: 3.0.0
run-async: 4.0.6
optionalDependencies:
'@types/node': 25.9.5
inspect-webkit@0.0.5(typescript@7.0.2):
dependencies:
typescript: 7.0.2
@@ -11517,8 +11973,7 @@ snapshots:
- '@types/node'
optional: true
mute-stream@3.0.0:
optional: true
mute-stream@3.0.0: {}
nan@2.26.2:
optional: true
@@ -11794,6 +12249,11 @@ snapshots:
path-key@4.0.0: {}
path-scurry@2.0.2:
dependencies:
lru-cache: 11.5.1
minipass: 7.1.3
path-to-regexp@6.3.0:
optional: true
@@ -12177,6 +12637,8 @@ snapshots:
string_decoder: 1.1.1
util-deprecate: 1.0.2
readdirp@5.0.0: {}
recast@0.23.11:
dependencies:
ast-types: 0.16.1
@@ -12396,6 +12858,8 @@ snapshots:
run-applescript@7.1.0: {}
run-async@4.0.6: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -13082,6 +13546,8 @@ snapshots:
yaml@2.8.4: {}
yaml@2.9.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
File diff suppressed because it is too large Load Diff
+2
View File
@@ -69,6 +69,7 @@ ORCA computer get-app-state --app <app> --json
ORCA computer get-app-state --app <app> --restore-window --json
ORCA computer click --app <app> --element-index <index> --json
ORCA computer click --app <app> --x 100 --y 100 --json
ORCA computer click --app <app> --x 100 --y 100 --modifiers CmdOrCtrl+Shift --json
ORCA computer perform-secondary-action --app <app> --element-index <index> --action <name> --json
ORCA computer set-value --app <app> --element-index <index> --value "text" --json
ORCA computer type-text --app <app> --text "text" --json
@@ -95,6 +96,7 @@ printf '%s' "$TEXT" | ORCA computer set-value --app <app> --element-index <index
- After any UI-changing action, use the returned state or rerun `get-app-state` before choosing the next element index.
- Use `type-text` only after focusing a field and confirming the app has a focused text receiver; synthetic keyboard delivery is reported as unverified, so inspect the returned state before assuming text landed.
- Use `press-key` for single/navigation keys such as Return, Escape, Tab, and arrows. Use `hotkey` only for one modifier chord plus one key, such as `CmdOrCtrl+A` or `CmdOrCtrl+Shift+P`; prefer `CmdOrCtrl+...` for cross-platform combos.
- Use `click --modifiers <chord>` for modifier-clicks. Never synthesize separate modifier-down and modifier-up commands around a click; interruption can leave a modifier logically held.
- Some actions work in background apps, but this is app-dependent. If success does not change the UI, refresh state and choose a more semantic action or restore/focus the window.
- Prefer `set-value` for text fields that expose values; it can report verified value writes when the provider can read the refreshed value.
- Coordinates are window-local; use coordinates from the latest screenshot/state for the same target window.
+2 -2
View File
@@ -143,12 +143,12 @@ ORCA worktree create --name task --setup skip --json
ORCA worktree create --name task --run-hooks --json
```
- `--agent <id>` launches that agent **in the first terminal** (Orca docs: *"`--agent` launches the selected agent in the first terminal"*); `--prompt <text>` sends initial work to it. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed TUI agents.
- `--agent <id>` launches that agent **in the first terminal** (Orca docs: _"`--agent` launches the selected agent in the first terminal"_); `--prompt <text>` sends initial work to it. Known ids include `claude`, `codex`, `omp`, `pi`, `grok`, and other installed TUI agents.
- **Prefer agent-first create for agent workers.** `orca worktree create --agent <id> --prompt "..."` puts the agent in the worktree's first terminal without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Without configured default tabs, the bare-create fallback shell plus a later `terminal create --command <agent>` is an anti-pattern for ordinary agent worktrees — use `--agent` instead of “create worktree, then open agent.” Configured default tabs are intentional surfaces; never treat one as disposable without verifying that it is an unused shell.
- After create, use exactly one agent handle: `startupTerminal.handle` from the create response when present, or the matching result from `orca terminal list --worktree id:<repoId>::<newWorktreePath> --json` (or `name:<displayName>`) when the response omits it. If a handle later returns `terminal_handle_stale`, re-list it; never dual-send to old and replacement handles.
- `--setup run|skip|inherit` controls repo setup hooks. Default is `inherit`, which follows the repo's setup policy.
- `--run-hooks` is a legacy alias for `--setup run`; it also reveals/activates the new worktree.
- `--agent`, `--activate`, and `--run-hooks` reveal the new worktree. Plain create stays in the background.
- `--activate` and `--run-hooks` reveal the new worktree. `--agent` alone stays in the background.
- Let Orca choose setup terminal placement from repo settings, including tab vs split behavior. Do not manually create extra setup terminals when `--agent` already owns the first tab.
- If an older installed CLI rejects `--agent`, `--prompt`, or `--setup`, create the worktree normally, then run `orca terminal create --worktree <selector> --command "<requested-agent>"` and `orca terminal send` if a prompt is needed. This can leave a fallback shell when no default tabs are configured; close it only after confirming it is unused.
- `worktree create` creates a new checkout. For a fresh agent in the **current** checkout (no new worktree), use `orca terminal create --worktree active --command "codex" --json` — that path does not create a second worktree shell.
File diff suppressed because one or more lines are too long
+15 -5
View File
@@ -12,7 +12,7 @@ import {
} from '../runtime-client'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { getDefaultPersistedState } from '../../shared/constants'
import type { PersistedState } from '../../shared/types'
import type { GlobalSettings, PersistedState } from '../../shared/types'
import {
applyAgentStatusHooksEnabled,
getManagedAgentHookStatuses
@@ -75,7 +75,10 @@ function readEnabledFromDisk(): boolean {
return state.settings?.agentStatusHooksEnabled !== false
}
function updateEnabledOnDisk(enabled: boolean): string {
function updateEnabledOnDisk(enabled: boolean): {
settingsPath: string
settings: Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
} {
const dataPath = getDataPath()
const state = readPersistedState(dataPath)
state.settings = {
@@ -84,7 +87,13 @@ function updateEnabledOnDisk(enabled: boolean): string {
agentStatusHooksEnabled: enabled
}
writePersistedState(dataPath, state)
return dataPath
return {
settingsPath: dataPath,
settings: {
agentCmdOverrides: state.settings.agentCmdOverrides ?? {},
disabledTuiAgents: state.settings.disabledTuiAgents ?? []
}
}
}
async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise<boolean> {
@@ -134,10 +143,11 @@ async function setAgentHooksEnabled(
enabled: boolean
): Promise<AgentHookCommandResult> {
const updatedRuntime = await updateRunningRuntime(client, enabled)
const settingsPath = updatedRuntime ? getDataPath() : updateEnabledOnDisk(enabled)
const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled)
const settingsPath = offlineUpdate?.settingsPath ?? getDataPath()
const statuses = updatedRuntime
? getManagedAgentHookStatuses()
: applyAgentStatusHooksEnabled(enabled)
: await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings)
return {
enabled,
settingsPath,
+12 -1
View File
@@ -1,4 +1,5 @@
import {
computerUseClickModifiersValidationMessage,
computerUseHotkeyValidationMessage,
computerUsePressKeyValidationMessage
} from '../../shared/computer-use-key-spec'
@@ -60,16 +61,26 @@ export function getComputerClickActionFlags(flags: Map<string, string | boolean>
y?: number
clickCount?: number
mouseButton?: string
modifiers?: string
} {
const rawModifiers = flags.get('modifiers')
const modifiers = typeof rawModifiers === 'string' ? rawModifiers : undefined
const result = {
elementIndex: getOptionalNonNegativeIntegerFlag(flags, 'element-index'),
x: getOptionalNumberFlag(flags, 'x'),
y: getOptionalNumberFlag(flags, 'y'),
clickCount: getOptionalPositiveIntegerFlag(flags, 'click-count'),
mouseButton: getOptionalStringFlag(flags, 'mouse-button')
mouseButton: getOptionalStringFlag(flags, 'mouse-button'),
modifiers
}
validateElementOrCoordinates('Click', result.elementIndex, result.x, result.y)
validateMouseButton(result.mouseButton)
if (modifiers !== undefined) {
const message = computerUseClickModifiersValidationMessage(modifiers)
if (message) {
throw new RuntimeClientError('invalid_argument', message)
}
}
return result
}
@@ -59,6 +59,8 @@ describe('orca computer action CLI routing', () => {
'Finder',
'--element-index',
'3',
'--modifiers',
'CmdOrCtrl+Shift',
'--json'
],
'/tmp/repo/src'
@@ -73,6 +75,7 @@ describe('orca computer action CLI routing', () => {
y: undefined,
clickCount: undefined,
mouseButton: undefined,
modifiers: 'CmdOrCtrl+Shift',
noScreenshot: undefined
})
})
@@ -289,6 +289,43 @@ describe('orca computer action CLI validation', () => {
vi.mocked(console.error).mockClear()
process.exitCode = undefined
await main(
[
'computer',
'click',
'--app',
'Finder',
'--element-index',
'1',
'--modifiers',
'CmdOrCtrl+A'
],
'/tmp/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'Click modifiers accept modifier keys only'
)
expect(process.exitCode).toBe(1)
vi.mocked(console.error).mockClear()
process.exitCode = undefined
await main(
['computer', 'click', '--app', 'Finder', '--element-index', '1', '--modifiers', ''],
'/tmp/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'Click modifiers accept modifier keys only'
)
expect(process.exitCode).toBe(1)
vi.mocked(console.error).mockClear()
process.exitCode = undefined
await main(
['computer', 'scroll', '--app', 'Finder', '--element-index', '1', '--direction', 'diagonal'],
'/tmp/repo/src'
+8
View File
@@ -72,6 +72,14 @@ describe('orca computer observation CLI handlers', () => {
expect(pressKeyOutput).toContain('Single key, e.g. Return, Escape, Tab, Left, or PageUp')
})
it('documents atomic modified clicks', async () => {
await main(['computer', 'click', '--help'], '/tmp/repo')
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('--modifiers <chord>')
expect(output).toContain('Modifier keys held only for this click')
})
it('passes list-apps through without resolving a worktree', async () => {
queueFixtures(callMock, okFixture('req_apps', { apps: [] }))
@@ -63,6 +63,41 @@ describe('lightweight Run CLI handlers', () => {
})
})
it('passes Run pagination flags to the runtime', async () => {
callMock.mockResolvedValue({
result: { runs: [], nextCursor: null }
})
await ORCHESTRATION_HANDLERS['orchestration run-list']({
flags: new Map([
['limit', '25'],
['cursor', 'next-page']
]),
client: { call: callMock },
json: true
} as never)
expect(callMock).toHaveBeenCalledWith('orchestration.runList', {
limit: 25,
cursor: 'next-page'
})
})
it('opts into bounded Run pagination by default', async () => {
callMock.mockResolvedValue({ result: { runs: [], nextCursor: null } })
await ORCHESTRATION_HANDLERS['orchestration run-list']({
flags: new Map(),
client: { call: callMock },
json: true
} as never)
expect(callMock).toHaveBeenCalledWith('orchestration.runList', {
limit: 100,
cursor: undefined
})
})
it('passes explicit legacy takeover only when requested', async () => {
callMock.mockResolvedValue({
result: { run: { id: 'run_adopted', objective: 'Recovered work' } }
+18 -11
View File
@@ -22,6 +22,7 @@ import type {
import type { NativeChatMessage } from '../../shared/native-chat-types'
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract'
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination'
import {
formatMessageReadOnlyTag,
formatOrchestrationCheckText,
@@ -463,19 +464,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
)
},
'orchestration run-list': async ({ client, json }) => {
'orchestration run-list': async ({ flags, client, json }) => {
const result = await client.call<{
runs: { id: string; objective: string; legacy: number }[]
}>('orchestration.runList', {})
printResult(result, json, (r) =>
r.runs.length === 0
? 'No Runs found.'
: r.runs
.map(
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
)
.join('\n')
)
nextCursor: string | null
}>('orchestration.runList', {
limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT,
cursor: getOptionalStringFlag(flags, 'cursor')
})
printResult(result, json, (r) => {
const rows =
r.runs.length === 0
? 'No Runs found.'
: r.runs
.map(
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
)
.join('\n')
return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows
})
},
'orchestration run-show': async ({ flags, client, json }) => {
+1 -2
View File
@@ -237,8 +237,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
...linearIssueLink,
comment: getOptionalStringFlag(flags, 'comment'),
runHooks: flags.get('run-hooks') === true,
activate:
flags.get('activate') === true || flags.get('run-hooks') === true || Boolean(startupAgent),
activate: flags.get('activate') === true || flags.get('run-hooks') === true,
...(setupDecision ? { setupDecision } : {}),
parentWorktree: explicitParentWorktree,
...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}),
+1
View File
@@ -523,6 +523,7 @@ export function formatFlagHelp(flag: string): string {
limit: '--limit <n> Maximum number of rows to return',
mode: '--mode <mode> Mode such as edit, diff, or both',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
modifiers: '--modifiers <chord> Modifier keys held only for this click',
name: '--name <name> Name for the new worktree or automation',
'no-parent': '--no-parent Force no parent lineage for unrelated work',
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
+4 -3
View File
@@ -2826,7 +2826,7 @@ describe('orca cli worktree awareness', () => {
})
})
it('passes agent prompt and setup policy through worktree.create', async () => {
it('starts an agent worktree in the background unless activation is explicit', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
@@ -2864,7 +2864,7 @@ describe('orca cli worktree awareness', () => {
linkedIssue: undefined,
comment: undefined,
runHooks: false,
activate: true,
activate: false,
setupDecision: 'run',
parentWorktree: undefined,
cwdParentWorktree: 'id:repo-1::/tmp/repo',
@@ -2876,7 +2876,7 @@ describe('orca cli worktree awareness', () => {
})
})
it('infers the repo from the current worktree on worktree.create', async () => {
it('infers the repo and honors explicit activation on worktree.create', async () => {
queueFixtures(
callMock,
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
@@ -2898,6 +2898,7 @@ describe('orca cli worktree awareness', () => {
'codex',
'--prompt',
'hi',
'--activate',
'--json'
],
'/tmp/repo/src'
+4 -3
View File
@@ -44,16 +44,17 @@ export const COMPUTER_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['computer', 'click'],
summary: 'Click an app element or window coordinate',
summary: 'Click an app element or window coordinate, optionally with modifiers',
usage:
'orca computer click --app <app> (--element-index <n> | --x <x> --y <y>) [--window-id <id> | --window-index <n>] [--click-count <n>] [--mouse-button <left|right|middle>] [--restore-window] [--no-screenshot] [--json]',
'orca computer click --app <app> (--element-index <n> | --x <x> --y <y>) [--window-id <id> | --window-index <n>] [--click-count <n>] [--mouse-button <left|right|middle>] [--modifiers <modifier-chord>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [
...COMPUTER_ACTION_FLAGS,
'element-index',
'x',
'y',
'click-count',
'mouse-button'
'mouse-button',
'modifiers'
]
},
{
+2 -2
View File
@@ -32,8 +32,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['orchestration', 'run-list'],
summary: 'List lightweight orchestration Runs',
usage: 'orca orchestration run-list [--json]',
allowedFlags: [...GLOBAL_FLAGS]
usage: 'orca orchestration run-list [--limit <n>] [--cursor <cursor>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'cursor']
},
{
path: ['orchestration', 'run-show'],
+15 -131
View File
@@ -1,155 +1,39 @@
// Pins the contract between `runManagedHookInstallers` and the
// `agent_hook_install_failed` telemetry event: each catch must fire `track`
// with the correct agent label and a truncated error_message, and one
// installer's failure must not stop the others (fail-open semantics).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() }))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
import { runManagedHookInstallers } from './install-telemetry'
import { recordManagedHookInstallFailure } from './install-telemetry'
describe('runManagedHookInstallers', () => {
describe('recordManagedHookInstallFailure', () => {
beforeEach(() => {
trackMock.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('runs every installer when none throw and never calls track', () => {
const claude = vi.fn()
const codex = vi.fn()
runManagedHookInstallers([
['claude', claude],
['codex', codex]
])
expect(claude).toHaveBeenCalledTimes(1)
expect(codex).toHaveBeenCalledTimes(1)
expect(trackMock).not.toHaveBeenCalled()
})
it('fires agent_hook_install_failed with the correct agent label when an installer throws', () => {
runManagedHookInstallers([
[
'codex',
() => {
throw new Error('codex config malformed')
}
]
])
it('records the agent and truncated error message', () => {
recordManagedHookInstallFailure('codex', new Error('x'.repeat(500)))
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'codex',
error_message: 'codex config malformed'
})
})
it('continues running later installers after an earlier one throws (fail-open)', () => {
const codex = vi.fn()
const gemini = vi.fn()
runManagedHookInstallers([
[
'claude',
() => {
throw new Error('claude failed')
}
],
['codex', codex],
['gemini', gemini]
])
expect(codex).toHaveBeenCalledTimes(1)
expect(gemini).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith(
'agent_hook_install_failed',
expect.objectContaining({ agent: 'claude' })
)
})
it('truncates error_message to 200 chars', () => {
const longMessage = 'x'.repeat(500)
runManagedHookInstallers([
[
'gemini',
() => {
throw new Error(longMessage)
}
]
])
expect(trackMock).toHaveBeenCalledTimes(1)
const [, props] = trackMock.mock.calls[0] as [string, { error_message: string }]
expect(props.error_message.length).toBe(200)
})
it('handles non-Error throws', () => {
runManagedHookInstallers([
[
'cursor',
() => {
throw 'cursor string failure'
}
]
])
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'cursor',
error_message: 'cursor string failure'
})
})
it('serializes thrown objects through JSON.stringify', () => {
runManagedHookInstallers([
[
'cursor',
() => {
throw { code: 'EACCES', path: '/tmp' }
}
]
])
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'cursor',
error_message: '{"code":"EACCES","path":"/tmp"}'
})
})
it('does not throw when an installer throws undefined (regression for JSON.stringify undefined return)', () => {
expect(() =>
runManagedHookInstallers([
[
'cursor',
() => {
throw undefined
}
]
])
).not.toThrow()
expect(trackMock).toHaveBeenCalledTimes(1)
const [eventName, props] = trackMock.mock.calls[0] as [string, { error_message: string }]
const [eventName, props] = trackMock.mock.calls[0] as [
string,
{ agent: string; error_message: string }
]
expect(eventName).toBe('agent_hook_install_failed')
expect(typeof props.error_message).toBe('string')
expect(props.agent).toBe('codex')
expect(props.error_message).toHaveLength(200)
})
it('continues running later installers when track itself throws (telemetry must not break fail-open)', () => {
const codex = vi.fn()
it('handles non-Error values and telemetry failures', () => {
trackMock.mockImplementationOnce(() => {
throw new Error('telemetry blew up')
throw new Error('telemetry failed')
})
expect(() =>
runManagedHookInstallers([
[
'claude',
() => {
throw new Error('claude failed')
}
],
['codex', codex]
])
).not.toThrow()
expect(codex).toHaveBeenCalledTimes(1)
expect(() => recordManagedHookInstallFailure('cursor', { code: 'EACCES' })).not.toThrow()
})
})
+8 -31
View File
@@ -1,21 +1,8 @@
// Per-agent managed-hook installer with fail-open semantics and PostHog
// attribution. Lifted out of `src/main/index.ts` so the loop is unit-testable
// without standing up the full Electron startup graph — the catch site needs
// the agent label to fire `agent_hook_install_failed`, and the previous
// closure-style loop lost it.
import type { HookInstallAgent } from '../../shared/telemetry-events'
import { track } from '../telemetry/client'
// Why: install errors are about config-file shape (malformed JSON, ACL
// denial), not user content — but messages can include paths or stack
// fragments. The 200-char cap matches `agentHookInstallFailedSchema.error_message`
// in `src/shared/telemetry-events.ts`; the validator drops overlength values,
// so truncation must happen here at the call site.
const ERROR_MESSAGE_MAX_LEN = 200
export type ManagedHookInstaller = readonly [HookInstallAgent, () => void]
function describeError(error: unknown): string {
if (error instanceof Error) {
return error.message
@@ -31,23 +18,13 @@ function describeError(error: unknown): string {
}
}
export function runManagedHookInstallers(installers: readonly ManagedHookInstaller[]): void {
for (const [agent, install] of installers) {
try {
install()
} catch (error) {
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
// Why: telemetry must not break fail-open. A throw inside `track` (e.g.
// a corrupted settings store the resolveConsent path reads from) would
// otherwise abort the for-loop and skip later agents' installers.
try {
track('agent_hook_install_failed', {
agent,
error_message: describeError(error).slice(0, ERROR_MESSAGE_MAX_LEN)
})
} catch (telemetryError) {
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
}
}
export function recordManagedHookInstallFailure(agent: HookInstallAgent, error: unknown): void {
try {
track('agent_hook_install_failed', {
agent,
error_message: describeError(error).slice(0, ERROR_MESSAGE_MAX_LEN)
})
} catch (telemetryError) {
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
}
}
@@ -0,0 +1,212 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets'
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
const codexTarget: ManagedAgentHookTarget = {
agent: 'codex',
tuiAgent: 'codex',
executableCandidates: ['codex']
}
const claudeTarget: ManagedAgentHookTarget = {
agent: 'claude',
tuiAgent: 'claude',
executableCandidates: ['claude']
}
describe('detectLocalManagedAgentCliPresence', () => {
let tmpDir: string | null = null
afterEach(() => {
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true })
tmpDir = null
}
})
it('scans a deduped PATH once for all agent candidates', async () => {
const probe = vi.fn(async (filePath: string) => filePath.endsWith('/bin/codex'))
const result = await detectLocalManagedAgentCliPresence(
[codexTarget, claudeTarget],
{ agentCmdOverrides: {} },
{
pathEnv: ['/bin', '/bin', '/usr/bin'].join(':'),
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux'
}
)
expect(result.codex?.state).toBe('found')
expect(result.claude?.state).toBe('missing')
expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([
'/bin/codex',
'/bin/claude',
'/usr/bin/claude'
])
})
it('uses executable override paths as positive evidence', async () => {
const probe = vi.fn(async (filePath: string) => filePath === '/custom/bin/codex')
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: { codex: '/custom/bin/codex --profile work' } },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux'
}
)
expect(result.codex?.state).toBe('found')
expect(probe).toHaveBeenCalledWith('/custom/bin/codex')
})
it('preserves Windows override separators', async () => {
const overridePath = 'C:\\My Tools\\claude.cmd'
const probe = vi.fn(async (filePath: string) => filePath === overridePath)
const result = await detectLocalManagedAgentCliPresence(
[claudeTarget],
{ agentCmdOverrides: { claude: `"${overridePath}" --flag` } },
{
pathEnv: '',
pathDelimiter: ';',
fileProbe: { isExecutableFile: probe },
platform: 'win32'
}
)
expect(result.claude?.state).toBe('found')
expect(probe).toHaveBeenCalledWith(overridePath)
})
it('expands Windows home-relative override paths with Windows separators', async () => {
const overridePath = 'C:\\Users\\orca\\bin\\claude.cmd'
const probe = vi.fn(async (filePath: string) => filePath === overridePath)
const result = await detectLocalManagedAgentCliPresence(
[claudeTarget],
{ agentCmdOverrides: { claude: '~\\bin\\claude.cmd --flag' } },
{
pathEnv: '',
fileProbe: { isExecutableFile: probe },
platform: 'win32',
homeDir: 'C:\\Users\\orca'
}
)
expect(result.claude?.state).toBe('found')
expect(probe).toHaveBeenCalledWith(overridePath)
})
it('expands home-relative override paths', async () => {
const probe = vi.fn(async (filePath: string) => filePath === '/home/orca/bin/codex')
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: { codex: '~/bin/codex --profile work' } },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux',
homeDir: '/home/orca'
}
)
expect(result.codex?.state).toBe('found')
expect(probe).toHaveBeenCalledWith('/home/orca/bin/codex')
})
it('reports relative override paths as unknown', async () => {
const probe = vi.fn(async () => true)
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: { codex: 'bin/codex --profile work' } },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux'
}
)
expect(result.codex).toEqual({ state: 'unknown' })
expect(probe).not.toHaveBeenCalled()
})
it('honors PATHEXT for Windows PATH candidates', async () => {
const probe = vi.fn(async (filePath: string) => filePath === 'C:\\Tools\\codex.CMD')
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: {} },
{
pathEnv: 'C:\\Other;C:\\Tools',
pathExt: '.EXE;.CMD',
fileProbe: { isExecutableFile: probe },
platform: 'win32'
}
)
expect(result.codex?.state).toBe('found')
expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([
'C:\\Other\\codex.EXE',
'C:\\Other\\codex.CMD',
'C:\\Tools\\codex.EXE',
'C:\\Tools\\codex.CMD'
])
})
it('warns and uses the inherited PATH when shell hydration throws', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: {} },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: vi.fn(async () => false) },
platform: 'linux',
shouldHydrateShellPath: true,
hydratePath: vi.fn(async () => {
throw new Error('shell unavailable')
})
}
)
expect(result.codex?.state).toBe('missing')
expect(warning).toHaveBeenCalledWith(
'[agent-hooks] Shell PATH hydration failed; using inherited PATH:',
expect.objectContaining({ message: 'shell unavailable' })
)
} finally {
warning.mockRestore()
}
})
it.runIf(process.platform !== 'win32')(
'accepts executable symlinks and rejects broken symlinks',
async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cli-presence-'))
const binDir = join(tmpDir, 'bin')
mkdirSync(binDir)
const targetPath = join(tmpDir, 'codex-real')
writeFileSync(targetPath, '#!/bin/sh\n')
chmodSync(targetPath, 0o755)
symlinkSync(targetPath, join(binDir, 'codex'))
symlinkSync(join(tmpDir, 'missing'), join(binDir, 'claude'))
const result = await detectLocalManagedAgentCliPresence(
[codexTarget, claudeTarget],
{ agentCmdOverrides: {} },
{ pathEnv: binDir, pathDelimiter: ':', platform: process.platform }
)
expect(result.codex?.state).toBe('found')
expect(result.claude?.state).toBe('missing')
}
)
})
@@ -0,0 +1,200 @@
import { constants } from 'node:fs'
import { access, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import path from 'node:path'
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import {
extractExecutableToken,
hasPathSeparatorToken,
isSafeExecutableBasename
} from '../../shared/managed-agent-command-token'
import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets'
import type { GlobalSettings } from '../../shared/types'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
export type LocalCliPresenceState = 'found' | 'missing' | 'unknown'
export type LocalCliPresenceByAgent = Partial<
Record<AgentHookTarget, { state: LocalCliPresenceState }>
>
type FileProbe = {
isExecutableFile: (filePath: string) => Promise<boolean>
}
type HydrationResult =
| { ok: true; segments: string[] }
| { ok: false; segments: []; failureReason?: string }
type DetectOptions = {
pathEnv?: string
platform?: NodeJS.Platform
pathDelimiter?: string
pathExt?: string
fileProbe?: FileProbe
hydratePath?: () => Promise<HydrationResult>
shouldHydrateShellPath?: boolean
homeDir?: string
}
type CommandOverrideSettings = Partial<Pick<GlobalSettings, 'agentCmdOverrides'>> | null | undefined
const DEFAULT_WINDOWS_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD']
function pathApiForPlatform(platform: NodeJS.Platform) {
return platform === 'win32' ? path.win32 : path.posix
}
async function isExecutableFile(filePath: string, platform: NodeJS.Platform): Promise<boolean> {
try {
const fileStat = await stat(filePath)
if (!fileStat.isFile()) {
return false
}
if (platform === 'win32') {
return true
}
await access(filePath, constants.X_OK)
return true
} catch {
return false
}
}
function pathEntries(pathEnv: string, delimiter: string): string[] {
return [...new Set(pathEnv.split(delimiter).filter(Boolean))]
}
function windowsPathExts(value: string | undefined): string[] {
const source = value?.length ? value : DEFAULT_WINDOWS_EXTENSIONS.join(';')
return [
...new Set(
source
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => (part.startsWith('.') ? part : `.${part}`))
.map((part) => part.toUpperCase())
)
]
}
function candidateFileNames(
candidate: string,
platform: NodeJS.Platform,
pathExt?: string
): string[] {
if (platform !== 'win32' || pathApiForPlatform(platform).extname(candidate)) {
return [candidate]
}
return windowsPathExts(pathExt).map((suffix) => `${candidate}${suffix}`)
}
function overrideTokenForAgent(
settings: CommandOverrideSettings,
target: ManagedAgentHookTarget,
platform: NodeJS.Platform
): string | null {
return extractExecutableToken(settings?.agentCmdOverrides?.[target.tuiAgent], { platform })
}
function expandHomePathToken(token: string, platform: NodeJS.Platform, homeDir: string): string {
if (token === '~') {
return homeDir
}
if (token.startsWith('~/') || (platform === 'win32' && token.startsWith('~\\'))) {
return pathApiForPlatform(platform).join(homeDir, token.slice(2))
}
return token
}
async function probePathCandidate(
candidate: string,
dirs: readonly string[],
platform: NodeJS.Platform,
fileProbe: FileProbe,
pathExt?: string
): Promise<boolean> {
if (!isSafeExecutableBasename(candidate)) {
return false
}
for (const dir of dirs) {
for (const fileName of candidateFileNames(candidate, platform, pathExt)) {
if (await fileProbe.isExecutableFile(pathApiForPlatform(platform).join(dir, fileName))) {
return true
}
}
}
return false
}
function isPlatformAbsolutePath(candidate: string, platform: NodeJS.Platform): boolean {
return platform === 'win32' ? path.win32.isAbsolute(candidate) : path.posix.isAbsolute(candidate)
}
async function maybeHydrateShellPath(options: DetectOptions): Promise<void> {
if (!options.shouldHydrateShellPath) {
return
}
try {
const result = await (options.hydratePath ?? hydrateShellPath)()
if (result.ok) {
mergePathSegments(result.segments)
}
} catch (error) {
// Detection failure must never permit config mutation.
console.warn('[agent-hooks] Shell PATH hydration failed; using inherited PATH:', error)
}
}
export async function detectLocalManagedAgentCliPresence(
targets: readonly ManagedAgentHookTarget[],
settings: CommandOverrideSettings,
options: DetectOptions = {}
): Promise<LocalCliPresenceByAgent> {
await maybeHydrateShellPath(options)
const platform = options.platform ?? process.platform
const delimiter = options.pathDelimiter ?? pathApiForPlatform(platform).delimiter
const dirs = pathEntries(options.pathEnv ?? process.env.PATH ?? '', delimiter)
const homeDir = options.homeDir ?? homedir()
const fileProbe = options.fileProbe ?? {
isExecutableFile: (filePath: string) => isExecutableFile(filePath, platform)
}
const candidates = new Set<string>()
for (const target of targets) {
for (const candidate of target.executableCandidates) {
if (!hasPathSeparatorToken(candidate)) {
candidates.add(candidate)
}
}
const override = overrideTokenForAgent(settings, target, platform)
if (override && !hasPathSeparatorToken(override)) {
candidates.add(override)
}
}
const found = new Set<string>()
for (const candidate of candidates) {
if (await probePathCandidate(candidate, dirs, platform, fileProbe, options.pathExt)) {
found.add(candidate)
}
}
const result: LocalCliPresenceByAgent = {}
for (const target of targets) {
const override = overrideTokenForAgent(settings, target, platform)
if (override && hasPathSeparatorToken(override)) {
const expanded = expandHomePathToken(override, platform, homeDir)
if (!isPlatformAbsolutePath(expanded, platform)) {
result[target.agent] = { state: 'unknown' }
continue
}
result[target.agent] = (await fileProbe.isExecutableFile(expanded))
? { state: 'found' }
: { state: 'missing' }
continue
}
const targetCandidates = [...target.executableCandidates, ...(override ? [override] : [])]
result[target.agent] = targetCandidates.some((candidate) => found.has(candidate))
? { state: 'found' }
: { state: 'missing' }
}
return result
}
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
detect: vi.fn(),
installClaude: vi.fn(),
installCodex: vi.fn(),
removeClaude: vi.fn(),
removeCodex: vi.fn(),
statusClaude: vi.fn(),
statusCodex: vi.fn()
}))
vi.mock('./local-agent-cli-presence', () => ({
detectLocalManagedAgentCliPresence: mocks.detect
}))
vi.mock('./managed-agent-hook-registry', () => ({
MANAGED_AGENT_HOOK_INSTALLERS: [
['claude', mocks.installClaude],
['codex', mocks.installCodex]
],
MANAGED_AGENT_HOOK_REMOVERS: [
['claude', mocks.removeClaude],
['codex', mocks.removeCodex]
],
MANAGED_AGENT_HOOK_STATUS_READERS: [
['claude', mocks.statusClaude],
['codex', mocks.statusCodex]
]
}))
import {
applyAgentStatusHooksEnabled,
installManagedAgentHooks
} from './managed-agent-hook-controls'
function status(agent: 'claude' | 'codex', state: 'installed' | 'not_installed') {
return {
agent,
state,
configPath: `/${agent}`,
managedHooksPresent: state === 'installed',
detail: null
} as const
}
describe('managed agent hook controls', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.installClaude.mockReturnValue(status('claude', 'installed'))
mocks.installCodex.mockReturnValue(status('codex', 'installed'))
mocks.removeClaude.mockReturnValue(status('claude', 'not_installed'))
mocks.removeCodex.mockReturnValue(status('codex', 'not_installed'))
})
it('installs only agents with positively detected CLIs', async () => {
mocks.detect.mockResolvedValue({
claude: { state: 'missing' },
codex: { state: 'found' }
})
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
expect(results).toEqual([
expect.objectContaining({
agent: 'claude',
state: 'skipped',
skipReason: 'cli_not_found'
}),
expect.objectContaining({ agent: 'codex', state: 'installed' })
])
})
it('fails closed when CLI detection rejects', async () => {
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).not.toHaveBeenCalled()
expect(results).toEqual([
expect.objectContaining({
agent: 'claude',
state: 'skipped',
skipReason: 'cli_presence_unknown'
}),
expect.objectContaining({
agent: 'codex',
state: 'skipped',
skipReason: 'cli_presence_unknown'
})
])
})
it('removes disabled agents without probing or reinstalling them', async () => {
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
const results = await applyAgentStatusHooksEnabled(true, {
agentCmdOverrides: {},
disabledTuiAgents: ['claude']
})
expect(mocks.removeClaude).toHaveBeenCalledTimes(1)
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
expect(results).toEqual([
expect.objectContaining({ agent: 'claude', state: 'not_installed' }),
expect.objectContaining({ agent: 'codex', state: 'installed' })
])
})
it('does not install an agent disabled while detection was running', async () => {
mocks.detect.mockResolvedValue({
claude: { state: 'found' },
codex: { state: 'found' }
})
await installManagedAgentHooks(
{ agentCmdOverrides: {} },
{ shouldContinue: (agent) => agent !== 'claude' }
)
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
})
it('does not remove an agent enabled by a newer settings update', async () => {
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
await applyAgentStatusHooksEnabled(
true,
{
agentCmdOverrides: {},
disabledTuiAgents: ['claude']
},
{ shouldContinue: () => true }
)
expect(mocks.removeClaude).not.toHaveBeenCalled()
})
it('removes every managed hook when the global setting is off', async () => {
await applyAgentStatusHooksEnabled(false)
expect(mocks.detect).not.toHaveBeenCalled()
expect(mocks.removeClaude).toHaveBeenCalledTimes(1)
expect(mocks.removeCodex).toHaveBeenCalledTimes(1)
})
})
@@ -1,75 +1,34 @@
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { HookInstallAgent } from '../../shared/telemetry-events'
import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types'
import {
getManagedAgentHookTarget,
isManagedAgentHookTarget
} from '../../shared/managed-agent-hook-targets'
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/types'
import { ampHookService } from '../amp/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { droidHookService } from '../droid/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { devinHookService } from '../devin/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
import {
MANAGED_AGENT_HOOK_INSTALLERS,
MANAGED_AGENT_HOOK_REMOVERS,
MANAGED_AGENT_HOOK_STATUS_READERS,
type ManagedAgentHookInstaller
} from './managed-agent-hook-registry'
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => void]
type ManagedHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
type ManagedHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry'
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
['claude', () => claudeHookService.install()],
['openclaude', () => openClaudeHookService.install()],
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['antigravity', () => antigravityHookService.install()],
['amp', () => ampHookService.install()],
['cursor', () => cursorHookService.install()],
['droid', () => droidHookService.install()],
['command-code', () => commandCodeHookService.install()],
['grok', () => grokHookService.install()],
['copilot', () => copilotHookService.install()],
['hermes', () => hermesHookService.install()],
['devin', () => devinHookService.install()],
['kimi', () => kimiHookService.install()]
]
type ManagedHookSettings = Partial<
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
> | null
const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [
['claude', () => claudeHookService.remove()],
['openclaude', () => openClaudeHookService.remove()],
['codex', () => codexHookService.remove()],
['gemini', () => geminiHookService.remove()],
['antigravity', () => antigravityHookService.remove()],
['amp', () => ampHookService.remove()],
['cursor', () => cursorHookService.remove()],
['droid', () => droidHookService.remove()],
['command-code', () => commandCodeHookService.remove()],
['grok', () => grokHookService.remove()],
['copilot', () => copilotHookService.remove()],
['hermes', () => hermesHookService.remove()],
['devin', () => devinHookService.remove()],
['kimi', () => kimiHookService.remove()]
]
type InstallOptions = {
shouldHydrateShellPath?: boolean
onInstallError?: (agent: AgentHookTarget, error: unknown) => void
shouldContinue?: (agent: AgentHookTarget) => boolean
agents?: readonly AgentHookTarget[]
}
const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [
['claude', () => claudeHookService.getStatus()],
['openclaude', () => openClaudeHookService.getStatus()],
['codex', () => codexHookService.getStatus()],
['gemini', () => geminiHookService.getStatus()],
['antigravity', () => antigravityHookService.getStatus()],
['amp', () => ampHookService.getStatus()],
['cursor', () => cursorHookService.getStatus()],
['droid', () => droidHookService.getStatus()],
['grok', () => grokHookService.getStatus()],
['command-code', () => commandCodeHookService.getStatus()],
['copilot', () => copilotHookService.getStatus()],
['hermes', () => hermesHookService.getStatus()],
['devin', () => devinHookService.getStatus()],
['kimi', () => kimiHookService.getStatus()]
]
type RemoveOptions = {
agents?: readonly AgentHookTarget[]
}
export function isAgentStatusHooksEnabled(
settings: Pick<GlobalSettings, 'agentStatusHooksEnabled'> | null | undefined
@@ -77,17 +36,7 @@ export function isAgentStatusHooksEnabled(
return settings?.agentStatusHooksEnabled !== false
}
export function installManagedAgentHooks(): void {
for (const [agent, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
try {
install()
} catch (error) {
console.warn(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
}
}
}
function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallStatus {
function errorStatus(agent: AgentHookTarget, error: unknown): AgentHookInstallStatus {
return {
agent,
state: 'error',
@@ -97,8 +46,110 @@ function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallS
}
}
export function removeManagedAgentHooks(): AgentHookInstallStatus[] {
return LOCAL_MANAGED_HOOK_REMOVERS.map(([agent, remove]) => {
function skippedStatus(
agent: AgentHookTarget,
skipReason: NonNullable<AgentHookInstallStatus['skipReason']>,
detail: string
): AgentHookInstallStatus {
return {
agent,
state: 'skipped',
configPath: '',
managedHooksPresent: false,
detail,
skipReason
}
}
function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookInstaller[] {
if (!options.agents) {
return MANAGED_AGENT_HOOK_INSTALLERS
}
const allowed = new Set(options.agents)
return MANAGED_AGENT_HOOK_INSTALLERS.filter(([agent]) => allowed.has(agent))
}
function runInstaller(
entry: ManagedAgentHookInstaller,
onInstallError: InstallOptions['onInstallError']
): AgentHookInstallStatus {
const [agent, install] = entry
try {
return install()
} catch (error) {
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
try {
onInstallError?.(agent, error)
} catch (telemetryError) {
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
}
return errorStatus(agent, error)
}
}
export async function installManagedAgentHooks(
settings: ManagedHookSettings = null,
options: InstallOptions = {}
): Promise<AgentHookInstallStatus[]> {
const installers = selectedInstallers(options)
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent))
const targets = enabledInstallers.flatMap(([agent]) => {
const target = getManagedAgentHookTarget(agent)
return target ? [target] : []
})
let presenceByAgent
try {
presenceByAgent = await detectLocalManagedAgentCliPresence(targets, settings, {
shouldHydrateShellPath: options.shouldHydrateShellPath
})
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
return installers.map(([agent]) =>
disabled.has(agent)
? skippedStatus(agent, 'agent_disabled', 'Agent is disabled in Settings.')
: skippedStatus(agent, 'cli_presence_unknown', detail)
)
}
const results: AgentHookInstallStatus[] = []
for (const entry of installers) {
const [agent] = entry
if (disabled.has(agent)) {
results.push(skippedStatus(agent, 'agent_disabled', 'Agent is disabled in Settings.'))
continue
}
if (options.shouldContinue && !options.shouldContinue(agent)) {
results.push(
skippedStatus(
agent,
'hooks_disabled',
'Agent status hooks were disabled before install completed.'
)
)
continue
}
const presence = presenceByAgent[agent]
if (presence?.state !== 'found') {
results.push(
skippedStatus(
agent,
presence?.state === 'unknown' ? 'cli_presence_unknown' : 'cli_not_found',
'CLI not found; managed hook install skipped.'
)
)
continue
}
results.push(runInstaller(entry, options.onInstallError))
}
return results
}
export function removeManagedAgentHooks(options: RemoveOptions = {}): AgentHookInstallStatus[] {
const allowed = options.agents ? new Set(options.agents) : null
return MANAGED_AGENT_HOOK_REMOVERS.filter(
([agent]) => allowed === null || allowed.has(agent)
).map(([agent, remove]) => {
try {
return remove()
} catch (error) {
@@ -108,7 +159,7 @@ export function removeManagedAgentHooks(): AgentHookInstallStatus[] {
}
export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
return LOCAL_MANAGED_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
return MANAGED_AGENT_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
try {
return getStatus()
} catch (error) {
@@ -117,10 +168,26 @@ export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
})
}
export function applyAgentStatusHooksEnabled(enabled: boolean): AgentHookInstallStatus[] {
if (enabled) {
installManagedAgentHooks()
return getManagedAgentHookStatuses()
export async function applyAgentStatusHooksEnabled(
enabled: boolean,
settings: ManagedHookSettings = null,
options: InstallOptions = {}
): Promise<AgentHookInstallStatus[]> {
if (!enabled) {
return removeManagedAgentHooks()
}
return removeManagedAgentHooks()
const disabled = normalizeDisabledTuiAgents(settings?.disabledTuiAgents).filter(
isManagedAgentHookTarget
)
const installed = await installManagedAgentHooks(settings, options)
const disabledToRemove = options.shouldContinue
? disabled.filter((agent) => !options.shouldContinue?.(agent))
: disabled
if (disabledToRemove.length === 0) {
return installed
}
const removed = new Map(
removeManagedAgentHooks({ agents: disabledToRemove }).map((status) => [status.agent, status])
)
return installed.map((status) => removed.get(status.agent) ?? status)
}
@@ -0,0 +1,71 @@
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { HookInstallAgent } from '../../shared/telemetry-events'
import { ampHookService } from '../amp/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { devinHookService } from '../devin/hook-service'
import { droidHookService } from '../droid/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
['claude', () => claudeHookService.install()],
['openclaude', () => openClaudeHookService.install()],
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['antigravity', () => antigravityHookService.install()],
['amp', () => ampHookService.install()],
['cursor', () => cursorHookService.install()],
['droid', () => droidHookService.install()],
['command-code', () => commandCodeHookService.install()],
['grok', () => grokHookService.install()],
['copilot', () => copilotHookService.install()],
['hermes', () => hermesHookService.install()],
['devin', () => devinHookService.install()],
['kimi', () => kimiHookService.install()]
]
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
['claude', () => claudeHookService.remove()],
['openclaude', () => openClaudeHookService.remove()],
['codex', () => codexHookService.remove()],
['gemini', () => geminiHookService.remove()],
['antigravity', () => antigravityHookService.remove()],
['amp', () => ampHookService.remove()],
['cursor', () => cursorHookService.remove()],
['droid', () => droidHookService.remove()],
['command-code', () => commandCodeHookService.remove()],
['grok', () => grokHookService.remove()],
['copilot', () => copilotHookService.remove()],
['hermes', () => hermesHookService.remove()],
['devin', () => devinHookService.remove()],
['kimi', () => kimiHookService.remove()]
]
export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusReader[] = [
['claude', () => claudeHookService.getStatus()],
['openclaude', () => openClaudeHookService.getStatus()],
['codex', () => codexHookService.getStatus()],
['gemini', () => geminiHookService.getStatus()],
['antigravity', () => antigravityHookService.getStatus()],
['amp', () => ampHookService.getStatus()],
['cursor', () => cursorHookService.getStatus()],
['droid', () => droidHookService.getStatus()],
['grok', () => grokHookService.getStatus()],
['command-code', () => commandCodeHookService.getStatus()],
['copilot', () => copilotHookService.getStatus()],
['hermes', () => hermesHookService.getStatus()],
['devin', () => devinHookService.getStatus()],
['kimi', () => kimiHookService.getStatus()]
]
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import {
buildManagedHookDetectionCommands,
detectedManagedHookAgents
} from './managed-hook-detection-commands'
describe('managed hook detection commands', () => {
it('omits disabled agents and includes safe command overrides', () => {
const commands = buildManagedHookDetectionCommands(
{
disabledTuiAgents: ['claude'],
agentCmdOverrides: { codex: '/opt/codex custom' }
},
'linux'
)
expect(commands.some((command) => command.id === 'claude')).toBe(false)
expect(commands).toContainEqual({ id: 'codex', cmd: '/opt/codex' })
})
it('maps detected TUI ids back to managed hook targets', () => {
expect(detectedManagedHookAgents(['codex', 'opencode', 'droid'])).toEqual(['codex', 'droid'])
})
})
@@ -0,0 +1,42 @@
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import {
extractExecutableToken,
isSafeOverrideExecutableToken
} from '../../shared/managed-agent-command-token'
import { MANAGED_AGENT_HOOK_TARGETS } from '../../shared/managed-agent-hook-targets'
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/types'
import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands'
export type ManagedHookDetectionSettings = Partial<
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
> | null
export function buildManagedHookDetectionCommands(
settings: ManagedHookDetectionSettings,
platform: NodeJS.Platform
): TuiAgentDetectionCommand[] {
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
return MANAGED_AGENT_HOOK_TARGETS.filter((target) => !disabled.has(target.tuiAgent)).flatMap(
(target) => {
const commands = new Set(target.executableCandidates)
const override = extractExecutableToken(settings?.agentCmdOverrides?.[target.tuiAgent], {
platform
})
if (override && isSafeOverrideExecutableToken(override)) {
commands.add(override)
}
return [...commands].map((cmd) => ({ id: target.tuiAgent, cmd }))
}
)
}
export function detectedManagedHookAgents(values: unknown): AgentHookTarget[] {
if (!Array.isArray(values)) {
return []
}
const detected = new Set(values.filter((value): value is string => typeof value === 'string'))
return MANAGED_AGENT_HOOK_TARGETS.filter((target) => detected.has(target.tuiAgent)).map(
(target) => target.agent
)
}
@@ -6,9 +6,11 @@ import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
const tempHomes: string[] = []
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
async function createTempHome(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'orca-managed-hooks-'))
// Why: mkdir-p probes ancestors; macOS's per-user temp directory can contain hundreds of thousands of entries.
const home = await mkdtemp(join(tempRoot, 'orca-managed-hooks-'))
tempHomes.push(home)
return home
}
+4 -1
View File
@@ -3,6 +3,7 @@ import { basename } from 'node:path'
import { homedir, userInfo } from 'node:os'
import { promisify } from 'node:util'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
import { withManagedHookInstallLock } from './managed-hook-install-lock'
import {
@@ -75,6 +76,7 @@ export async function resolveRelayGrokHome(home: string, signal?: AbortSignal):
export async function installManagedHooks(options?: {
signal?: AbortSignal
hostKeyFingerprint?: string
agents?: readonly AgentHookTarget[]
}): Promise<ManagedHookInstallSummary> {
options?.signal?.throwIfAborted()
const home = homedir()
@@ -93,7 +95,8 @@ export async function installManagedHooks(options?: {
home,
{
grokHomeDir,
signal: options?.signal
signal: options?.signal,
agents: options?.agents
}
)
return {
@@ -713,6 +713,20 @@ describe('remote hook service installers', () => {
expect(byAgent.get('copilot')).toBe('installed')
})
it('installs only positively detected remote agents', async () => {
const { sftp, fs } = createFakeSftp()
const results = await installRemoteManagedAgentHooks(sftp, '/home/dev', {
agents: ['codex']
})
expect(results.map((result) => result.agent)).toEqual(['codex'])
const paths = [...fs.files.keys(), ...fs.dirs]
for (const unusedHome of ['.factory', '.gemini', '.grok', '.hermes', '.commandcode']) {
expect(paths.some((path) => path.includes(`/home/dev/${unusedHome}`))).toBe(false)
}
})
it('stops before the next installer when its relay request is cancelled', async () => {
const controller = new AbortController()
const claudeInstall = vi
@@ -1,5 +1,5 @@
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types'
import { ampHookService } from '../amp/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
@@ -26,6 +26,8 @@ export type RemoteManagedHookInstallOptions = {
/** Stops before starting the next installer when the owning relay request
* is cancelled. Individual filesystem mutations remain atomic. */
signal?: AbortSignal
/** Positively detected and enabled agents allowed to mutate config. */
agents?: readonly AgentHookTarget[]
}
type RemoteManagedHookInstaller = readonly [
@@ -80,7 +82,11 @@ export async function installRemoteManagedAgentHooks(
options?: RemoteManagedHookInstallOptions
): Promise<AgentHookInstallStatus[]> {
const results: AgentHookInstallStatus[] = []
const allowedAgents = options?.agents ? new Set(options.agents) : null
for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) {
if (allowedAgents && !allowedAgents.has(agent)) {
continue
}
// Why: relay requests can disappear during reconnect; do not start more
// user-config mutations after their client has gone away.
options?.signal?.throwIfAborted()
@@ -0,0 +1,559 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSubagentSnapshot } from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { toAppSshPtyId } from '../../shared/ssh-pty-id'
import { AgentHookServer } from './server'
import {
indexPersistedPaneKeyPtyIds,
isLocalExecutionHost,
resolveAgentWorkspaceExecutionHostId,
sweepRestoredSubagentsWithoutLiveAgent
} from './restored-subagent-liveness-sweep'
const LEAF = '11111111-1111-4111-8111-111111111111'
const PANE = makePaneKey('tab-1', LEAF)
const PTY = 'wt-1__pty-1'
const WORKING_CHILD: AgentSubagentSnapshot = {
id: 'areview-loop-c237a4c577493352',
state: 'working',
startedAt: 1_000
}
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-restored-subagent-'))
})
afterEach(() => {
vi.restoreAllMocks()
rmSync(dir, { recursive: true, force: true })
})
/** Persist a pane whose lead has finished but whose roster still holds a working
* child, then restart into a fresh server — the shape a machine sleep leaves
* behind when the child's SubagentStop is lost while Orca is down. */
async function restartWithInFlightSubagent(options?: {
connectionId?: string
state?: 'working' | 'waiting'
subagents?: AgentSubagentSnapshot[]
additionalPaneKey?: string
}): Promise<AgentHookServer> {
const first = new AgentHookServer()
await first.start({ env: 'production', userDataPath: dir })
first.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
connectionId: options?.connectionId ?? null,
payload: {
state: options?.state ?? 'working',
prompt: 'review the PR',
agentType: 'claude',
subagents: options?.subagents ?? [WORKING_CHILD]
}
})
if (options?.additionalPaneKey) {
first.ingestTerminalStatus({
paneKey: options.additionalPaneKey,
tabId: 'tab-2',
worktreeId: 'wt-1',
connectionId: options.connectionId ?? null,
payload: {
state: options.state ?? 'working',
prompt: 'review the PR',
agentType: 'claude',
subagents: options.subagents ?? [WORKING_CHILD]
}
})
}
first.flushStatusPersistSync()
first.stop()
const restarted = new AgentHookServer()
await restarted.start({ env: 'production', userDataPath: dir })
return restarted
}
function sweepWith(
server: AgentHookServer,
overrides: {
probeLiveLocalPty?: (ptyId: string) => boolean | null | Promise<boolean | null>
executionHostId?: string | null
boundPtyIdByPaneKey?: Record<string, string>
persistedPtyIdByPaneKey?: Record<string, string>
} = {}
): Promise<number> {
return sweepRestoredSubagentsWithoutLiveAgent({
probeLiveLocalPty: async (ptyId) =>
overrides.probeLiveLocalPty ? await overrides.probeLiveLocalPty(ptyId) : false,
isLocalExecutionHost: () =>
isLocalExecutionHost(
overrides.executionHostId === undefined ? 'local' : overrides.executionHostId
),
getBoundPtyIdForPaneKey: (paneKey) => overrides.boundPtyIdByPaneKey?.[paneKey],
getPersistedPtyIdForPaneKey: (paneKey) => overrides.persistedPtyIdByPaneKey?.[paneKey],
reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) =>
server.reapRestoredClaudeSubagentsWithoutLiveAgent(
isLocalHost,
isLocalPaneAgentLive,
isLocalPaneLivenessEvidenceCurrent
)
})
}
function paneStatus(
server: AgentHookServer,
paneKey = PANE
): { state: string; subagents?: AgentSubagentSnapshot[] } {
const entry = server.getStatusSnapshotForPane(paneKey)[0]
return { state: entry?.state ?? 'missing', subagents: entry?.subagents }
}
describe('restored subagent liveness sweep', () => {
it('reaps the phantom seed so a slept-through pane reaches done', async () => {
const server = await restartWithInFlightSubagent()
try {
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
const previous = server.getStatusSnapshotForPane(PANE)[0]
const previousReceivedAt = previous?.receivedAt ?? 0
const reconciledAt = previousReceivedAt + 1
const now = vi.spyOn(Date, 'now').mockReturnValue(previousReceivedAt)
expect(await sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).toBe(1)
expect(paneStatus(server)).toEqual({ state: 'done', subagents: undefined })
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
receivedAt: reconciledAt,
stateStartedAt: reconciledAt
})
now.mockRestore()
} finally {
server.stop()
}
})
it('keeps a seed whose pane still has a live local PTY', async () => {
const server = await restartWithInFlightSubagent()
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: (ptyId) => ptyId === PTY,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
} finally {
server.stop()
}
})
it('preserves timing when reaping rows does not change the lead state', async () => {
const server = await restartWithInFlightSubagent({ state: 'waiting' })
try {
const previous = server.getStatusSnapshotForPane(PANE)[0]
expect(await sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).toBe(1)
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
state: 'waiting',
subagents: undefined,
receivedAt: previous?.receivedAt,
stateStartedAt: previous?.stateStartedAt
})
} finally {
server.stop()
}
})
it('uses targeted liveness for a PTY bound in this runtime', async () => {
const server = await restartWithInFlightSubagent()
try {
const probeLiveLocalPty = vi.fn((ptyId: string) => ptyId === PTY)
expect(
await sweepWith(server, {
probeLiveLocalPty,
boundPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('probes only restored rosters and deduplicates a shared exact PTY', async () => {
const otherPane = makePaneKey('tab-2', LEAF)
const server = await restartWithInFlightSubagent({ additionalPaneKey: otherPane })
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY, [otherPane]: PTY }
})
).toBe(2)
expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY)
} finally {
server.stop()
}
})
it('does not probe a hydrated Claude pane without restored rows', async () => {
const server = await restartWithInFlightSubagent({ subagents: [] })
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(probeLiveLocalPty).not.toHaveBeenCalled()
} finally {
server.stop()
}
})
it('never reaps an SSH-launched pane, whose agent cannot appear in a local scan', async () => {
const sshPtyId = toAppSshPtyId('conn-1', PTY)
const server = await restartWithInFlightSubagent()
try {
expect(
await sweepWith(server, {
executionHostId: 'ssh:conn-1',
persistedPtyIdByPaneKey: { [PANE]: sshPtyId }
})
).toBe(0)
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
} finally {
server.stop()
}
})
it('never reaps a relay-owned pane even with no local PTY at all', async () => {
const server = await restartWithInFlightSubagent({ connectionId: 'conn-1' })
try {
expect(await sweepWith(server)).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('never reaps a runtime-hosted pane from the desktop local provider', async () => {
const server = await restartWithInFlightSubagent()
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(
await sweepWith(server, {
executionHostId: 'runtime:ephemeral-vm-1',
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: 'remote:ephemeral-vm-1@@pty-1' }
})
).toBe(0)
expect(probeLiveLocalPty).not.toHaveBeenCalled()
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('does nothing when targeted PTY liveness is unknown', async () => {
const server = await restartWithInFlightSubagent()
try {
expect(
await sweepWith(server, {
probeLiveLocalPty: () => null,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('does nothing without an exact pane PTY binding', async () => {
const server = await restartWithInFlightSubagent()
const probeLiveLocalPty = vi.fn(() => false)
try {
expect(await sweepWith(server, { probeLiveLocalPty })).toBe(0)
expect(probeLiveLocalPty).not.toHaveBeenCalled()
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('skips panes that have reported to this runtime', async () => {
const server = await restartWithInFlightSubagent()
try {
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'now reviewing round two',
agentType: 'claude',
subagents: [WORKING_CHILD]
}
})
expect(await sweepWith(server)).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane that reports while its liveness probe is pending', async () => {
const server = await restartWithInFlightSubagent()
let resolveProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn(
() =>
new Promise<boolean | null>((resolve) => {
resolveProbe = resolve
})
)
try {
const sweep = sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledOnce())
server.ingestTerminalStatus({
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'confirmed during probe',
agentType: 'claude',
subagents: [WORKING_CHILD]
}
})
resolveProbe(false)
expect(await sweep).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane that binds a newer PTY while the old probe is pending', async () => {
const server = await restartWithInFlightSubagent()
const boundPtyIdByPaneKey = { [PANE]: PTY }
let resolveProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn(
() =>
new Promise<boolean | null>((resolve) => {
resolveProbe = resolve
})
)
try {
const sweep = sweepWith(server, { probeLiveLocalPty, boundPtyIdByPaneKey })
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY))
boundPtyIdByPaneKey[PANE] = 'wt-1__pty-new'
resolveProbe(false)
expect(await sweep).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a pane that rebinds its persisted PTY while the probe is pending', async () => {
const server = await restartWithInFlightSubagent()
const boundPtyIdByPaneKey: Record<string, string> = {}
let resolveProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn(
() =>
new Promise<boolean | null>((resolve) => {
resolveProbe = resolve
})
)
try {
const sweep = sweepWith(server, {
probeLiveLocalPty,
boundPtyIdByPaneKey,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY))
boundPtyIdByPaneKey[PANE] = PTY
resolveProbe(false)
expect(await sweep).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
it('keeps a same-id rebind that lands after its probe but before the batch settles', async () => {
const otherPane = makePaneKey('tab-2', LEAF)
const otherPty = 'wt-1__pty-2'
const server = await restartWithInFlightSubagent({ additionalPaneKey: otherPane })
const boundPtyIdByPaneKey: Record<string, string> = {}
let resolveOtherProbe!: (live: boolean | null) => void
const probeLiveLocalPty = vi.fn((ptyId: string) =>
ptyId === PTY
? Promise.resolve(false)
: new Promise<boolean | null>((resolve) => {
resolveOtherProbe = resolve
})
)
try {
const sweep = sweepWith(server, {
probeLiveLocalPty,
boundPtyIdByPaneKey,
persistedPtyIdByPaneKey: { [PANE]: PTY, [otherPane]: otherPty }
})
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledTimes(2))
boundPtyIdByPaneKey[PANE] = PTY
resolveOtherProbe(false)
expect(await sweep).toBe(1)
expect(paneStatus(server).state).toBe('working')
expect(paneStatus(server, otherPane).state).toBe('done')
} finally {
server.stop()
}
})
it('leaves a pane whose lead is genuinely mid-turn at working', async () => {
const server = await restartWithInFlightSubagent()
try {
// Why: the lead's own tool event proves the process is alive; only the
// child-gated 'working' may be re-derived, so the state must survive.
server.ingestTerminalStatus({
paneKey: makePaneKey('tab-2', LEAF),
tabId: 'tab-2',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'still going', agentType: 'claude' }
})
await sweepWith(server)
expect(paneStatus(server, makePaneKey('tab-2', LEAF)).state).toBe('working')
} finally {
server.stop()
}
})
it('reports zero and leaves state alone when targeted liveness throws', async () => {
const server = await restartWithInFlightSubagent()
const probeLiveLocalPty = vi.fn(() => {
throw new Error('daemon unreachable')
})
try {
expect(
await sweepWith(server, {
probeLiveLocalPty,
persistedPtyIdByPaneKey: { [PANE]: PTY }
})
).toBe(0)
expect(paneStatus(server).state).toBe('working')
} finally {
server.stop()
}
})
})
describe('indexPersistedPaneKeyPtyIds', () => {
it('maps layout leaves to pane keys and ignores empty bindings', () => {
expect(
indexPersistedPaneKeyPtyIds({
'tab-1': { ptyIdsByLeafId: { [LEAF]: PTY, 'leaf-empty': '' } },
'tab-2': undefined,
'tab-3': {}
})
).toEqual(new Map([[PANE, PTY]]))
})
})
describe('resolveAgentWorkspaceExecutionHostId', () => {
const localRepo = {
id: 'local-repo',
connectionId: null,
executionHostId: 'local' as const
}
const runtimeRepo = {
id: 'runtime-repo',
connectionId: null,
executionHostId: 'runtime:ephemeral-vm-1' as const
}
const futureHostRepo = {
id: 'future-repo',
connectionId: null,
executionHostId: 'container:future-host'
}
const deps = {
getRepo: (repoId: string) =>
[localRepo, runtimeRepo, futureHostRepo].find((candidate) => candidate.id === repoId),
getWorktreeMeta: (worktreeId: string) => {
if (worktreeId === 'local-repo::/runtime-worktree') {
return { hostId: 'runtime:worktree-owner' }
}
if (worktreeId === 'runtime-repo::/local-worktree') {
return { hostId: 'local' }
}
if (worktreeId === 'local-repo::/future-worktree') {
return { hostId: 'container:future-host' }
}
return undefined
},
getFolderWorkspace: (id: string) =>
id === 'folder-runtime' ? { projectGroupId: 'group-runtime', connectionId: null } : undefined,
getProjectGroups: () => [
{
id: 'group-runtime',
connectionId: null,
executionHostId: 'runtime:ephemeral-vm-1'
}
]
}
it('positively identifies local ownership and rejects runtime hosts', () => {
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/repo', deps)).toBe('local')
expect(resolveAgentWorkspaceExecutionHostId('runtime-repo::/repo', deps)).toBe(
'runtime:ephemeral-vm-1'
)
expect(resolveAgentWorkspaceExecutionHostId('folder:folder-runtime', deps)).toBe(
'runtime:ephemeral-vm-1'
)
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/runtime-worktree', deps)).toBe(
'runtime:worktree-owner'
)
expect(resolveAgentWorkspaceExecutionHostId('runtime-repo::/local-worktree', deps)).toBe(
'local'
)
expect(resolveAgentWorkspaceExecutionHostId('future-repo::/repo', deps)).toBeNull()
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/future-worktree', deps)).toBeNull()
expect(isLocalExecutionHost('local')).toBe(true)
expect(isLocalExecutionHost('runtime:ephemeral-vm-1')).toBe(false)
})
it('treats missing workspace provenance as unknown', () => {
expect(resolveAgentWorkspaceExecutionHostId('missing::/repo', deps)).toBeNull()
expect(resolveAgentWorkspaceExecutionHostId(undefined, deps)).toBeNull()
expect(isLocalExecutionHost(null)).toBe(false)
})
})
@@ -0,0 +1,135 @@
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type { FolderWorkspace, ProjectGroup } from '../../shared/types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
export type RestoredSubagentLivenessSweepDeps = {
/** Targeted provider liveness, or null when the provider cannot prove either state. */
probeLiveLocalPty: (ptyId: string) => Promise<boolean | null>
isLocalExecutionHost: (worktreeId: string | undefined) => boolean
/** PTY bound to this pane in the current session, if it has one. */
getBoundPtyIdForPaneKey: (paneKey: string) => string | undefined
/** PTY this pane was bound to when the session was last persisted; covers panes
* whose surviving daemon session has not been reattached yet. */
getPersistedPtyIdForPaneKey: (paneKey: string) => string | undefined
reap: (
isLocalExecutionHost: (worktreeId: string | undefined) => boolean,
isLocalPaneAgentLive: (paneKey: string) => Promise<boolean>,
isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean
) => Promise<number>
}
/** Drop restored rows only when the owning host is local and its provider proves
* the exact PTY absent. */
export async function sweepRestoredSubagentsWithoutLiveAgent(
deps: RestoredSubagentLivenessSweepDeps
): Promise<number> {
const probesByPtyId = new Map<string, Promise<boolean | null>>()
const boundPtyIdAtProbeByPaneKey = new Map<string, string | undefined>()
return await deps.reap(
(worktreeId) => deps.isLocalExecutionHost(worktreeId),
async (paneKey) => {
const boundPtyId = deps.getBoundPtyIdForPaneKey(paneKey)
boundPtyIdAtProbeByPaneKey.set(paneKey, boundPtyId)
const ptyId = boundPtyId ?? deps.getPersistedPtyIdForPaneKey(paneKey)
if (!ptyId) {
return true
}
try {
let probe = probesByPtyId.get(ptyId)
if (!probe) {
probe = deps.probeLiveLocalPty(ptyId)
probesByPtyId.set(ptyId, probe)
}
const live = await probe
const currentBoundPtyId = deps.getBoundPtyIdForPaneKey(paneKey)
// Why: cold restore can rebind the persisted id while its absence probe is in flight.
return currentBoundPtyId !== boundPtyId || live !== false
} catch {
return true
}
},
(paneKey) =>
!boundPtyIdAtProbeByPaneKey.has(paneKey) ||
deps.getBoundPtyIdForPaneKey(paneKey) === boundPtyIdAtProbeByPaneKey.get(paneKey)
)
}
/** Index the persisted terminal layouts as `paneKey -> ptyId`. Layout leaves are
* the only persisted binding that carries a stable pane key, so tab-level PTY ids
* (legacy numeric panes) are deliberately skipped. */
export function indexPersistedPaneKeyPtyIds(
layoutsByTabId: Record<string, { ptyIdsByLeafId?: Record<string, string> } | undefined>
): Map<string, string> {
const byPaneKey = new Map<string, string>()
for (const [tabId, layout] of Object.entries(layoutsByTabId)) {
for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
if (ptyId) {
byPaneKey.set(`${tabId}:${leafId}`, ptyId)
}
}
}
return byPaneKey
}
type AgentWorkspaceExecutionHostDeps = {
getRepo: (repoId: string) => ExecutionHostOwner | null | undefined
getWorktreeMeta: (worktreeId: string) => { hostId?: string | null } | null | undefined
getFolderWorkspace: (
folderWorkspaceId: string
) => Pick<FolderWorkspace, 'projectGroupId' | 'connectionId'> | null | undefined
getProjectGroups: () => readonly Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>[]
}
type ExecutionHostOwner = {
connectionId?: string | null
executionHostId?: string | null
}
function resolveDeclaredExecutionHost(owner: ExecutionHostOwner): ExecutionHostId | null {
if (owner.executionHostId?.trim()) {
return parseExecutionHostId(owner.executionHostId)?.id ?? null
}
const connectionId = owner.connectionId?.trim()
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
}
/** Resolve persisted workspace ownership; unknown provenance is not local authority. */
export function resolveAgentWorkspaceExecutionHostId(
workspaceId: string | undefined,
deps: AgentWorkspaceExecutionHostDeps
): ExecutionHostId | null {
if (!workspaceId) {
return null
}
const scope = parseWorkspaceKey(workspaceId)
if (scope?.type === 'folder') {
const workspace = deps.getFolderWorkspace(scope.folderWorkspaceId)
const group = workspace
? deps.getProjectGroups().find((candidate) => candidate.id === workspace.projectGroupId)
: undefined
if (!workspace || !group) {
return null
}
return resolveDeclaredExecutionHost({
connectionId: workspace.connectionId ?? group.connectionId,
executionHostId: group.executionHostId
})
}
const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId
const declaredWorktreeHost = deps.getWorktreeMeta(worktreeId)?.hostId?.trim()
if (declaredWorktreeHost) {
return parseExecutionHostId(declaredWorktreeHost)?.id ?? null
}
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
return repo ? resolveDeclaredExecutionHost(repo) : null
}
export function isLocalExecutionHost(hostId: string | null | undefined): boolean {
return parseExecutionHostId(hostId)?.kind === 'local'
}
+91
View File
@@ -25,6 +25,7 @@ import {
normalizeHookPayload,
parseFormEncodedBody,
readRequestBody,
reapRestoredClaudeSubagentsForDeadPane,
reconcileRemoteCodexState,
resolveHookSource,
preparePendingGrokResultDiscovery,
@@ -35,6 +36,11 @@ import {
type AgentHookEventPayload,
type HookListenerState
} from '../../shared/agent-hook-listener'
import {
claudeRosterHasRestoredSnapshotSubagent,
claudeRosterHasWorkingSubagent,
claudeRosterToSnapshots
} from '../../shared/claude-subagent-roster'
import type { AgentHookSource } from '../../shared/agent-hook-relay'
import {
CLAUDE_STATUSLINE_PATHNAME,
@@ -2171,6 +2177,91 @@ export class AgentHookServer {
}
}
/** Second reap path for restored Claude subagent rows: drop the ones whose pane
* has no live local agent process behind it any more. A PTY that dies while Orca
* is down never runs the teardown that clears pane state, so hydrate rebuilds a
* roster nothing can ever retire — the inventory reap needs the parent to emit a
* complete `background_tasks` list and an idle parent never does. The row then
* gates the pane 'working' for the rest of its life and hibernation, which
* requires 'done', can never reclaim the agent's heap.
*
* Both the execution host and relay binding must prove local ownership before
* targeted PTY liveness is consulted. Panes that reported in this runtime are
* also skipped. Returns the number of panes changed. */
async reapRestoredClaudeSubagentsWithoutLiveAgent(
isLocalExecutionHost: (worktreeId: string | undefined) => boolean,
isLocalPaneAgentLive: (paneKey: string) => Promise<boolean>,
isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean
): Promise<number> {
const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = []
for (const [paneKey, entry] of this.state.lastStatusByPaneKey) {
const enriched = entry as EnrichedAgentHookEventPayload
if (
enriched.payload.agentType === 'claude' &&
enriched.connectionId === null &&
isLocalExecutionHost(enriched.worktreeId) &&
claudeRosterHasRestoredSnapshotSubagent(
this.state.claudeSubagentRosterByPaneKey.get(paneKey)
) &&
!this.runtimeObservedStatusPaneKeys.has(paneKey)
) {
candidates.push({ paneKey, entry: enriched })
}
}
const liveness = await Promise.all(
candidates.map(async (candidate) => {
try {
return await isLocalPaneAgentLive(candidate.paneKey)
} catch {
return true
}
})
)
let changedPanes = 0
for (const [index, candidate] of candidates.entries()) {
const { paneKey, entry: enriched } = candidate
if (
liveness[index] ||
!isLocalPaneLivenessEvidenceCurrent(paneKey) ||
this.state.lastStatusByPaneKey.get(paneKey) !== enriched ||
this.runtimeObservedStatusPaneKeys.has(paneKey) ||
!isLocalExecutionHost(enriched.worktreeId)
) {
continue
}
if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) {
continue
}
changedPanes += 1
const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey)
const subagents = claudeRosterToSnapshots(roster)
// Why: the pane's persisted 'working' was the child gate holding a finished
// lead open (subagent events never set lead state). With the last working row
// gone and no process left to report, 'done' is the only truthful state — and
// the one hibernation needs once this pane's agent is restored.
const state =
enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster)
? 'done'
: enriched.payload.state
const stateChanged = state !== enriched.payload.state
const reconciledAt = stateChanged
? Math.max(Date.now(), enriched.receivedAt + 1)
: enriched.receivedAt
const reconciled: EnrichedAgentHookEventPayload = {
...enriched,
receivedAt: reconciledAt,
stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt,
payload: { ...enriched.payload, state, subagents }
}
this.state.lastStatusByPaneKey.set(paneKey, reconciled)
}
if (changedPanes > 0) {
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
}
return changedPanes
}
buildPtyEnv(): Record<string, string> {
if (this.port <= 0 || !this.token) {
return {}
+26 -2
View File
@@ -6,6 +6,11 @@
import type { SFTPWrapper } from 'ssh2'
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import {
buildManagedHookDetectionCommands,
detectedManagedHookAgents,
type ManagedHookDetectionSettings
} from './managed-hook-detection-commands'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
@@ -18,11 +23,30 @@ export async function installWslGuestHooks(options: {
guestHome: string
distro: string
installHooks: typeof installRemoteManagedAgentHooks
settings: ManagedHookDetectionSettings
warn: (message: string) => void
}): Promise<void> {
const { mux, guestHome, distro, installHooks, warn } = options
const { mux, guestHome, distro, installHooks, settings, warn } = options
let agents
try {
const detected = (await mux.request('preflight.detectAgents', {
commands: buildManagedHookDetectionCommands(settings, 'linux')
})) as { agents?: unknown }
agents = detectedManagedHookAgents(detected?.agents)
} catch (error) {
warn(
`[agent-hooks] WSL agent detection for '${distro}' failed: ${
error instanceof Error ? error.message : String(error)
}`
)
return
}
if (agents.length === 0) {
return
}
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome)
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
agents
})
const failed = results.filter((r) => r.state === 'error').length
if (failed > 0) {
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { agentHookServer } from './server'
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { getOpenCodePluginSource } from '../opencode/hook-service'
import type { PluginSources } from '../../relay/plugin-overlay'
@@ -59,6 +60,7 @@ export type WslHookRelayManagerDeps = {
waitForSentinel: typeof waitForWslRelaySentinel
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
installHooks: typeof installRemoteManagedAgentHooks
managedHookSettings: () => ManagedHookDetectionSettings
/** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */
pluginSources: () => PluginSources
warn: (message: string) => void
@@ -89,6 +91,7 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
connectionId
),
installHooks: installRemoteManagedAgentHooks,
managedHookSettings: () => null,
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }),
warn: (message) => console.warn(message),
@@ -7,7 +7,6 @@
// exercises minus the wsl.exe byte transport (validated separately on-rig).
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
@@ -59,7 +58,7 @@ describe.skipIf(process.platform === 'win32')(
})
it('delivers a Claude hook POST from the live relay into ingestRemote and installs guest hooks', async () => {
fakeHome = mkdtempSync(join(tmpdir(), 'wsl-live-home-'))
fakeHome = mkdtempSync(join('/tmp', 'wsl-live-home-'))
const preferredPort = await pickFreePort()
const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim()
@@ -102,6 +101,12 @@ describe.skipIf(process.platform === 'win32')(
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
connectionId
),
managedHookSettings: () => ({
agentCmdOverrides: {
claude: process.execPath,
codex: process.execPath
}
}),
warn: (message) => warns.push(message),
transientRetryDelayMs: 1
})
@@ -3,7 +3,6 @@
// the per-distro relay manager state machine with fault injection.
import { EventEmitter } from 'node:events'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -34,9 +33,13 @@ function createGuestHarness(): GuestHarness {
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const closeCallbacks: (() => void)[] = []
const transport: MultiplexerTransport = {
write: (data) => {
setImmediate(() => relayFeed?.(data))
write: (data, onSettled) => {
setImmediate(() => {
relayFeed?.(data)
onSettled?.({ ok: true })
})
},
supportsWriteSettlement: true,
onData: (cb) => {
clientDataCallbacks.push(cb)
},
@@ -44,21 +47,24 @@ function createGuestHarness(): GuestHarness {
closeCallbacks.push(cb)
}
}
const guestDispatcher = new RelayDispatcher((data: Buffer) => {
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
})
})
const guestDispatcher = new RelayDispatcher(
(data: Buffer, onSettled) => {
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
onSettled({ ok: true })
})
},
{ supportsWriteCallback: true }
)
relayFeed = (data) => guestDispatcher.feed(data)
const mux = new SshChannelMultiplexer(transport)
return { transport, guestDispatcher, mux }
}
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only by
// design (posix.resolve). On a Windows dev host tmpdir() yields C:\ paths the
// bridge correctly refuses; Windows coverage comes from the live rig runs.
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only;
// Windows coverage comes from the live rig runs.
describe.skipIf(process.platform === 'win32')(
'createWslHookSftpAdapter over the guest fs bridge',
() => {
@@ -66,7 +72,7 @@ describe.skipIf(process.platform === 'win32')(
let harness: GuestHarness
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'wsl-guest-home-'))
home = mkdtempSync(join('/tmp', 'wsl-guest-home-'))
harness = createGuestHarness()
registerWslHookFsHandlers(harness.guestDispatcher, home)
})
@@ -166,13 +172,19 @@ describe('WslHookRelayManager', () => {
return child as unknown as ChildProcessWithoutNullStreams & { emitClose: () => void }
}
function guestTransport(registerInstallPlugins = true): MultiplexerTransport {
function guestTransport(
options: { registerInstallPlugins?: boolean; detectedAgents?: string[] } = {}
): MultiplexerTransport {
const { registerInstallPlugins = true, detectedAgents = ['codex'] } = options
const harness = createGuestHarness()
harnesses.push(harness)
registerWslHookFsHandlers(harness.guestDispatcher, home)
harness.guestDispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
replayed: 0
}))
harness.guestDispatcher.onRequest('preflight.detectAgents', async () => ({
agents: detectedAgents
}))
// A guest bundle predating the plugin overlay omits this handler (-32601).
if (registerInstallPlugins) {
harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({
@@ -212,6 +224,7 @@ describe('WslHookRelayManager', () => {
waitForSentinel: vi.fn(async () => guestTransport()),
ingest: vi.fn(),
installHooks: vi.fn(async () => []),
managedHookSettings: () => null,
pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }),
warn: vi.fn(),
transientRetryDelayMs: 1,
@@ -228,7 +241,8 @@ describe('WslHookRelayManager', () => {
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
// Codex is the one agent whose home Orca redirects for WSL sessions.
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`,
agents: ['codex']
})
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
@@ -261,7 +275,7 @@ describe('WslHookRelayManager', () => {
})
it('leaves the overlay dir null when the guest bundle lacks the installPlugins handler', async () => {
const waitForSentinel = vi.fn(async () => guestTransport(false))
const waitForSentinel = vi.fn(async () => guestTransport({ registerInstallPlugins: false }))
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
// Connect still completes (hooks install); the -32601 is swallowed silently.
@@ -272,6 +286,17 @@ describe('WslHookRelayManager', () => {
manager.disposeAll()
})
it('does not mutate managed agent homes when no WSL agents are detected', async () => {
const waitForSentinel = vi.fn(async () => guestTransport({ detectedAgents: [] }))
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir))
expect(deps.installHooks).not.toHaveBeenCalled()
manager.disposeAll()
})
it('resolves the default distro for null and dedupes it with the explicit name', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro(null)
+10 -8
View File
@@ -18,6 +18,7 @@ import {
} from './wsl-hook-relay-deps'
import { wireWslRelayLink } from './wsl-hook-relay-link'
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
import { wslHookRelayStateKey } from './wsl-hook-relay-state-key'
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
@@ -44,10 +45,6 @@ type DistroState = {
lastInstallAt?: number
}
function distroKey(distro: string): string {
return distro.trim().toLowerCase()
}
export class WslHookRelayManager {
private deps: WslHookRelayManagerDeps
private recovery: WslRelayRecovery
@@ -62,12 +59,12 @@ export class WslHookRelayManager {
isDistroRunning: (distro) => this.deps.isDistroRunning(distro),
warn: (message) => this.deps.warn(message),
isDisposed: () => this.disposed,
isCurrent: (state) => this.states.get(distroKey(state.distro)) === state,
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
restart: (distro) => this.ensureForDistro(distro),
dropState: (state) => {
// Why: identity-guarded — a fresh ensure() may own this key by now;
// deleting by key alone would orphan its live relay child.
const key = distroKey(state.distro)
const key = wslHookRelayStateKey(state.distro)
if (this.states.get(key) === state) {
this.states.delete(key)
}
@@ -75,6 +72,10 @@ export class WslHookRelayManager {
})
}
setManagedHookSettingsResolver(resolve: WslHookRelayManagerDeps['managedHookSettings']): void {
this.deps.managedHookSettings = resolve
}
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
ensureForDistro(distro: string | null): void {
if (this.disposed || this.deps.platform() !== 'win32' || !this.deps.remoteHooksEnabled()) {
@@ -89,7 +90,7 @@ export class WslHookRelayManager {
private stateFor(distro: string | null): DistroState | undefined {
// Empty key never matches a real (non-empty) distro state.
return this.states.get(distroKey(distro ?? this.defaultDistro ?? ''))
return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? ''))
}
/** Guest endpoint file path once known; null before first connect
@@ -120,7 +121,7 @@ export class WslHookRelayManager {
if (!distro || this.disposed) {
return
}
const key = distroKey(distro)
const key = wslHookRelayStateKey(distro)
const existing = this.states.get(key)
if (existing) {
if (existing.phase === 'running') {
@@ -280,6 +281,7 @@ export class WslHookRelayManager {
guestHome,
distro: state.distro,
installHooks: this.deps.installHooks,
settings: this.deps.managedHookSettings(),
warn: this.deps.warn
})
// Why: ship OpenCode's status plugin and record the guest overlay dir the
@@ -16,14 +16,20 @@ type FakeChild = ChildProcessWithoutNullStreams & { kill: ReturnType<typeof vi.f
function fakeChild(): FakeChild {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
stdout: EventEmitter & {
pause: ReturnType<typeof vi.fn>
resume: ReturnType<typeof vi.fn>
}
stderr: EventEmitter
stdin: { write: ReturnType<typeof vi.fn> }
stdin: EventEmitter & { write: ReturnType<typeof vi.fn> }
kill: ReturnType<typeof vi.fn>
}
child.stdout = new EventEmitter()
child.stdout = Object.assign(new EventEmitter(), {
pause: vi.fn(),
resume: vi.fn()
})
child.stderr = new EventEmitter()
child.stdin = { write: vi.fn(() => true) }
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(() => true) })
child.kill = vi.fn()
return child as unknown as FakeChild
}
@@ -62,6 +68,10 @@ describe('waitForWslRelaySentinel', () => {
const transport = await promise
expect(typeof transport.write).toBe('function')
expect(typeof transport.onData).toBe('function')
transport.pauseReads?.()
transport.resumeReads?.()
expect(child.stdout.pause).toHaveBeenCalledOnce()
expect(child.stdout.resume).toHaveBeenCalledOnce()
})
it('resolves past leading garbage and hands trailing bytes to onData', async () => {
@@ -149,4 +159,25 @@ describe('waitForWslRelaySentinel', () => {
expect(err.message).toContain('E_FAIL')
expect(err.message).not.toContain(String.fromCharCode(0))
})
it('forwards write(false), callback settlement, and drain from WSL stdin', async () => {
const child = fakeChild()
const callback = vi.fn()
const drain = vi.fn()
const writeMock = child.stdin.write as ReturnType<typeof vi.fn>
writeMock.mockImplementation((_data, onWritten) => {
onWritten(null)
return false
})
const promise = waitForWslRelaySentinel(child)
emitStdout(child, RELAY_SENTINEL)
const transport = await promise
transport.onDrain?.(drain)
expect(transport.write(Buffer.from('frame'), callback)).toBe(false)
expect(callback).toHaveBeenCalledWith({ ok: true })
child.stdin.emit('drain')
expect(drain).toHaveBeenCalledOnce()
expect(transport.supportsWriteSettlement).toBe(true)
})
})
@@ -123,12 +123,15 @@ export function waitForWslRelaySentinel(
pendingChunks.push(trailing)
}
const transport: MultiplexerTransport = {
write: (data) => {
try {
child.stdin.write(data)
} catch {
// Channel already closing — mux close handling takes over.
}
write: (data, onSettled) => {
return child.stdin.write(data, (error?: Error | null) => {
onSettled?.(error ? { ok: false, error } : { ok: true })
})
},
supportsWriteSettlement: true,
onDrain: (cb) => {
child.stdin.on('drain', cb)
return () => child.stdin.off('drain', cb)
},
onData: (cb) => {
dataCallbacks.push(cb)
@@ -143,6 +146,8 @@ export function waitForWslRelaySentinel(
}
},
onClose: (cb) => closeCallbacks.push(cb),
pauseReads: () => child.stdout.pause(),
resumeReads: () => child.stdout.resume(),
close: () => child.kill()
}
resolve(transport)
@@ -0,0 +1,3 @@
export function wslHookRelayStateKey(distro: string): string {
return distro.trim().toLowerCase()
}
@@ -0,0 +1,192 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const processMocks = vi.hoisted(() => ({
spawn: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
spawn: processMocks.spawn
}))
vi.mock('electron', () => ({
app: { getPath: () => 'C:\\orca-review-11407' }
}))
vi.mock('../codex-cli/command', () => ({
resolveClaudeCommand: () => 'claude.exe'
}))
vi.mock('./keychain', () => ({
deleteActiveClaudeKeychainCredentialsStrict: vi.fn(),
deleteManagedClaudeKeychainCredentials: vi.fn(),
readActiveClaudeKeychainCredentials: vi.fn(),
readActiveClaudeKeychainCredentialsStrict: vi.fn(),
readManagedClaudeKeychainCredentials: vi.fn(),
writeActiveClaudeKeychainCredentials: vi.fn(),
writeManagedClaudeKeychainCredentials: vi.fn()
}))
type CommandConfig = {
windowsPath: string
linuxPath: string | null
wslDistro: string | null
}
type FakeChild = EventEmitter & {
stdin: PassThrough
stdout: PassThrough
stderr: PassThrough
kill: ReturnType<typeof vi.fn>
pid: number
}
type CommandRunner = {
runClaudeCommand(
args: string[],
config: CommandConfig,
timeoutMs: number,
options?: { keepStdinOpen?: boolean; signal?: AbortSignal }
): Promise<string>
}
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
}
function createChild(): FakeChild {
const child = new EventEmitter() as FakeChild
child.stdin = new PassThrough()
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.kill = vi.fn()
child.pid = 11407
return child
}
async function createRunner(): Promise<CommandRunner> {
const { ClaudeAccountService } = await import('./service')
return new ClaudeAccountService({} as never, {} as never, {} as never) as unknown as CommandRunner
}
async function flushPromiseCallbacks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('native Windows Claude login completion oracle', () => {
beforeEach(() => {
vi.useFakeTimers()
processMocks.spawn.mockReset()
})
afterEach(() => {
vi.useRealTimers()
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
it('settles exact native Windows auth login on exit exactly once and cleans resources', async () => {
setPlatform('win32')
const child = createChild()
const destroyStdin = vi.spyOn(child.stdin, 'destroy')
const destroyStdout = vi.spyOn(child.stdout, 'destroy')
const destroyStderr = vi.spyOn(child.stderr, 'destroy')
const abortController = new AbortController()
const removeAbortListener = vi.spyOn(abortController.signal, 'removeEventListener')
processMocks.spawn.mockReturnValue(child)
const runner = await createRunner()
let completions = 0
const command = runner
.runClaudeCommand(
['auth', 'login', '--claudeai'],
{ windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null },
1000,
{ keepStdinOpen: true, signal: abortController.signal }
)
.then(() => {
completions += 1
})
child.emit('exit', 0)
await flushPromiseCallbacks()
try {
expect(completions).toBe(1)
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('exit')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
expect(child.stdout.listenerCount('data')).toBe(0)
expect(child.stderr.listenerCount('data')).toBe(0)
expect(destroyStdin).toHaveBeenCalledTimes(1)
expect(destroyStdout).toHaveBeenCalledTimes(1)
expect(destroyStderr).toHaveBeenCalledTimes(1)
expect(removeAbortListener).toHaveBeenCalledTimes(1)
child.emit('close', 0)
await vi.advanceTimersByTimeAsync(1001)
expect(completions).toBe(1)
expect(child.kill).not.toHaveBeenCalled()
expect(destroyStdin).toHaveBeenCalledTimes(1)
expect(destroyStdout).toHaveBeenCalledTimes(1)
expect(destroyStderr).toHaveBeenCalledTimes(1)
expect(removeAbortListener).toHaveBeenCalledTimes(1)
} finally {
child.emit('close', 0)
await command
}
})
it.each([
{
name: 'non-Windows auth login',
platform: 'linux' as const,
args: ['auth', 'login', '--claudeai'],
config: { windowsPath: '/isolated-auth', linuxPath: null, wslDistro: null }
},
{
name: 'unrelated native Windows command containing login',
platform: 'win32' as const,
args: ['config', 'login'],
config: { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null }
},
{
name: 'WSL auth login',
platform: 'win32' as const,
args: ['auth', 'login', '--claudeai'],
config: {
windowsPath: 'C:\\isolated-auth',
linuxPath: '/home/orca/isolated-auth',
wslDistro: 'Ubuntu-Oracle'
}
}
])('preserves close completion for $name', async ({ platform, args, config }) => {
setPlatform(platform)
const child = createChild()
processMocks.spawn.mockReturnValue(child)
const runner = await createRunner()
let completions = 0
const command = runner.runClaudeCommand(args, config, 1000).then(() => {
completions += 1
})
child.emit('exit', 0)
await flushPromiseCallbacks()
expect(completions).toBe(0)
expect(child.listenerCount('close')).toBe(1)
child.emit('close', 0)
await command
expect(completions).toBe(1)
expect(child.listenerCount('close')).toBe(0)
expect(child.kill).not.toHaveBeenCalled()
})
})
+16 -3
View File
@@ -967,6 +967,13 @@ export class ClaudeAccountService {
rejectPromise(new Error('Claude command failed to open output streams.'))
return
}
const completesOnExit =
process.platform === 'win32' &&
configDir.linuxPath === null &&
configDir.wslDistro === null &&
args[0] === 'auth' &&
args[1] === 'login'
const completionEvent = completesOnExit ? 'exit' : 'close'
let settled = false
let output = ''
@@ -990,11 +997,15 @@ export class ClaudeAccountService {
stdout.off('data', appendOutput)
stderr.off('data', appendOutput)
child.off('error', onError)
child.off('close', onClose)
child.off(completionEvent, onDone)
options?.signal?.removeEventListener('abort', onAbort)
if (options?.keepStdinOpen) {
child.stdin?.destroy()
}
if (completesOnExit) {
stdout.destroy()
stderr.destroy()
}
}
const settle = (callback: () => void): void => {
if (settled) {
@@ -1062,7 +1073,7 @@ export class ClaudeAccountService {
}
settle(() => rejectPromise(error))
}
const onClose = (code: number | null): void => {
const onDone = (code: number | null): void => {
if (terminationPending) {
return
}
@@ -1085,7 +1096,9 @@ export class ClaudeAccountService {
stdout.on('data', appendOutput)
stderr.on('data', appendOutput)
child.on('error', onError)
child.on('close', onClose)
// Native Windows browsers can inherit these pipes and indefinitely delay close.
child.on(completionEvent, onDone)
if (options?.signal?.aborted) {
onAbort()
} else {
+164 -5
View File
@@ -1,17 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * 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 type { ClaudeUsagePersistedState } from './types'
import type * as Scanner from './scanner'
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/tmp/orca-test-userdata')
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
// every open would hide a regression back to multiple full-cache rewrites per scan.
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
writeGate: {
blocked: false,
waiters: [] as (() => void)[]
}
}))
import { ClaudeUsageStore } from './store'
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
if (args[1] !== 'w') {
return actual.open(...args)
}
writeOpens.value += 1
writeOpens.inFlight += 1
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
try {
if (writeGate.blocked) {
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
}
return await actual.open(...args)
} finally {
writeOpens.inFlight -= 1
}
}) as typeof actual.open
}
})
vi.mock('./scanner', async (importOriginal) => ({
...(await importOriginal<typeof Scanner>()),
scanClaudeUsageFiles: vi.fn()
}))
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
import { scanClaudeUsageFiles } from './scanner'
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
const store = new ClaudeUsageStore({
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
} as never)
@@ -34,11 +80,32 @@ function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): Claude
}
describe('ClaudeUsageStore', () => {
let tempUserData: string
beforeEach(() => {
tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-'))
getPathMock.mockReturnValue(tempUserData)
initClaudeUsagePath()
writeOpens.value = 0
writeOpens.inFlight = 0
writeOpens.maxConcurrent = 0
writeGate.blocked = false
writeGate.waiters = []
vi.mocked(scanClaudeUsageFiles).mockReset()
vi.mocked(scanClaudeUsageFiles).mockResolvedValue({
processedFiles: [],
sessions: [],
dailyAggregates: []
})
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-09T12:00:00.000-04:00'))
})
afterEach(() => {
vi.useRealTimers()
rmSync(tempUserData, { recursive: true, force: true })
})
it('reports no data for Orca scope when only non-Orca usage exists', async () => {
const store = createStoreWithState({
sessions: [
@@ -549,4 +616,96 @@ describe('ClaudeUsageStore', () => {
expect(refreshMock).toHaveBeenCalledWith(false)
})
it('persists setEnabled via async durable write without leaving tmp files', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
await store.setEnabled(true)
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
const persisted = JSON.parse(
readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')
)
expect(persisted.scanState.enabled).toBe(true)
// Pretty-print preserved for human inspection of the analytics cache.
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
})
it('vetoes a stale concurrent async write so the newer snapshot wins', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
const internals = store as unknown as {
writeToDisk: () => Promise<void>
state: ClaudeUsagePersistedState
}
writeGate.blocked = true
const first = internals.writeToDisk()
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
internals.state.scanState.enabled = false
writeGate.blocked = false
const second = internals.writeToDisk()
writeGate.waiters.splice(0).forEach((resolve) => resolve())
await Promise.all([first, second])
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
.enabled
).toBe(false)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
expect(writeOpens.maxConcurrent).toBe(1)
})
it('persists a successful refresh with one full-cache write', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
await store.refresh(true)
// Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed.
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
).toMatchObject({
lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
lastScanError: null
})
})
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp')
writeFileSync(orphan, '{}')
createStoreWithState({})
await vi.waitFor(() =>
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
)
})
})
+23 -18
View File
@@ -1,7 +1,8 @@
/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */
import { app } from 'electron'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
@@ -331,6 +332,9 @@ export class ClaudeUsageStore {
private state: ClaudeUsagePersistedState
private readonly store: Store
private scanPromise: Promise<void> | null = null
// Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes
// and vetoes superseded renames.
private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile)
constructor(store: Store) {
this.store = store
@@ -375,23 +379,19 @@ export class ClaudeUsageStore {
}
}
private writeToDisk(): void {
const usageFile = getClaudeUsageFile()
const dir = dirname(usageFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
// Why: scans can refresh while the app is in active use. Use the same
// atomic temp-file pattern as the main store so a crash or concurrent write
// cannot leave a truncated analytics file as the common failure mode.
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
renameSync(tmpFile, usageFile)
private writeToDisk(): Promise<void> {
// Pretty-print preserved: humans inspect this analytics cache on disk.
return this.writer.write(() => JSON.stringify(this.state, null, 2))
}
/** Await queued cache writes so quit does not drop the final snapshot. */
flush(): Promise<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<ClaudeUsageScanState> {
this.state.scanState.enabled = enabled
this.writeToDisk()
await this.writeToDisk()
return this.getScanState()
}
@@ -441,8 +441,11 @@ export class ClaudeUsageStore {
this.state.scanState.lastScanStartedAt = Date.now()
this.state.scanState.lastScanError = null
this.writeToDisk()
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
// result changed. The completion/failure write below persists the same fields.
// Why: assign scanPromise before any await so concurrent refresh shares one scan.
this.scanPromise = (async () => {
try {
const repos = this.store.getRepos()
@@ -458,10 +461,12 @@ export class ClaudeUsageStore {
this.state.worktreeFingerprint = worktreeFingerprint
this.state.scanState.lastScanCompletedAt = Date.now()
this.state.scanState.lastScanError = null
this.writeToDisk()
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
await this.writeToDisk().catch(() => {})
} catch (error) {
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
this.writeToDisk()
await this.writeToDisk().catch(() => {})
} finally {
this.scanPromise = null
}
+84 -12
View File
@@ -1,9 +1,9 @@
/* eslint-disable max-lines */
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import type * 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 type * as Fs from 'node:fs'
import type {
CodexUsageDailyAggregate,
CodexUsagePersistedFile,
@@ -11,8 +11,15 @@ import type {
CodexUsageSession
} from './types'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
// every open would hide a regression back to multiple full-cache rewrites per scan.
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
writeGate: {
blocked: false,
waiters: [] as (() => void)[]
}
}))
vi.mock('electron', () => ({
@@ -21,11 +28,26 @@ vi.mock('electron', () => ({
}
}))
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof Fs>('fs')
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
return {
...actual,
writeFileSync: vi.fn(actual.writeFileSync)
open: (async (...args: Parameters<typeof actual.open>) => {
if (args[1] !== 'w') {
return actual.open(...args)
}
writeOpens.value += 1
writeOpens.inFlight += 1
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
try {
if (writeGate.blocked) {
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
}
return await actual.open(...args)
} finally {
writeOpens.inFlight -= 1
}
}) as typeof actual.open
}
})
@@ -97,7 +119,11 @@ describe('CodexUsageStore', () => {
tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-'))
getPathMock.mockReturnValue(tempUserData)
initCodexUsagePath()
vi.mocked(writeFileSync).mockClear()
writeOpens.value = 0
writeOpens.inFlight = 0
writeOpens.maxConcurrent = 0
writeGate.blocked = false
writeGate.waiters = []
vi.mocked(scanCodexUsageFiles).mockReset()
vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult())
vi.useFakeTimers()
@@ -109,7 +135,7 @@ describe('CodexUsageStore', () => {
rmSync(tempUserData, { recursive: true, force: true })
})
it('persists a successful refresh with one compact disk write', async () => {
it('persists a successful refresh with one compact async disk write', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
@@ -122,7 +148,9 @@ describe('CodexUsageStore', () => {
await store.refresh(true)
expect(writeFileSync).toHaveBeenCalledTimes(1)
// Why exactly one: a refresh that rewrites the whole 60 MB cache twice is the regression this guards.
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')
expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson)))
expect(persistedJson).not.toContain('\n')
@@ -155,13 +183,57 @@ describe('CodexUsageStore', () => {
lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
lastScanError: null
})
expect(writeFileSync).not.toHaveBeenCalled()
expect(writeOpens.value).toBe(0)
pendingScan.resolve(createEmptyScanResult())
await refreshPromise
expect(store.getScanState().isScanning).toBe(false)
expect(writeFileSync).toHaveBeenCalledTimes(1)
expect(writeOpens.value).toBe(1)
})
it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
const internals = store as unknown as {
writeToDisk: () => Promise<void>
state: CodexUsagePersistedState
}
writeGate.blocked = true
const first = internals.writeToDisk()
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
internals.state.scanState.enabled = false
writeGate.blocked = false
const second = internals.writeToDisk()
writeGate.waiters.splice(0).forEach((resolve) => resolve())
await Promise.all([first, second])
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')).scanState
.enabled
).toBe(false)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
expect(writeOpens.maxConcurrent).toBe(1)
})
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
const orphan = join(tempUserData, 'orca-codex-usage.json.999.1.abc.tmp')
writeFileSync(orphan, '{}')
createStoreWithState({})
await vi.waitFor(() =>
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
)
})
it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => {
+21 -15
View File
@@ -1,7 +1,8 @@
/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */
import { app } from 'electron'
import { dirname, join } from 'node:path'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
import type {
CodexUsageBreakdownKind,
CodexUsageBreakdownRow,
@@ -366,6 +367,9 @@ export class CodexUsageStore {
private state: CodexUsagePersistedState
private readonly store: Store
private scanPromise: Promise<void> | null = null
// Why: the 60 MB usage JSON must not block the Electron main thread; the writer serializes writes
// and vetoes superseded renames.
private readonly writer = new UsageCacheSnapshotWriter('[codex-usage]', getCodexUsageFile)
constructor(store: Store) {
this.store = store
@@ -393,20 +397,19 @@ export class CodexUsageStore {
}
}
private writeToDisk(): void {
const usageFile = getCodexUsageFile()
const dir = dirname(usageFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state), 'utf-8')
renameSync(tmpFile, usageFile)
private writeToDisk(): Promise<void> {
// Compact: this cache reaches 60 MB, and pretty-printing it costs main-thread time per scan.
return this.writer.write(() => JSON.stringify(this.state))
}
/** Await queued cache writes so quit does not drop the final snapshot. */
flush(): Promise<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<CodexUsageScanState> {
this.state.scanState.enabled = enabled
this.writeToDisk()
await this.writeToDisk()
return this.getScanState()
}
@@ -456,7 +459,8 @@ export class CodexUsageStore {
this.state.scanState.lastScanStartedAt = Date.now()
this.state.scanState.lastScanError = null
// Why: start-only writes rewrite the full usage cache before scan results change.
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
// result changed. The completion/failure write below persists the same fields.
this.scanPromise = (async () => {
try {
@@ -473,10 +477,12 @@ export class CodexUsageStore {
this.state.worktreeFingerprint = worktreeFingerprint
this.state.scanState.lastScanCompletedAt = Date.now()
this.state.scanState.lastScanError = null
this.writeToDisk()
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
await this.writeToDisk().catch(() => {})
} catch (error) {
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
this.writeToDisk()
await this.writeToDisk().catch(() => {})
} finally {
this.scanPromise = null
}
@@ -1,4 +1,5 @@
import {
computerUseClickModifiersValidationMessage,
computerUseHotkeyValidationMessage,
computerUsePressKeyValidationMessage
} from '../../shared/computer-use-key-spec'
@@ -27,6 +28,7 @@ export async function validateComputerProviderActionParams(
validateElementOrCoordinates('Click', params)
validatePositiveInteger(params, 'clickCount')
validateMouseButton(params)
validateClickModifiers(params)
return app
case 'performSecondaryAction':
requireNonNegativeInteger(params, 'elementIndex')
@@ -159,6 +161,17 @@ function validateMouseButton(params: Record<string, unknown>): void {
}
}
function validateClickModifiers(params: Record<string, unknown>): void {
if (params.modifiers === undefined) {
return
}
const modifiers = requireNonEmptyString(params, 'modifiers')
const message = computerUseClickModifiersValidationMessage(modifiers)
if (message) {
throw new RuntimeClientError('invalid_argument', message)
}
}
function validateScrollDirection(params: Record<string, unknown>): void {
const direction = requireNonEmptyString(params, 'direction')
if (direction !== 'up' && direction !== 'down' && direction !== 'left' && direction !== 'right') {
@@ -186,10 +186,18 @@ describe('DesktopScriptProviderClient actions', () => {
ok: true,
capabilities: sampleCapabilities()
})
mockBridgeResponse({
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
})
mockBridgeResponse(
{
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
},
(operation) => {
expect(operation).toMatchObject({
tool: 'click',
modifiers: 'CmdOrCtrl+Shift'
})
}
)
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
@@ -197,6 +205,7 @@ describe('DesktopScriptProviderClient actions', () => {
const result = await client.action('click', {
app: 'Text Editor',
elementIndex: 0,
modifiers: 'CmdOrCtrl+Shift',
noScreenshot: true
})
@@ -112,7 +112,7 @@ export function mapBridgeError(message: string): RuntimeClientError {
return new RuntimeClientError('app_blocked', text)
}
if (
/unsupported capability|hotkey.*require|paste_text requires|GDK is required for non-character key synthesis/i.test(
/unsupported capability|hotkey.*require|paste_text requires|modified clicks require xdotool|GDK is required for non-character key synthesis/i.test(
text
)
) {
@@ -152,6 +152,7 @@ export class DesktopScriptProviderClient {
to_y: optionalNumberParam(params, 'toY'),
click_count: optionalNumberParam(params, 'clickCount'),
mouse_button: optionalStringParam(params, 'mouseButton'),
modifiers: optionalStringParam(params, 'modifiers'),
action: optionalStringParam(params, 'action'),
direction: optionalStringParam(params, 'direction'),
pages: optionalNumberParam(params, 'pages'),
@@ -134,6 +134,7 @@ export type BridgeRequest = {
to_y?: number
click_count?: number
mouse_button?: string
modifiers?: string
action?: string
direction?: string
pages?: number
@@ -278,6 +278,16 @@ describe('MacOSNativeProviderClient', () => {
code: 'invalid_argument',
message: expect.stringContaining('Click requires')
})
await expect(
client.action('click', {
app: 'TextEdit',
elementIndex: 0,
modifiers: 'CmdOrCtrl+A'
})
).rejects.toMatchObject({
code: 'invalid_argument',
message: expect.stringContaining('Click modifiers accept modifier keys only')
})
await expect(client.action('typeText', { app: 'TextEdit', text: '' })).rejects.toMatchObject({
code: 'invalid_argument',
message: expect.stringContaining('Missing text')
+15
View File
@@ -0,0 +1,15 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
export function shouldHandoffDaemonHistory(
keepHistory: boolean | undefined,
owner: DaemonPtyAdapter,
current: DaemonPtyAdapter
): boolean {
return (
keepHistory === true &&
owner !== current &&
owner.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION &&
current.protocolVersion >= HISTORY_SEED_TRANSFER_PROTOCOL_VERSION
)
}
@@ -0,0 +1,73 @@
import type { PtyBackgroundStreamEvent } from '../providers/types'
import { combineUnsubscribes } from './combine-unsubscribes'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
export class DaemonPtyAdapterSubscriptionFanout {
private unsubscribers: (() => void)[] = []
private dataListeners: ((payload: DaemonPtyRouterDataEvent) => void)[] = []
private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = []
constructor(
private readonly adapters: readonly DaemonPtyAdapter[],
onAdapterExit: (id: string) => void
) {
for (const adapter of adapters) {
this.unsubscribers.push(
adapter.onData((payload) => {
for (const listener of this.dataListeners) {
listener(payload)
}
}),
adapter.onExit((payload) => {
onAdapterExit(payload.id)
for (const listener of this.exitListeners) {
listener(payload)
}
})
)
}
}
onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void {
this.dataListeners.push(callback)
return () => {
const index = this.dataListeners.indexOf(callback)
if (index !== -1) {
this.dataListeners.splice(index, 1)
}
}
}
onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void {
return combineUnsubscribes(
this.adapters.map((adapter) => adapter.onBackgroundStreamEvent(callback))
)
}
// Why: main subscribes on the routed provider, so without this the dead-endpoint
// fan-out never reaches the renderer and only the written pane recovers (STA-2373).
onWriteUnavailable(callback: (payload: { id: string }) => void): () => void {
return combineUnsubscribes(this.adapters.map((adapter) => adapter.onWriteUnavailable(callback)))
}
onReplay(_callback: (payload: { id: string; data: string }) => void): () => void {
return () => {}
}
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
this.exitListeners.push(callback)
return () => {
const index = this.exitListeners.indexOf(callback)
if (index !== -1) {
this.exitListeners.splice(index, 1)
}
}
}
dispose(): void {
for (const unsubscribe of this.unsubscribers.splice(0)) {
unsubscribe()
}
}
}
@@ -1180,6 +1180,30 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
})
})
describe('probePtyLiveness', () => {
it('reads daemon truth before a fresh adapter has attached the session', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
const activeSessionIds = (adapter as unknown as { activeSessionIds: Set<string> })
.activeSessionIds
activeSessionIds.clear()
expect(adapter.hasPty(id)).toBe(false)
await expect(adapter.probePtyLiveness(id)).resolves.toBe(true)
await expect(adapter.probePtyLiveness('missing-session')).resolves.toBe(false)
})
it('returns unknown when the daemon cannot answer', async () => {
const client = (
adapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
).client
vi.spyOn(client, 'request').mockRejectedValueOnce(new Error('unavailable'))
await expect(adapter.probePtyLiveness('session')).resolves.toBeNull()
})
})
describe('getBufferSnapshot', () => {
it('returns the daemon model with its absolute stream sequence', async () => {
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
+12
View File
@@ -813,6 +813,18 @@ export class DaemonPtyAdapter implements IPtyProvider {
return this.activeSessionIds.has(id)
}
async probePtyLiveness(id: string): Promise<boolean | null> {
try {
const result = await this.client.request<{ size: { cols: number; rows: number } | null }>(
'getSize',
{ sessionId: id }
)
return result.size !== null
} catch {
return null
}
}
write(id: string, data: string): void {
this.markSessionDirty(id)
// Why recoverable and not just active: rejecting a write asks the pane to remount,
@@ -0,0 +1,20 @@
import type { IPtyProvider } from '../providers/types'
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
export async function probePtyOwners(
id: string,
routed: IPtyProvider | undefined,
possibleOwners: readonly DaemonPtyAdapter[]
): Promise<boolean | null> {
if (routed) {
return routed.probePtyLiveness
? await routed.probePtyLiveness(id)
: (routed.hasPty?.(id) ?? null)
}
const results = await Promise.all(possibleOwners.map((provider) => provider.probePtyLiveness(id)))
return results.some((result) => result === true)
? true
: results.every((result) => result === false)
? false
: null
}
@@ -0,0 +1,15 @@
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type DaemonPtyRouterDataEvent = {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}
export type DaemonPtyRouterExitEvent = {
id: string
code: number
incarnationId?: PtyIncarnationId
}
@@ -0,0 +1,129 @@
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'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyRouter } from './daemon-pty-router'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
import type { DaemonFileLog } from './daemon-file-log'
import type { SubprocessHandle } from './session'
function createMockSubprocess(): SubprocessHandle & {
emitData: (data: string) => void
} {
let onData: ((data: string) => void) | undefined
let onExit: ((code: number) => void) | undefined
return {
pid: 999_999_999,
getForegroundProcess: () => null,
write: vi.fn(),
resize: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExit?.(0), 0)),
forceKill: vi.fn(() => setTimeout(() => onExit?.(137), 0)),
signal: vi.fn(),
onData(callback) {
onData = callback
},
onExit(callback) {
onExit = callback
},
dispose: vi.fn(),
emitData(data: string) {
onData?.(data)
}
}
}
describe('DaemonPtyRouter history handoff', () => {
let testDir: string
let historyPath: string
let legacyServer: DaemonServer
let currentServer: DaemonServer
let legacyAdapter: DaemonPtyAdapter
let currentAdapter: DaemonPtyAdapter
let router: DaemonPtyRouter
let legacySubprocess: ReturnType<typeof createMockSubprocess>
beforeEach(async () => {
testDir = mkdtempSync(join(tmpdir(), 'daemon-router-history-handoff-'))
historyPath = join(testDir, 'history')
const log: DaemonFileLog = { log: () => {}, close: () => {} }
legacyServer = new DaemonServer({
socketPath: getDaemonSocketPath(testDir, 29),
tokenPath: join(testDir, 'legacy.token'),
protocolVersion: 29,
log,
spawnSubprocess: () => {
legacySubprocess = createMockSubprocess()
return legacySubprocess
}
})
currentServer = new DaemonServer({
socketPath: getDaemonSocketPath(testDir, 30),
tokenPath: join(testDir, 'current.token'),
protocolVersion: 30,
log,
spawnSubprocess: createMockSubprocess
})
await Promise.all([legacyServer.start(), currentServer.start()])
legacyAdapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(testDir, 29),
tokenPath: join(testDir, 'legacy.token'),
protocolVersion: 29,
historyPath
})
currentAdapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(testDir, 30),
tokenPath: join(testDir, 'current.token'),
protocolVersion: 30,
historyPath
})
})
afterEach(async () => {
if (router) {
router.dispose()
} else {
legacyAdapter?.dispose()
currentAdapter?.dispose()
}
await Promise.all([legacyServer?.shutdown(), currentServer?.shutdown()])
rmSync(testDir, { recursive: true, force: true })
vi.restoreAllMocks()
})
it('moves a slept v29 session to v30 with its full large history', async () => {
const sessionId = 'large-legacy-session'
const marker = 'V29-HISTORY-HANDOFF-MARKER'
await legacyAdapter.spawn({ sessionId, cols: 400, rows: 24 })
legacySubprocess.emitData(`${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}${marker}`)
router = new DaemonPtyRouter({ current: currentAdapter, legacy: [legacyAdapter] })
await router.discoverLegacySessions()
const client = (
currentAdapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
).client
const request = vi.spyOn(client, 'request')
await router.shutdown(sessionId, { immediate: true, keepHistory: true })
const restored = await router.spawn({ sessionId, cols: 400, rows: 24 })
expect(restored.coldRestore?.scrollback).toContain(marker)
expect(request.mock.calls.map(([type]) => type)).toEqual(
expect.arrayContaining([
'startHistorySeedTransfer',
'appendHistorySeedTransfer',
'finishHistorySeedTransfer'
])
)
await expect(router.getBufferSnapshot(sessionId)).resolves.toMatchObject({
data: expect.stringContaining(marker)
})
expect(currentAdapter.getHistoryManager()?.hasWriter(sessionId)).toBe(true)
})
})

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