Merge remote-tracking branch 'origin/main' into brennanb2025/windows-remote-worktree-tcc

# Conflicts:
#	src/renderer/src/store/slices/ui.ts
This commit is contained in:
Brennan Benson
2026-07-29 19:18:13 -07:00
408 changed files with 48180 additions and 5199 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
+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)
---
+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 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', () => {
@@ -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', () => {
+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'])
})
})
+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
+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
}
})
+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
}
@@ -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)
})
})
+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 }
+4 -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",
@@ -111,6 +106,7 @@
"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",
@@ -198,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",
+483 -17
View File
@@ -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
@@ -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
+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
+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: [] }))
+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'
]
},
{
@@ -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,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') {
@@ -0,0 +1,261 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ComputerProviderSupervisorClient,
DESKTOP_PROVIDER_SUPERVISOR_REQUEST_TIMEOUT_MS
} from './computer-provider-supervisor-client'
import {
COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
type ComputerProviderSupervisorRequest
} from './computer-provider-supervisor-protocol'
describe('ComputerProviderSupervisorClient', () => {
afterEach(() => {
vi.useRealTimers()
})
it('starts only the fixed macOS operation and observes early helper termination', async () => {
const sent: ComputerProviderSupervisorRequest[] = []
const client = new ComputerProviderSupervisorClient((message, callback) => {
sent.push(message)
callback(null)
})
const start = client.startMacOSProvider()
expect(sent).toEqual([
{
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'request',
id: 1,
method: 'macos.start',
params: {}
}
])
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'event',
event: 'macos.sessionTerminated',
sessionId: 'session-1',
error: { code: 'accessibility_error', message: 'helper exited before connect' }
})
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
id: 1,
ok: true,
result: {
sessionId: 'session-1',
socketPath: '/tmp/session-1/provider.sock',
socketToken: 'token-1'
}
})
const started = await start
expect(started).toMatchObject({
sessionId: 'session-1',
socketPath: '/tmp/session-1/provider.sock',
socketToken: 'token-1'
})
await expect(started.termination).rejects.toThrow('helper exited before connect')
})
it('uses fixed claim and release requests for a returned session id', async () => {
const sent: ComputerProviderSupervisorRequest[] = []
const client = new ComputerProviderSupervisorClient((message, callback) => {
sent.push(message)
callback(null)
})
const claim = client.claimMacOSProvider('session-1')
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
id: 1,
ok: true,
result: { claimed: true }
})
await claim
const release = client.releaseMacOSProvider('session-1')
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
id: 2,
ok: true,
result: { released: true }
})
await release
expect(sent.map(({ method, params }) => ({ method, params }))).toEqual([
{ method: 'macos.claim', params: { sessionId: 'session-1' } },
{ method: 'macos.release', params: { sessionId: 'session-1' } }
])
})
it('executes only a fixed desktop request and validates its result', async () => {
const sent: ComputerProviderSupervisorRequest[] = []
const client = new ComputerProviderSupervisorClient((message, callback) => {
sent.push(message)
callback(null)
})
const execution = client.executeDesktopProvider({ tool: 'list_apps' })
expect(sent).toEqual([
{
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'request',
id: 1,
method: 'desktop.execute',
params: { request: { tool: 'list_apps' } }
}
])
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
id: 1,
ok: true,
result: { stdout: '{"ok":true}', stderr: '', error: null }
})
await expect(execution).resolves.toEqual({
stdout: '{"ok":true}',
stderr: '',
error: null
})
})
it('rejects malformed desktop results from the parent boundary', async () => {
const client = new ComputerProviderSupervisorClient(() => {})
const execution = client.executeDesktopProvider({ tool: 'list_apps' })
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
id: 1,
ok: true,
result: { stdout: Buffer.from('unbounded binary'), stderr: '', error: null }
})
await expect(execution).rejects.toThrow('invalid desktop provider result')
})
it('rejects immediately when supervisor IPC delivery fails', async () => {
const client = new ComputerProviderSupervisorClient((_message, callback) => {
callback(new Error('ipc channel closed'))
})
await expect(client.startMacOSProvider()).rejects.toThrow('ipc channel closed')
})
it('rejects malformed session metadata from the parent boundary', async () => {
const client = new ComputerProviderSupervisorClient(() => {})
const start = client.startMacOSProvider()
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
id: 1,
ok: true,
result: { sessionId: 'session-1', socketPath: '', socketToken: '' }
})
await expect(start).rejects.toThrow('returned an invalid macOS session')
})
it('preserves typed supervisor error responses', async () => {
const client = new ComputerProviderSupervisorClient(() => {})
const start = client.startMacOSProvider()
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
id: 1,
ok: false,
error: { code: 'provider_missing', message: 'helper is unavailable' }
})
await expect(start).rejects.toMatchObject({
code: 'provider_missing',
message: 'helper is unavailable'
})
})
it('bounds terminated sessions whose start responses never arrived', async () => {
const client = new ComputerProviderSupervisorClient(() => {})
for (let session = 1; session <= 33; session++) {
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'event',
event: 'macos.sessionTerminated',
sessionId: `session-${session}`,
error: { code: 'accessibility_error', message: `early-${session}` }
})
}
const oldestStart = client.startMacOSProvider()
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
id: 1,
ok: true,
result: {
sessionId: 'session-1',
socketPath: '/tmp/session-1/provider.sock',
socketToken: 'token-1'
}
})
const oldest = await oldestStart
let oldestSettled = false
void oldest.termination.catch(() => {
oldestSettled = true
})
await Promise.resolve()
expect(oldestSettled).toBe(false)
const newestStart = client.startMacOSProvider()
client.handleMessage({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
id: 2,
ok: true,
result: {
sessionId: 'session-33',
socketPath: '/tmp/session-33/provider.sock',
socketToken: 'token-33'
}
})
const newest = await newestStart
await expect(newest.termination).rejects.toThrow('early-33')
client.shutdown()
await expect(oldest.termination).rejects.toThrow('channel shut down')
})
it('bounds an unanswered supervisor request', async () => {
vi.useFakeTimers()
const client = new ComputerProviderSupervisorClient(() => {})
const start = client.startMacOSProvider()
const rejection = expect(start).rejects.toThrow(
'computer provider supervisor macos.start timed out'
)
await vi.advanceTimersByTimeAsync(10_000)
await rejection
})
it('allows the desktop supervisor deadline and reap grace to finish', async () => {
vi.useFakeTimers()
const client = new ComputerProviderSupervisorClient(() => {})
const execution = client.executeDesktopProvider({ tool: 'list_apps' })
const rejection = expect(execution).rejects.toThrow(
'computer provider supervisor desktop.execute timed out'
)
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_SUPERVISOR_REQUEST_TIMEOUT_MS - 1)
let settled = false
void execution.catch(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await rejection
})
})
@@ -0,0 +1,238 @@
import {
COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
isComputerProviderSupervisorMessage,
isStartedSupervisedMacOSProvider,
isSupervisedDesktopProviderResult,
type ComputerProviderSupervisorRequest,
type StartedSupervisedMacOSProvider,
type SupervisedDesktopProviderResult
} from './computer-provider-supervisor-protocol'
import type { BridgeRequest } from './desktop-script-provider-types'
import { RuntimeClientError } from './runtime-client-error'
const SUPERVISOR_REQUEST_TIMEOUT_MS = 10_000
export const DESKTOP_PROVIDER_SUPERVISOR_REQUEST_TIMEOUT_MS = 40_000
const MAX_EARLY_MACOS_TERMINATIONS = 32
type PendingSupervisorRequest = {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: NodeJS.Timeout
}
type MacOSSessionState = {
termination: Promise<never>
rejectTermination: (error: Error) => void
}
type SupervisorSend = (
message: ComputerProviderSupervisorRequest,
callback: (error: Error | null) => void
) => void
export type SupervisedMacOSProviderSession = StartedSupervisedMacOSProvider & {
termination: Promise<never>
}
export class ComputerProviderSupervisorClient {
private nextId = 1
private readonly pending = new Map<number, PendingSupervisorRequest>()
private readonly macOSSessions = new Map<string, MacOSSessionState>()
private readonly earlyMacOSTerminations = new Map<string, RuntimeClientError>()
constructor(private readonly send: SupervisorSend) {}
async startMacOSProvider(): Promise<SupervisedMacOSProviderSession> {
const result = await this.request('macos.start', {})
if (!isStartedSupervisedMacOSProvider(result)) {
throw new RuntimeClientError(
'accessibility_error',
'computer provider supervisor returned an invalid macOS session'
)
}
const state = this.createMacOSSessionState()
this.macOSSessions.set(result.sessionId, state)
const earlyTermination = this.earlyMacOSTerminations.get(result.sessionId)
if (earlyTermination) {
this.earlyMacOSTerminations.delete(result.sessionId)
state.rejectTermination(earlyTermination)
}
return { ...result, termination: state.termination }
}
async claimMacOSProvider(sessionId: string): Promise<void> {
await this.request('macos.claim', { sessionId })
}
async releaseMacOSProvider(sessionId: string): Promise<void> {
try {
await this.request('macos.release', { sessionId })
} finally {
this.macOSSessions.delete(sessionId)
this.earlyMacOSTerminations.delete(sessionId)
}
}
async executeDesktopProvider(request: BridgeRequest): Promise<SupervisedDesktopProviderResult> {
const result = await this.request('desktop.execute', { request })
if (!isSupervisedDesktopProviderResult(result)) {
throw new RuntimeClientError(
'accessibility_error',
'computer provider supervisor returned an invalid desktop provider result'
)
}
return result
}
handleMessage(message: unknown): boolean {
if (!isComputerProviderSupervisorMessage(message)) {
return false
}
if (message.kind === 'event') {
const error = new RuntimeClientError(message.error.code, message.error.message)
const session = this.macOSSessions.get(message.sessionId)
if (session) {
session.rejectTermination(error)
} else {
if (
!this.earlyMacOSTerminations.has(message.sessionId) &&
this.earlyMacOSTerminations.size >= MAX_EARLY_MACOS_TERMINATIONS
) {
const oldestSessionId = this.earlyMacOSTerminations.keys().next().value
if (oldestSessionId) {
this.earlyMacOSTerminations.delete(oldestSessionId)
}
}
this.earlyMacOSTerminations.set(message.sessionId, error)
}
return true
}
const pending = this.pending.get(message.id)
if (!pending) {
return true
}
clearTimeout(pending.timer)
this.pending.delete(message.id)
if (message.ok) {
pending.resolve(message.result)
} else {
pending.reject(new RuntimeClientError(message.error.code, message.error.message))
}
return true
}
shutdown(): void {
const error = new RuntimeClientError(
'accessibility_error',
'computer provider supervisor channel shut down'
)
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(error)
this.pending.delete(id)
}
for (const session of this.macOSSessions.values()) {
session.rejectTermination(error)
}
this.macOSSessions.clear()
this.earlyMacOSTerminations.clear()
}
private request(
method: ComputerProviderSupervisorRequest['method'],
params: Record<string, unknown>
): Promise<unknown> {
const id = this.nextId++
const request = {
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'request',
id,
method,
params
} as ComputerProviderSupervisorRequest
return new Promise((resolve, reject) => {
const timeoutMs =
method === 'desktop.execute'
? DESKTOP_PROVIDER_SUPERVISOR_REQUEST_TIMEOUT_MS
: SUPERVISOR_REQUEST_TIMEOUT_MS
const timer = setTimeout(() => {
this.pending.delete(id)
reject(
new RuntimeClientError(
'action_timeout',
`computer provider supervisor ${method} timed out`
)
)
}, timeoutMs)
this.pending.set(id, { resolve, reject, timer })
try {
this.send(request, (error) => {
if (!error) {
return
}
const pending = this.pending.get(id)
if (!pending) {
return
}
clearTimeout(pending.timer)
this.pending.delete(id)
pending.reject(new RuntimeClientError('accessibility_error', error.message))
})
} catch (error) {
clearTimeout(timer)
this.pending.delete(id)
reject(
new RuntimeClientError(
'accessibility_error',
error instanceof Error ? error.message : String(error)
)
)
}
})
}
private createMacOSSessionState(): MacOSSessionState {
let rejectTermination = (_error: Error): void => {}
const termination = new Promise<never>((_resolve, reject) => {
rejectTermination = reject
})
void termination.catch(() => undefined)
return { termination, rejectTermination }
}
}
const client = new ComputerProviderSupervisorClient((message, callback) => {
if (!process.send || !process.connected) {
callback(new Error('computer provider supervisor IPC is unavailable'))
return
}
process.send(message, callback)
})
export function startSupervisedMacOSProvider(): Promise<SupervisedMacOSProviderSession> {
return client.startMacOSProvider()
}
export function claimSupervisedMacOSProvider(sessionId: string): Promise<void> {
return client.claimMacOSProvider(sessionId)
}
export function releaseSupervisedMacOSProvider(sessionId: string): Promise<void> {
return client.releaseMacOSProvider(sessionId)
}
export function executeSupervisedDesktopProvider(
request: BridgeRequest
): Promise<SupervisedDesktopProviderResult> {
return client.executeDesktopProvider(request)
}
export function handleComputerProviderSupervisorMessage(message: unknown): boolean {
return client.handleMessage(message)
}
export function shutdownComputerProviderSupervisorClient(): void {
client.shutdown()
}
@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from 'vitest'
import { ComputerProviderSupervisorHost } from './computer-provider-supervisor-host'
import { COMPUTER_PROVIDER_SUPERVISOR_CHANNEL } from './computer-provider-supervisor-protocol'
function macOSSupervisor() {
return {
start: vi.fn(() => ({
sessionId: 'session-1',
socketPath: '/tmp/session-1/provider.sock',
socketToken: 'token-1'
})),
claim: vi.fn(),
release: vi.fn(),
shutdown: vi.fn()
}
}
function desktopSupervisor() {
return {
execute: vi.fn(async () => ({ stdout: '{"ok":true}', stderr: '', error: null })),
shutdown: vi.fn()
}
}
describe('ComputerProviderSupervisorHost', () => {
it('routes only fixed domain operations and returns their bounded result', async () => {
const macOS = macOSSupervisor()
const host = new ComputerProviderSupervisorHost(macOS as never)
const sent: unknown[] = []
host.attach((message) => sent.push(message))
expect(
host.handle({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'request',
id: 1,
method: 'macos.start',
params: {}
})
).toBe(true)
await vi.waitFor(() => expect(sent).toHaveLength(1))
expect(macOS.start).toHaveBeenCalledTimes(1)
expect(sent).toEqual([
{
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
id: 1,
ok: true,
result: {
sessionId: 'session-1',
socketPath: '/tmp/session-1/provider.sock',
socketToken: 'token-1'
}
}
])
})
it('routes validated desktop requests without accepting invocation fields', async () => {
const macOS = macOSSupervisor()
const desktop = desktopSupervisor()
const host = new ComputerProviderSupervisorHost(macOS as never, desktop as never)
const sent: unknown[] = []
host.attach((message) => sent.push(message))
expect(
host.handle({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'request',
id: 2,
method: 'desktop.execute',
params: { request: { tool: 'list_apps' } }
})
).toBe(true)
await vi.waitFor(() => expect(sent).toHaveLength(1))
expect(desktop.execute).toHaveBeenCalledWith({ tool: 'list_apps' })
expect(sent).toEqual([
expect.objectContaining({
id: 2,
ok: true,
result: { stdout: '{"ok":true}', stderr: '', error: null }
})
])
expect(
host.handle({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'request',
id: 3,
method: 'desktop.execute',
params: {
request: { tool: 'list_apps' },
command: '/tmp/untrusted',
args: ['--anything']
}
})
).toBe(false)
expect(desktop.execute).toHaveBeenCalledTimes(1)
})
it('does not deliver a stale operation response to a replacement owner', async () => {
type DesktopResult = { stdout: string; stderr: string; error: null }
let resolveExecution = (_result: DesktopResult): void => {}
const desktop = desktopSupervisor()
desktop.execute.mockImplementationOnce(
() =>
new Promise<DesktopResult>((resolve) => {
resolveExecution = resolve
})
)
const host = new ComputerProviderSupervisorHost(macOSSupervisor() as never, desktop as never)
const originalSend = vi.fn()
const replacementSend = vi.fn()
host.attach(originalSend)
host.handle({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'request',
id: 4,
method: 'desktop.execute',
params: { request: { tool: 'list_apps' } }
})
host.shutdown()
host.attach(replacementSend)
resolveExecution({ stdout: '{"ok":true}', stderr: '', error: null })
await new Promise<void>((resolve) => setImmediate(resolve))
expect(originalSend).not.toHaveBeenCalled()
expect(replacementSend).not.toHaveBeenCalled()
})
it('rejects arbitrary executable and argument fields at the protocol boundary', () => {
const macOS = macOSSupervisor()
const host = new ComputerProviderSupervisorHost(macOS as never)
expect(
host.handle({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'request',
id: 1,
method: 'macos.start',
params: { executablePath: '/tmp/untrusted', args: ['--anything'] }
})
).toBe(false)
expect(macOS.start).not.toHaveBeenCalled()
})
it('kills all sessions and detaches delivery when the owner shuts down', () => {
const macOS = macOSSupervisor()
const desktop = desktopSupervisor()
const host = new ComputerProviderSupervisorHost(macOS as never, desktop as never)
const send = vi.fn()
host.attach(send)
host.shutdown()
expect(macOS.shutdown).toHaveBeenCalledTimes(1)
expect(desktop.shutdown).toHaveBeenCalledTimes(1)
expect(
host.handle({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'request',
id: 2,
method: 'macos.start',
params: {}
})
).toBe(false)
expect(macOS.start).not.toHaveBeenCalled()
expect(send).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,95 @@
import {
COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
isComputerProviderSupervisorRequest,
type ComputerProviderSupervisorMessage,
type ComputerProviderSupervisorRequest,
type ComputerProviderSupervisorResponse
} from './computer-provider-supervisor-protocol'
import { DesktopScriptProviderSupervisor } from './desktop-script-provider-supervisor'
import { MacOSNativeProviderSupervisor } from './macos-native-provider-supervisor'
type SupervisorMessageSender = (message: ComputerProviderSupervisorMessage) => void
type SupervisorResponsePayload =
| { id: number; ok: true; result: unknown }
| { id: number; ok: false; error: { code: string; message: string } }
export class ComputerProviderSupervisorHost {
private sender: SupervisorMessageSender | null = null
private ownerGeneration = 0
private readonly macOS: MacOSNativeProviderSupervisor
private readonly desktop: DesktopScriptProviderSupervisor
constructor(macOS?: MacOSNativeProviderSupervisor, desktop?: DesktopScriptProviderSupervisor) {
this.macOS = macOS ?? new MacOSNativeProviderSupervisor((event) => this.sender?.(event))
this.desktop = desktop ?? new DesktopScriptProviderSupervisor()
}
attach(sender: SupervisorMessageSender): void {
this.ownerGeneration++
this.sender = sender
}
handle(message: unknown): boolean {
if (!this.sender || !isComputerProviderSupervisorRequest(message)) {
return false
}
const ownerGeneration = this.ownerGeneration
void this.dispatch(message).then(
(result) => this.send(ownerGeneration, { id: message.id, ok: true, result }),
(error) =>
this.send(ownerGeneration, {
id: message.id,
ok: false,
error: errorPayload(error)
})
)
return true
}
shutdown(): void {
this.ownerGeneration++
this.sender = null
this.macOS.shutdown()
this.desktop.shutdown()
}
private async dispatch(request: ComputerProviderSupervisorRequest): Promise<unknown> {
switch (request.method) {
case 'macos.start':
return this.macOS.start()
case 'macos.claim':
this.macOS.claim(request.params.sessionId)
return { claimed: true }
case 'macos.release':
this.macOS.release(request.params.sessionId)
return { released: true }
case 'desktop.execute':
return this.desktop.execute(request.params.request)
}
}
private send(ownerGeneration: number, response: SupervisorResponsePayload): void {
if (ownerGeneration !== this.ownerGeneration) {
return
}
this.sender?.({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'response',
...response
} as ComputerProviderSupervisorResponse)
}
}
function errorPayload(error: unknown): { code: string; message: string } {
if (
error instanceof Error &&
'code' in error &&
typeof (error as { code: unknown }).code === 'string'
) {
return { code: (error as { code: string }).code, message: error.message }
}
return {
code: 'accessibility_error',
message: error instanceof Error ? error.message : String(error)
}
}
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest'
import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../shared/clipboard-text'
import {
COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
isComputerProviderSupervisorRequest,
isSupervisedDesktopProviderResult
} from './computer-provider-supervisor-protocol'
import { DESKTOP_PROVIDER_REQUEST_MAX_BYTES } from './desktop-script-provider-request-validation'
function desktopRequest(request: Record<string, unknown>): Record<string, unknown> {
return {
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'request',
id: 1,
method: 'desktop.execute',
params: { request }
}
}
describe('computer provider supervisor protocol', () => {
it('accepts exact desktop tool requests with validated nested element state', () => {
expect(
isComputerProviderSupervisorRequest(
desktopRequest({
tool: 'click',
app: 'Text Editor',
element: {
index: 0,
runtimeId: [42, 7],
name: 'Save',
frame: { x: 1, y: 2, width: 30, height: 10 },
actions: ['invoke']
},
click_count: 1,
mouse_button: 'left',
modifiers: 'CmdOrCtrl+Shift',
windowBounds: null
})
)
).toBe(true)
})
it('rejects command, path, argument, environment, and envelope injection', () => {
for (const injected of [
{ command: 'sh' },
{ executablePath: '/tmp/provider' },
{ scriptPath: '/tmp/runtime.py' },
{ args: ['--arbitrary'] },
{ env: { PATH: '/tmp' } },
{ timeout: 1 }
]) {
expect(
isComputerProviderSupervisorRequest(desktopRequest({ tool: 'list_apps', ...injected }))
).toBe(false)
}
expect(
isComputerProviderSupervisorRequest({
...desktopRequest({ tool: 'list_apps' }),
command: 'sh'
})
).toBe(false)
})
it('rejects malformed and incomplete action requests', () => {
const rejected = [
{ tool: 'unknown' },
{ tool: 'click', app: 'Text Editor' },
{ tool: 'click', app: 'Text Editor', x: 1 },
{ tool: 'click', app: 'Text Editor', x: Number.NaN, y: 2 },
{ tool: 'click', app: 'Text Editor', x: 1, y: 2, modifiers: 'A' },
{ tool: 'scroll', app: 'Text Editor', x: 1, y: 2, direction: 'diagonal' },
{
tool: 'drag',
app: 'Text Editor',
from_x: 1,
from_y: 2,
to_x: 3
},
{
tool: 'set_value',
app: 'Text Editor',
element: { index: -1 },
value: 'draft'
},
{
tool: 'click',
app: 'Text Editor',
element: { index: 0, runtimeId: [1, 'untrusted'] }
},
{
tool: 'get_app_state',
app: 'Text Editor',
windowId: 1,
windowIndex: 2
}
]
for (const request of rejected) {
expect(isComputerProviderSupervisorRequest(desktopRequest(request))).toBe(false)
}
})
it('enforces the existing paste limit independently of the envelope cap', () => {
const text = 'x'.repeat(CLIPBOARD_TEXT_WRITE_MAX_BYTES + 1)
expect(
isComputerProviderSupervisorRequest(
desktopRequest({ tool: 'paste_text', app: 'Text Editor', text })
)
).toBe(false)
})
it('rejects oversized and non-serializable requests', () => {
expect(
isComputerProviderSupervisorRequest(
desktopRequest({
tool: 'type_text',
app: 'Text Editor',
text: 'x'.repeat(DESKTOP_PROVIDER_REQUEST_MAX_BYTES)
})
)
).toBe(false)
const cyclic = { tool: 'get_app_state' } as Record<string, unknown>
cyclic.app = cyclic
expect(isComputerProviderSupervisorRequest(desktopRequest(cyclic))).toBe(false)
})
it('validates exact desktop result shapes', () => {
expect(
isSupervisedDesktopProviderResult({
stdout: '{"ok":true}',
stderr: '',
error: null
})
).toBe(true)
expect(
isSupervisedDesktopProviderResult({
stdout: '',
stderr: 'failed',
error: { message: 'failed', killed: false }
})
).toBe(true)
expect(
isSupervisedDesktopProviderResult({
stdout: '',
stderr: '',
error: null,
command: 'sh'
})
).toBe(false)
})
})
@@ -0,0 +1,177 @@
import { isDesktopScriptProviderRequest } from './desktop-script-provider-request-validation'
import type { BridgeRequest } from './desktop-script-provider-types'
export const COMPUTER_PROVIDER_SUPERVISOR_CHANNEL = 'orca:computer-provider-supervisor'
export type ComputerProviderSupervisorRequest =
| {
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
kind: 'request'
id: number
method: 'macos.start'
params: Record<string, never>
}
| {
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
kind: 'request'
id: number
method: 'macos.claim' | 'macos.release'
params: { sessionId: string }
}
| {
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
kind: 'request'
id: number
method: 'desktop.execute'
params: { request: BridgeRequest }
}
export type ComputerProviderSupervisorResponse =
| {
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
kind: 'response'
id: number
ok: true
result: unknown
}
| {
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
kind: 'response'
id: number
ok: false
error: { code: string; message: string }
}
export type ComputerProviderSupervisorEvent = {
channel: typeof COMPUTER_PROVIDER_SUPERVISOR_CHANNEL
kind: 'event'
event: 'macos.sessionTerminated'
sessionId: string
error: { code: string; message: string }
}
export type ComputerProviderSupervisorMessage =
| ComputerProviderSupervisorResponse
| ComputerProviderSupervisorEvent
export type StartedSupervisedMacOSProvider = {
sessionId: string
socketPath: string
socketToken: string
}
export type SupervisedDesktopProviderResult = {
stdout: string
stderr: string
error: { message: string; killed: boolean } | null
}
const REQUEST_KEYS = new Set(['channel', 'kind', 'id', 'method', 'params'])
export function isComputerProviderSupervisorRequest(
value: unknown
): value is ComputerProviderSupervisorRequest {
const record = messageRecord(value)
if (
!record ||
record.channel !== COMPUTER_PROVIDER_SUPERVISOR_CHANNEL ||
record.kind !== 'request' ||
!Number.isSafeInteger(record.id) ||
typeof record.method !== 'string' ||
!hasOnlyKeys(record, REQUEST_KEYS)
) {
return false
}
const params = messageRecord(record.params)
if (!params) {
return false
}
if (record.method === 'macos.start') {
return Object.keys(params).length === 0
}
if (record.method === 'macos.claim' || record.method === 'macos.release') {
return (
typeof params.sessionId === 'string' &&
params.sessionId.length > 0 &&
Object.keys(params).length === 1
)
}
if (record.method === 'desktop.execute') {
return Object.keys(params).length === 1 && isDesktopScriptProviderRequest(params.request)
}
return false
}
export function isComputerProviderSupervisorMessage(
value: unknown
): value is ComputerProviderSupervisorMessage {
const record = messageRecord(value)
if (!record || record.channel !== COMPUTER_PROVIDER_SUPERVISOR_CHANNEL) {
return false
}
if (record.kind === 'event') {
return (
record.event === 'macos.sessionTerminated' &&
typeof record.sessionId === 'string' &&
isErrorPayload(record.error)
)
}
if (record.kind !== 'response' || !Number.isSafeInteger(record.id)) {
return false
}
return record.ok === true || (record.ok === false && isErrorPayload(record.error))
}
export function isStartedSupervisedMacOSProvider(
value: unknown
): value is StartedSupervisedMacOSProvider {
const record = messageRecord(value)
return (
!!record &&
typeof record.sessionId === 'string' &&
record.sessionId.length > 0 &&
typeof record.socketPath === 'string' &&
record.socketPath.length > 0 &&
typeof record.socketToken === 'string' &&
record.socketToken.length > 0
)
}
export function isSupervisedDesktopProviderResult(
value: unknown
): value is SupervisedDesktopProviderResult {
const record = messageRecord(value)
if (
!record ||
!hasOnlyKeys(record, new Set(['stdout', 'stderr', 'error'])) ||
typeof record.stdout !== 'string' ||
typeof record.stderr !== 'string'
) {
return false
}
if (record.error === null) {
return true
}
const error = messageRecord(record.error)
return (
!!error &&
hasOnlyKeys(error, new Set(['message', 'killed'])) &&
typeof error.message === 'string' &&
typeof error.killed === 'boolean'
)
}
function isErrorPayload(value: unknown): value is { code: string; message: string } {
const record = messageRecord(value)
return !!record && typeof record.code === 'string' && typeof record.message === 'string'
}
function messageRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function hasOnlyKeys(value: Record<string, unknown>, allowed: Set<string>): boolean {
return Object.keys(value).every((key) => allowed.has(key))
}
@@ -0,0 +1,322 @@
import { fork, type ChildProcess } from 'node:child_process'
import { join } from 'node:path'
import { ComputerProviderSupervisorHost } from './computer-provider-supervisor-host'
import type { ComputerProviderSupervisorMessage } from './computer-provider-supervisor-protocol'
import { RuntimeClientError } from './runtime-client-error'
export type ComputerSidecarMethod =
| 'capabilities'
| 'listApps'
| 'listWindows'
| 'getAppState'
| 'click'
| 'performSecondaryAction'
| 'scroll'
| 'drag'
| 'typeText'
| 'pressKey'
| 'hotkey'
| 'pasteText'
| 'setValue'
type ComputerSidecarRequest = {
id: number
method: ComputerSidecarMethod
params: unknown
}
type ComputerSidecarResponse =
| { id: number; ok: true; result: unknown }
| { id: number; ok: false; error: { code: string; message: string } }
type PendingRequest = {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: NodeJS.Timeout
}
const REQUEST_TIMEOUT_MS = 60_000
export const COMPUTER_SIDECAR_FORCE_KILL_GRACE_MS = 5_000
// Why: stale children need an error listener without retaining their former owner.
function ignoreStaleChildError(): void {}
function terminateSidecarChild(child: ChildProcess): void {
let exited = false
let forceKillTimer: NodeJS.Timeout | null = null
const onExit = (): void => {
exited = true
if (forceKillTimer) {
clearTimeout(forceKillTimer)
forceKillTimer = null
}
}
child.once('exit', onExit)
try {
child.kill('SIGTERM')
} catch {}
if (exited) {
return
}
forceKillTimer = setTimeout(() => {
forceKillTimer = null
child.off('exit', onExit)
try {
child.kill('SIGKILL')
} catch {}
}, COMPUTER_SIDECAR_FORCE_KILL_GRACE_MS)
forceKillTimer.unref()
}
export class ComputerSidecarProcess {
private child: ChildProcess | null = null
private childListenerCleanup: (() => void) | null = null
private readonly providerSupervisor = new ComputerProviderSupervisorHost()
private nextId = 1
private pending = new Map<number, PendingRequest>()
private queueTail: Promise<void> | null = null
private queueGeneration = 0
constructor(private readonly entryPath: string = getComputerSidecarEntryPath()) {}
call(method: ComputerSidecarMethod, params: unknown): Promise<unknown> {
const generation = this.queueGeneration
const run = () => {
if (generation !== this.queueGeneration) {
throw new RuntimeClientError(
'accessibility_error',
'computer sidecar queue was invalidated; retry the computer-use request'
)
}
return this.send(method, params)
}
const result = this.queueTail ? this.queueTail.then(run, run) : run()
const tail = result.then(
() => undefined,
() => undefined
)
this.queueTail = tail
void tail.finally(() => {
if (this.queueTail === tail) {
this.queueTail = null
}
})
return result
}
shutdown(): void {
const child = this.child
this.child = null
this.queueGeneration++
this.cleanupActiveChildListeners()
this.providerSupervisor.shutdown()
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(new RuntimeClientError('accessibility_error', 'computer sidecar shut down'))
this.pending.delete(id)
}
if (child) {
terminateSidecarChild(child)
}
}
private send(method: ComputerSidecarMethod, params: unknown): Promise<unknown> {
const child = this.ensureStarted()
if (!child.send) {
const error = new RuntimeClientError(
'accessibility_error',
'computer sidecar IPC is unavailable'
)
this.failActiveChild(child, error)
return Promise.reject(error)
}
const id = this.nextId++
const request: ComputerSidecarRequest = { id, method, params }
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
this.shutdown()
reject(new RuntimeClientError('action_timeout', `computer sidecar ${method} timed out`))
}, REQUEST_TIMEOUT_MS)
this.pending.set(id, { resolve, reject, timer })
child.send(request, (error) => {
if (!error) {
return
}
const wrapped = new RuntimeClientError('accessibility_error', error.message)
if (this.child === child) {
this.failActiveChild(child, wrapped)
return
}
clearTimeout(timer)
this.pending.delete(id)
reject(wrapped)
})
})
}
private ensureStarted(): ChildProcess {
if (this.child && !this.child.killed) {
return this.child
}
this.cleanupActiveChildListeners()
this.providerSupervisor.shutdown()
this.child = null
const child = fork(this.entryPath, [], {
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
ORCA_COMPUTER_SIDECAR: '1'
},
...(process.platform === 'win32' ? { windowsHide: true } : {})
})
const onMessage = (message: unknown) => {
if (this.child !== child || this.providerSupervisor.handle(message)) {
return
}
this.handleResponse(message)
}
const onExit = (code: number | null, signal: NodeJS.Signals | null) =>
this.handleExit(child, code, signal)
const onError = (error: Error) => this.handleError(child, error)
child.on('message', onMessage)
child.on('exit', onExit)
child.on('error', onError)
this.childListenerCleanup = () => {
child.off('message', onMessage)
child.off('exit', onExit)
child.off('error', onError)
child.off('error', ignoreStaleChildError)
child.on('error', ignoreStaleChildError)
}
this.child = child
this.providerSupervisor.attach((message) =>
this.sendComputerProviderSupervisorMessage(child, message)
)
return child
}
private handleResponse(message: unknown): void {
if (!isSidecarResponse(message)) {
return
}
const pending = this.pending.get(message.id)
if (!pending) {
return
}
clearTimeout(pending.timer)
this.pending.delete(message.id)
if (message.ok) {
pending.resolve(message.result)
return
}
pending.reject(new RuntimeClientError(message.error.code, message.error.message))
}
private handleExit(
child: ChildProcess,
code: number | null,
signal: NodeJS.Signals | null
): void {
if (this.child !== child) {
return
}
this.cleanupActiveChildListeners()
this.providerSupervisor.shutdown()
this.child = null
this.queueGeneration++
const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`
this.rejectPending(
new RuntimeClientError('accessibility_error', `computer sidecar exited with ${detail}`)
)
}
private handleError(child: ChildProcess, error: Error): void {
if (this.child !== child) {
return
}
this.cleanupActiveChildListeners()
this.failActiveChild(child, new RuntimeClientError('accessibility_error', error.message))
}
private failActiveChild(child: ChildProcess, error: RuntimeClientError): void {
this.cleanupActiveChildListeners()
this.child = null
this.queueGeneration++
this.providerSupervisor.shutdown()
terminateSidecarChild(child)
this.rejectPending(error)
}
private rejectPending(error: Error): void {
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(error)
this.pending.delete(id)
}
}
private cleanupActiveChildListeners(): void {
const cleanup = this.childListenerCleanup
this.childListenerCleanup = null
cleanup?.()
}
private sendComputerProviderSupervisorMessage(
child: ChildProcess,
message: ComputerProviderSupervisorMessage
): void {
if (this.child !== child || !child.send) {
return
}
try {
child.send(message, (error) => {
if (!error || this.child !== child) {
return
}
this.failActiveChild(child, new RuntimeClientError('accessibility_error', error.message))
})
} catch (error) {
if (this.child === child) {
this.failActiveChild(
child,
new RuntimeClientError(
'accessibility_error',
error instanceof Error ? error.message : String(error)
)
)
}
}
}
}
function getComputerSidecarEntryPath(): string {
const app = loadElectronApp()
const appPath = app?.getAppPath() ?? process.cwd()
const isPackaged = app?.isPackaged ?? false
// Why: ELECTRON_RUN_AS_NODE bypasses Electron's packaged asar require integration.
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
return join(basePath, 'out', 'main', 'computer-sidecar.js')
}
function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null {
try {
return require('electron').app
} catch {
return null
}
}
function isSidecarResponse(message: unknown): message is ComputerSidecarResponse {
if (!message || typeof message !== 'object') {
return false
}
const record = message as Record<string, unknown>
return typeof record.id === 'number' && typeof record.ok === 'boolean'
}
@@ -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
})
@@ -1,106 +1,21 @@
import { execFile } from 'node:child_process'
import { executeSupervisedDesktopProvider } from './computer-provider-supervisor-client'
import { RuntimeClientError } from './runtime-client-error'
import type { DesktopScriptPlatform } from './desktop-script-provider-paths'
import type { BridgeRequest } from './desktop-script-provider-types'
const REQUEST_TIMEOUT_MS = 30_000
const FORCE_KILL_GRACE_MS = 1_000
export function execBridge(
platform: DesktopScriptPlatform,
scriptPath: string,
operationPath: string
export async function execBridge(
request: BridgeRequest
): Promise<{ stdout: string; stderr: string }> {
const command = platform === 'windows' ? 'powershell.exe' : 'python3'
const args =
platform === 'windows'
? [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
scriptPath,
operationPath
]
: [scriptPath, operationPath]
return new Promise((resolve, reject) => {
let child: ReturnType<typeof execFile> | null = null
let settled = false
let forceKillTimer: ReturnType<typeof setTimeout> | null = null
const result = await executeSupervisedDesktopProvider(withoutUndefinedValues(request))
if (result.error) {
throw mapBridgeError(result.error.message)
}
return { stdout: result.stdout, stderr: result.stderr }
}
const clearForceKillTimer = (): void => {
if (forceKillTimer) {
clearTimeout(forceKillTimer)
forceKillTimer = null
}
}
const finish = (
error: Error | null,
result?: { stdout: string; stderr: string },
options: { keepForceKillTimer?: boolean } = {}
): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
if (!options.keepForceKillTimer) {
clearForceKillTimer()
}
if (error) {
reject(error)
return
}
resolve(result ?? { stdout: '', stderr: '' })
}
// Why: native automation can hang inside platform APIs; reject promptly,
// then escalate cleanup if the bridge ignores the graceful termination.
const timeout = setTimeout(() => {
child?.kill('SIGTERM')
forceKillTimer = setTimeout(() => {
child?.kill('SIGKILL')
forceKillTimer = null
}, FORCE_KILL_GRACE_MS)
finish(
new RuntimeClientError(
'action_timeout',
`desktop provider timed out after ${REQUEST_TIMEOUT_MS}ms`
),
undefined,
{ keepForceKillTimer: true }
)
}, REQUEST_TIMEOUT_MS)
try {
child = execFile(
command,
args,
{
env: process.env,
maxBuffer: 20 * 1024 * 1024,
timeout: REQUEST_TIMEOUT_MS,
windowsHide: true
},
(error, stdout, stderr) => {
if (error) {
const message = stderr.trim() || stdout.trim() || error.message
finish(
error.killed
? new RuntimeClientError('action_timeout', message)
: mapBridgeError(message)
)
return
}
finish(null, { stdout, stderr })
}
)
child?.once('exit', clearForceKillTimer)
} catch (error) {
finish(mapBridgeError(error instanceof Error ? error.message : String(error)))
}
})
function withoutUndefinedValues(request: BridgeRequest): BridgeRequest {
return Object.fromEntries(
Object.entries(request).filter(([, value]) => value !== undefined)
) as BridgeRequest
}
export function mapBridgeError(message: string): RuntimeClientError {
@@ -112,7 +27,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
)
) {
@@ -1,6 +1,3 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type {
ComputerActionResult,
ComputerListAppsResult,
@@ -27,8 +24,7 @@ import {
} from './desktop-script-provider-params'
import {
desktopScriptPlatform,
resolveDesktopScriptProviderPath,
type DesktopScriptPlatform
resolveDesktopScriptProviderPath
} from './desktop-script-provider-paths'
import type {
BridgeRequest,
@@ -49,11 +45,6 @@ export class DesktopScriptProviderClient {
readonly snapshots = this.snapshotStore.snapshots
private providerCapabilities: ComputerProviderCapabilities | null = null
constructor(
private readonly platform: DesktopScriptPlatform = requiredPlatform(),
private readonly scriptPath: string = requiredScriptPath()
) {}
shutdown(): void {
this.snapshotStore.clear()
this.providerCapabilities = null
@@ -152,6 +143,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'),
@@ -202,27 +194,20 @@ export class DesktopScriptProviderClient {
}
private async callBridge(request: BridgeRequest): Promise<BridgeResponse> {
const operationDirectory = await mkdtemp(join(tmpdir(), 'orca-computer-use-'))
const operationPath = join(operationDirectory, 'operation.json')
const { stdout, stderr } = await execBridge(request)
let response: BridgeResponse
try {
await writeFile(operationPath, JSON.stringify(request), { encoding: 'utf8', mode: 0o600 })
const { stdout, stderr } = await execBridge(this.platform, this.scriptPath, operationPath)
let response: BridgeResponse
try {
response = JSON.parse(stdout) as BridgeResponse
} catch (error) {
throw new RuntimeClientError(
'accessibility_error',
`desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`
)
}
if (!response.ok) {
throw mapBridgeError(response.error ?? stderr)
}
return response
} finally {
await rm(operationDirectory, { force: true, recursive: true })
response = JSON.parse(stdout) as BridgeResponse
} catch (error) {
throw new RuntimeClientError(
'accessibility_error',
`desktop provider returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`
)
}
if (!response.ok) {
throw mapBridgeError(response.error ?? stderr)
}
return response
}
private async readCapabilities(): Promise<ComputerProviderCapabilities> {
@@ -253,22 +238,3 @@ export class DesktopScriptProviderClient {
return renderSnapshot(response.snapshot, noScreenshot)
}
}
function requiredPlatform(): DesktopScriptPlatform {
const platform = desktopScriptPlatform()
if (!platform) {
throw new RuntimeClientError('accessibility_error', 'desktop script provider is not available')
}
return platform
}
function requiredScriptPath(): string {
const scriptPath = resolveDesktopScriptProviderPath()
if (!scriptPath) {
throw new RuntimeClientError(
'accessibility_error',
'desktop script provider script was not found'
)
}
return scriptPath
}
@@ -1,8 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import {
createDesktopScriptProviderClient,
expectDesktopProviderSubprocessStarted,
mockDesktopProviderSubprocessThatIgnoresTimeout,
mockBridgeExecutionError,
mockBridgeResponse,
resetDesktopScriptProviderTestHarness,
sampleBridgeSnapshot,
@@ -117,43 +116,11 @@ describe('DesktopScriptProviderClient errors and capabilities', () => {
})
})
it('rejects when the desktop provider subprocess ignores the exec timeout', async () => {
vi.useFakeTimers()
const kill = vi.fn()
const once = vi.fn()
mockDesktopProviderSubprocessThatIgnoresTimeout({ kill, once })
it('maps supervised provider process failures', async () => {
mockBridgeExecutionError('appNotFound("Missing")')
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
const promise = client.listApps()
let settled = false
void promise.catch(() => {
settled = true
})
await vi.waitFor(expectDesktopProviderSubprocessStarted, { timeout: 1_000 })
await vi.advanceTimersByTimeAsync(30_001)
await vi.runOnlyPendingTimersAsync()
expect(settled).toBe(true)
expect(kill).toHaveBeenCalledWith('SIGTERM')
await expect(promise).rejects.toMatchObject({ code: 'action_timeout' })
})
it('force-kills a timed-out desktop provider subprocess that has not exited', async () => {
vi.useFakeTimers()
const kill = vi.fn()
const once = vi.fn()
mockDesktopProviderSubprocessThatIgnoresTimeout({ kill, once })
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
const promise = client.listApps().catch(() => undefined)
await vi.waitFor(expectDesktopProviderSubprocessStarted, { timeout: 1_000 })
await vi.advanceTimersByTimeAsync(30_001)
expect(kill).toHaveBeenCalledWith('SIGTERM')
await vi.advanceTimersByTimeAsync(1_000)
expect(kill).toHaveBeenCalledWith('SIGKILL')
await promise
await expect(client.listApps()).rejects.toMatchObject({ code: 'app_not_found' })
})
})
@@ -0,0 +1,110 @@
import { execFile, type ExecFileException } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { rmSync } from 'node:fs'
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import type { SupervisedDesktopProviderResult } from './computer-provider-supervisor-protocol'
import {
desktopScriptPlatform,
resolveDesktopScriptProviderPath,
type DesktopScriptPlatform
} from './desktop-script-provider-paths'
import { RuntimeClientError } from './runtime-client-error'
export const DESKTOP_PROVIDER_MAX_BUFFER_BYTES = 20 * 1024 * 1024
export type DesktopProviderExecutionPlan = {
platform: DesktopScriptPlatform
command: string
scriptPath: string
env: NodeJS.ProcessEnv
}
export type DesktopScriptProviderSupervisorDeps = {
platform: () => DesktopScriptPlatform | null
resolveScriptPath: (platform: DesktopScriptPlatform) => string | null
execFile: typeof execFile
randomUUID: () => string
temporaryDirectory: () => string
mkdtemp: typeof mkdtemp
chmod: typeof chmod
writeFile: typeof writeFile
rmSync: typeof rmSync
setTimer: (callback: () => void, timeoutMs: number) => NodeJS.Timeout
clearTimer: (timer: NodeJS.Timeout) => void
}
export function desktopProviderExecutionPlan(
deps: DesktopScriptProviderSupervisorDeps
): DesktopProviderExecutionPlan {
const platform = deps.platform()
if (!platform) {
throw new RuntimeClientError(
'accessibility_error',
'desktop script provider is not available on this platform'
)
}
const scriptPath = deps.resolveScriptPath(platform)
if (!scriptPath) {
throw new RuntimeClientError(
'accessibility_error',
'desktop script provider script was not found'
)
}
return {
platform,
command: platform === 'windows' ? 'powershell.exe' : 'python3',
scriptPath,
env: { ...process.env }
}
}
export function desktopProviderCommandArgs(
plan: DesktopProviderExecutionPlan,
operationPath: string
): string[] {
return plan.platform === 'windows'
? [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
plan.scriptPath,
operationPath
]
: [plan.scriptPath, operationPath]
}
export function desktopProviderExecutionResult(
error: ExecFileException | null,
stdout: string,
stderr: string
): SupervisedDesktopProviderResult {
return {
stdout,
stderr,
error: error
? {
message: stderr.trim() || stdout.trim() || error.message,
killed: error.killed === true
}
: null
}
}
export function createDesktopScriptProviderSupervisorDeps(): DesktopScriptProviderSupervisorDeps {
return {
platform: desktopScriptPlatform,
resolveScriptPath: resolveDesktopScriptProviderPath,
execFile,
randomUUID,
temporaryDirectory: tmpdir,
mkdtemp,
chmod,
writeFile,
rmSync,
setTimer: setTimeout,
clearTimer: clearTimeout
}
}
@@ -0,0 +1,277 @@
import { CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../shared/clipboard-text'
import {
computerUseClickModifiersValidationMessage,
computerUseHotkeyValidationMessage,
computerUsePressKeyValidationMessage
} from '../../shared/computer-use-key-spec'
import type { BridgeElement, BridgeFrame, BridgeRequest } from './desktop-script-provider-types'
export const DESKTOP_PROVIDER_REQUEST_MAX_BYTES = 17 * 1024 * 1024
const SERIALIZED_REQUESTS = new WeakMap<object, string>()
const APP_TARGET_KEYS = ['tool', 'app', 'windowId', 'windowIndex', 'noScreenshot', 'restoreWindow']
const ACTION_BASE_KEYS = [...APP_TARGET_KEYS, 'windowBounds']
const TOOL_KEYS = new Map<string, Set<string>>([
['handshake', keys('tool')],
['list_apps', keys('tool')],
['list_windows', keys('tool', 'app')],
['get_app_state', keys(...APP_TARGET_KEYS)],
[
'click',
keys(...ACTION_BASE_KEYS, 'element', 'x', 'y', 'click_count', 'mouse_button', 'modifiers')
],
['perform_secondary_action', keys(...ACTION_BASE_KEYS, 'element', 'action')],
['scroll', keys(...ACTION_BASE_KEYS, 'element', 'x', 'y', 'direction', 'pages')],
[
'drag',
keys(...ACTION_BASE_KEYS, 'fromElement', 'toElement', 'from_x', 'from_y', 'to_x', 'to_y')
],
['type_text', keys(...ACTION_BASE_KEYS, 'text')],
['press_key', keys(...ACTION_BASE_KEYS, 'key')],
['hotkey', keys(...ACTION_BASE_KEYS, 'key')],
['paste_text', keys(...ACTION_BASE_KEYS, 'text')],
['set_value', keys(...ACTION_BASE_KEYS, 'element', 'value')]
])
const ELEMENT_KEYS = keys(
'index',
'runtimeId',
'automationId',
'name',
'controlType',
'localizedControlType',
'className',
'value',
'isSelected',
'nativeWindowHandle',
'frame',
'actions'
)
const FRAME_KEYS = keys('x', 'y', 'width', 'height')
export function isDesktopScriptProviderRequest(value: unknown): value is BridgeRequest {
const request = record(value)
if (!request || typeof request.tool !== 'string') {
return false
}
const allowedKeys = TOOL_KEYS.get(request.tool)
if (!allowedKeys || !hasOnlyKeys(request, allowedKeys) || !isWithinRequestLimit(request)) {
return false
}
if (request.tool === 'handshake' || request.tool === 'list_apps') {
return true
}
if (!isNonEmptyString(request.app)) {
return false
}
if (request.tool === 'list_windows') {
return true
}
if (!hasValidAppTarget(request)) {
return false
}
if (request.tool === 'get_app_state') {
return true
}
if (!isOptionalFrameOrNull(request.windowBounds)) {
return false
}
return hasValidAction(request)
}
export function serializeDesktopScriptProviderRequest(request: BridgeRequest): string {
const cached = SERIALIZED_REQUESTS.get(request)
if (cached !== undefined) {
SERIALIZED_REQUESTS.delete(request)
return cached
}
return JSON.stringify(request)
}
function hasValidAppTarget(request: Record<string, unknown>): boolean {
return (
isOptionalFiniteNumber(request.windowId) &&
isOptionalFiniteNumber(request.windowIndex) &&
!(request.windowId !== undefined && request.windowIndex !== undefined) &&
isOptionalBoolean(request.noScreenshot) &&
isOptionalBoolean(request.restoreWindow)
)
}
function hasValidAction(request: Record<string, unknown>): boolean {
switch (request.tool) {
case 'click':
return (
isElementOrCoordinateTarget(request, 'element', 'x', 'y') &&
isOptionalPositiveInteger(request.click_count) &&
isOptionalEnum(request.mouse_button, ['left', 'right', 'middle']) &&
isValidClickModifiers(request.modifiers)
)
case 'perform_secondary_action':
return isElement(request.element) && isNonEmptyString(request.action)
case 'scroll':
return (
isElementOrCoordinateTarget(request, 'element', 'x', 'y') &&
isOptionalEnum(request.direction, ['up', 'down', 'left', 'right'], true) &&
isOptionalPositiveNumber(request.pages)
)
case 'drag':
return isDragTarget(request)
case 'type_text':
return isNonEmptyString(request.text)
case 'press_key':
return (
isNonEmptyString(request.key) && computerUsePressKeyValidationMessage(request.key) === null
)
case 'hotkey':
return (
isNonEmptyString(request.key) && computerUseHotkeyValidationMessage(request.key) === null
)
case 'paste_text':
return (
isNonEmptyString(request.text) &&
Buffer.byteLength(request.text, 'utf8') <= CLIPBOARD_TEXT_WRITE_MAX_BYTES
)
case 'set_value':
return isElement(request.element) && typeof request.value === 'string'
default:
return false
}
}
function isValidClickModifiers(value: unknown): value is string | undefined {
return (
value === undefined ||
(isNonEmptyString(value) && computerUseClickModifiersValidationMessage(value) === null)
)
}
function isElementOrCoordinateTarget(
request: Record<string, unknown>,
elementKey: string,
xKey: string,
yKey: string
): boolean {
const hasElement = request[elementKey] !== undefined
const hasX = request[xKey] !== undefined
const hasY = request[yKey] !== undefined
return (
hasElement !== (hasX && hasY) &&
hasX === hasY &&
(!hasElement || isElement(request[elementKey])) &&
isOptionalFiniteNumber(request[xKey]) &&
isOptionalFiniteNumber(request[yKey])
)
}
function isDragTarget(request: Record<string, unknown>): boolean {
const hasElements = request.fromElement !== undefined || request.toElement !== undefined
const coordinates = [request.from_x, request.from_y, request.to_x, request.to_y]
const hasCoordinates = coordinates.some((value) => value !== undefined)
if (hasElements === hasCoordinates) {
return false
}
return hasElements
? isElement(request.fromElement) && isElement(request.toElement)
: coordinates.every((value) => typeof value === 'number' && Number.isFinite(value))
}
function isElement(value: unknown): value is BridgeElement {
const element = record(value)
return (
!!element &&
hasOnlyKeys(element, ELEMENT_KEYS) &&
Number.isSafeInteger(element.index) &&
(element.index as number) >= 0 &&
(element.runtimeId === undefined ||
(Array.isArray(element.runtimeId) &&
element.runtimeId.every((part) => Number.isSafeInteger(part)))) &&
isOptionalString(element.automationId) &&
isOptionalString(element.name) &&
isOptionalString(element.controlType) &&
isOptionalString(element.localizedControlType) &&
isOptionalString(element.className) &&
isOptionalString(element.value) &&
isOptionalBoolean(element.isSelected) &&
isOptionalFiniteNumber(element.nativeWindowHandle) &&
isOptionalFrameOrNull(element.frame) &&
(element.actions === undefined ||
(Array.isArray(element.actions) &&
element.actions.every((action) => typeof action === 'string')))
)
}
function isOptionalFrameOrNull(value: unknown): value is BridgeFrame | null | undefined {
if (value === undefined || value === null) {
return true
}
const frame = record(value)
return (
!!frame &&
hasOnlyKeys(frame, FRAME_KEYS) &&
Number.isFinite(frame.x) &&
Number.isFinite(frame.y) &&
Number.isFinite(frame.width) &&
Number.isFinite(frame.height)
)
}
function isWithinRequestLimit(request: Record<string, unknown>): boolean {
try {
const serialized = JSON.stringify(request)
const withinLimit =
typeof serialized === 'string' &&
Buffer.byteLength(serialized, 'utf8') <= DESKTOP_PROVIDER_REQUEST_MAX_BYTES
if (withinLimit) {
SERIALIZED_REQUESTS.set(request, serialized)
}
return withinLimit
} catch {
return false
}
}
function isOptionalEnum(
value: unknown,
allowed: readonly string[],
required = false
): value is string | undefined {
return value === undefined ? !required : typeof value === 'string' && allowed.includes(value)
}
function isOptionalPositiveInteger(value: unknown): value is number | undefined {
return value === undefined || (Number.isSafeInteger(value) && (value as number) > 0)
}
function isOptionalPositiveNumber(value: unknown): value is number | undefined {
return value === undefined || (typeof value === 'number' && Number.isFinite(value) && value > 0)
}
function hasOnlyKeys(value: Record<string, unknown>, allowed: Set<string>): boolean {
return Object.keys(value).every((key) => allowed.has(key))
}
function keys(...values: string[]): Set<string> {
return new Set(values)
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}
function isOptionalString(value: unknown): value is string | undefined {
return value === undefined || typeof value === 'string'
}
function isOptionalFiniteNumber(value: unknown): value is number | undefined {
return value === undefined || (typeof value === 'number' && Number.isFinite(value))
}
function isOptionalBoolean(value: unknown): value is boolean | undefined {
return value === undefined || typeof value === 'boolean'
}
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
@@ -0,0 +1,317 @@
import type { ExecFileException } from 'node:child_process'
import { EventEmitter } from 'node:events'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
DESKTOP_PROVIDER_MAX_BUFFER_BYTES,
type DesktopScriptProviderSupervisorDeps
} from './desktop-script-provider-process-invocation'
import {
DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS,
DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS,
DesktopScriptProviderSupervisor
} from './desktop-script-provider-supervisor'
type ExecCallback = (error: ExecFileException | null, stdout: string, stderr: string) => void
class FakeChild extends EventEmitter {
pid: number | undefined = 4321
kill = vi.fn(() => true)
}
function deferred<T>() {
let resolve = (_value: T): void => {}
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
async function flushPreparation(): Promise<void> {
for (let turn = 0; turn < 8; turn++) {
await Promise.resolve()
}
}
describe('DesktopScriptProviderSupervisor', () => {
let callback: ExecCallback | null
let child: FakeChild
let deps: DesktopScriptProviderSupervisorDeps
beforeEach(() => {
vi.useFakeTimers()
callback = null
child = new FakeChild()
deps = {
platform: vi.fn<DesktopScriptProviderSupervisorDeps['platform']>(() => 'linux'),
resolveScriptPath: vi.fn(() => '/fixed/runtime.py'),
execFile: vi.fn((_command, _args, _options, execCallback) => {
callback = execCallback as ExecCallback
return child as never
}) as never,
randomUUID: vi.fn(() => 'operation-1'),
temporaryDirectory: vi.fn(() => '/private/tmp'),
mkdtemp: vi.fn(
async () => '/private/tmp/orca-computer-use-operation'
) as unknown as DesktopScriptProviderSupervisorDeps['mkdtemp'],
chmod: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
rmSync: vi.fn(),
setTimer: (handler, timeoutMs) => setTimeout(handler, timeoutMs),
clearTimer: (timer) => clearTimeout(timer)
}
})
afterEach(() => {
vi.useRealTimers()
})
it('constructs the fixed Linux invocation and waits for callback plus exit', async () => {
const supervisor = new DesktopScriptProviderSupervisor(deps)
const result = supervisor.execute({ tool: 'list_apps' })
await flushPreparation()
const operationPath = join('/private/tmp/orca-computer-use-operation', 'operation.json')
expect(deps.mkdtemp).toHaveBeenCalledWith(join('/private/tmp', 'orca-computer-use-'))
expect(deps.chmod).toHaveBeenCalledWith('/private/tmp/orca-computer-use-operation', 0o700)
expect(deps.writeFile).toHaveBeenCalledWith(operationPath, '{"tool":"list_apps"}', {
encoding: 'utf8',
mode: 0o600
})
expect(deps.execFile).toHaveBeenCalledWith(
'python3',
['/fixed/runtime.py', operationPath],
expect.objectContaining({
encoding: 'utf8',
maxBuffer: DESKTOP_PROVIDER_MAX_BUFFER_BYTES,
windowsHide: true
}),
expect.any(Function)
)
callback!(null, '{"ok":true}', '')
let settled = false
void result.finally(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
expect(deps.rmSync).not.toHaveBeenCalled()
child.emit('exit', 0, null)
await expect(result).resolves.toEqual({
stdout: '{"ok":true}',
stderr: '',
error: null
})
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-operation', {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 100
})
})
it('waits for the exec callback when exit arrives first', async () => {
const supervisor = new DesktopScriptProviderSupervisor(deps)
const result = supervisor.execute({ tool: 'list_apps' })
await flushPreparation()
child.emit('exit', 0, null)
let settled = false
void result.finally(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
expect(deps.rmSync).not.toHaveBeenCalled()
callback!(null, '{"ok":true}', '')
await expect(result).resolves.toMatchObject({ error: null })
expect(deps.rmSync).toHaveBeenCalledTimes(1)
})
it('reuses the main-selected platform, script, and environment plan', async () => {
const supervisor = new DesktopScriptProviderSupervisor(deps)
const first = supervisor.execute({ tool: 'list_apps' })
await flushPreparation()
callback!(null, '{"ok":true}', '')
child.emit('exit', 0, null)
await first
const second = supervisor.execute({ tool: 'list_apps' })
await flushPreparation()
callback!(null, '{"ok":true}', '')
child.emit('exit', 0, null)
await second
expect(deps.platform).toHaveBeenCalledTimes(1)
expect(deps.resolveScriptPath).toHaveBeenCalledTimes(1)
expect(deps.execFile).toHaveBeenCalledTimes(2)
})
it('escalates a Linux deadline while retaining state until confirmed exit', async () => {
const supervisor = new DesktopScriptProviderSupervisor(deps)
const result = supervisor.execute({ tool: 'list_apps' })
const rejection = expect(result).rejects.toMatchObject({ code: 'action_timeout' })
await flushPreparation()
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS)
await rejection
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
expect(deps.rmSync).not.toHaveBeenCalled()
expect(child.listenerCount('exit')).toBe(1)
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS)
expect(child.kill).toHaveBeenLastCalledWith('SIGKILL')
expect(deps.rmSync).not.toHaveBeenCalled()
child.emit('exit', null, 'SIGKILL')
expect(deps.rmSync).toHaveBeenCalledTimes(1)
expect(child.listenerCount('exit')).toBe(0)
})
it('cancels Linux force escalation after the timed-out child exits', async () => {
const supervisor = new DesktopScriptProviderSupervisor(deps)
const result = supervisor.execute({ tool: 'list_apps' })
const rejection = expect(result).rejects.toMatchObject({ code: 'action_timeout' })
await flushPreparation()
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS)
await rejection
child.emit('exit', null, 'SIGTERM')
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS)
expect(child.kill).toHaveBeenCalledTimes(1)
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
expect(deps.rmSync).toHaveBeenCalledTimes(1)
})
it('uses forceful Windows termination without POSIX signal escalation', async () => {
vi.mocked(deps.platform).mockReturnValue('windows')
vi.mocked(deps.resolveScriptPath).mockReturnValue('/fixed/runtime.ps1')
const supervisor = new DesktopScriptProviderSupervisor(deps)
const result = supervisor.execute({ tool: 'list_apps' })
const rejection = expect(result).rejects.toMatchObject({ code: 'action_timeout' })
await flushPreparation()
expect(deps.execFile).toHaveBeenCalledWith(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
'/fixed/runtime.ps1',
join('/private/tmp/orca-computer-use-operation', 'operation.json')
],
expect.any(Object),
expect.any(Function)
)
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS)
await rejection
expect(child.kill).toHaveBeenCalledTimes(1)
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
await vi.advanceTimersByTimeAsync(DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS)
expect(child.kill).toHaveBeenCalledTimes(1)
child.emit('exit', null, 'SIGTERM')
expect(deps.rmSync).toHaveBeenCalledTimes(1)
})
it('cancels an operation before directory creation completes', async () => {
const directory = deferred<string>()
vi.mocked(deps.mkdtemp).mockReturnValueOnce(directory.promise)
const supervisor = new DesktopScriptProviderSupervisor(deps)
const result = supervisor.execute({ tool: 'list_apps' })
const rejection = expect(result).rejects.toThrow('supervisor shut down')
supervisor.shutdown()
await rejection
expect(deps.execFile).not.toHaveBeenCalled()
directory.resolve('/private/tmp/orca-computer-use-late')
await flushPreparation()
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-late', {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 100
})
expect(deps.execFile).not.toHaveBeenCalled()
})
it('cancels after directory creation without spawning after an async setup resumes', async () => {
const chmod = deferred<void>()
vi.mocked(deps.chmod).mockReturnValueOnce(chmod.promise)
const supervisor = new DesktopScriptProviderSupervisor(deps)
const result = supervisor.execute({ tool: 'list_apps' })
const rejection = expect(result).rejects.toThrow('supervisor shut down')
await flushPreparation()
expect(deps.chmod).toHaveBeenCalled()
supervisor.shutdown()
await rejection
expect(deps.rmSync).toHaveBeenCalledTimes(1)
chmod.resolve()
await flushPreparation()
expect(deps.writeFile).not.toHaveBeenCalled()
expect(deps.execFile).not.toHaveBeenCalled()
})
it('retains a spawned operation and its file until shutdown confirms exit', async () => {
const supervisor = new DesktopScriptProviderSupervisor(deps)
const result = supervisor.execute({ tool: 'list_apps' })
const rejection = expect(result).rejects.toThrow('supervisor shut down')
await flushPreparation()
supervisor.shutdown()
await rejection
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
expect(deps.rmSync).not.toHaveBeenCalled()
expect(child.listenerCount('exit')).toBe(1)
child.emit('exit', null, 'SIGKILL')
expect(deps.rmSync).toHaveBeenCalledTimes(1)
expect(child.listenerCount('exit')).toBe(0)
})
it('handles a child without a pid and retains an asynchronous error listener', async () => {
child.pid = undefined
const supervisor = new DesktopScriptProviderSupervisor(deps)
const result = supervisor.execute({ tool: 'list_apps' })
await flushPreparation()
await expect(result).rejects.toThrow('did not report a pid')
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
expect(deps.rmSync).toHaveBeenCalledTimes(1)
expect(() => child.emit('error', new Error('spawn failed asynchronously'))).not.toThrow()
})
it('rejects a registered child error but cleans up only after exit', async () => {
const supervisor = new DesktopScriptProviderSupervisor(deps)
const result = supervisor.execute({ tool: 'list_apps' })
const rejection = expect(result).rejects.toThrow('provider failed: handle failed')
await flushPreparation()
child.emit('error', new Error('handle failed'))
await rejection
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
expect(deps.rmSync).not.toHaveBeenCalled()
child.emit('exit', null, 'SIGKILL')
expect(deps.rmSync).toHaveBeenCalledTimes(1)
})
it('removes the private directory when setup fails before spawn', async () => {
vi.mocked(deps.writeFile).mockRejectedValueOnce(new Error('operation write failed'))
const supervisor = new DesktopScriptProviderSupervisor(deps)
await expect(supervisor.execute({ tool: 'list_apps' })).rejects.toThrow(
'operation write failed'
)
expect(deps.execFile).not.toHaveBeenCalled()
expect(deps.rmSync).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,320 @@
import type { ChildProcess } from 'node:child_process'
import { join } from 'node:path'
import type { SupervisedDesktopProviderResult } from './computer-provider-supervisor-protocol'
import {
createDesktopScriptProviderSupervisorDeps,
desktopProviderCommandArgs,
desktopProviderExecutionPlan,
desktopProviderExecutionResult,
DESKTOP_PROVIDER_MAX_BUFFER_BYTES,
type DesktopProviderExecutionPlan,
type DesktopScriptProviderSupervisorDeps
} from './desktop-script-provider-process-invocation'
import { serializeDesktopScriptProviderRequest } from './desktop-script-provider-request-validation'
import type { BridgeRequest } from './desktop-script-provider-types'
import { RuntimeClientError } from './runtime-client-error'
export const DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS = 30_000
export const DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS = 1_000
type DesktopProviderOperation = {
id: string
platform: DesktopProviderExecutionPlan['platform']
child: ChildProcess | null
directory: string | null
settled: boolean
terminating: boolean
exitConfirmed: boolean
callbackResult: SupervisedDesktopProviderResult | null
deadlineTimer: NodeJS.Timeout | null
forceKillTimer: NodeJS.Timeout | null
resolve: (result: SupervisedDesktopProviderResult) => void
reject: (error: Error) => void
cleanupListeners: () => void
}
function ignoreUnownedChildError(): void {}
export class DesktopScriptProviderSupervisor {
private readonly operations = new Map<string, DesktopProviderOperation>()
private executionPlan: DesktopProviderExecutionPlan | null = null
constructor(
private readonly deps: DesktopScriptProviderSupervisorDeps = createDesktopScriptProviderSupervisorDeps()
) {}
execute(request: BridgeRequest): Promise<SupervisedDesktopProviderResult> {
const plan = this.executionPlan ?? desktopProviderExecutionPlan(this.deps)
this.executionPlan = plan
let resolve = (_result: SupervisedDesktopProviderResult): void => {}
let reject = (_error: Error): void => {}
const result = new Promise<SupervisedDesktopProviderResult>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
const operation: DesktopProviderOperation = {
id: this.deps.randomUUID(),
platform: plan.platform,
child: null,
directory: null,
settled: false,
terminating: false,
exitConfirmed: false,
callbackResult: null,
deadlineTimer: null,
forceKillTimer: null,
resolve,
reject,
cleanupListeners: () => {}
}
this.operations.set(operation.id, operation)
void this.prepareAndSpawn(operation, plan, request)
return result
}
shutdown(): void {
for (const operation of this.operations.values()) {
operation.terminating = true
this.clearOperationTimers(operation)
this.rejectRequest(
operation,
new RuntimeClientError('accessibility_error', 'desktop provider supervisor shut down')
)
if (!operation.child || operation.exitConfirmed) {
this.finishOperation(operation)
continue
}
try {
operation.child.kill('SIGKILL')
} catch {}
}
}
private async prepareAndSpawn(
operation: DesktopProviderOperation,
plan: DesktopProviderExecutionPlan,
request: BridgeRequest
): Promise<void> {
try {
const directory = await this.deps.mkdtemp(
join(this.deps.temporaryDirectory(), 'orca-computer-use-')
)
operation.directory = directory
if (!this.isRegistered(operation) || operation.terminating) {
this.removeOperationDirectory(operation)
return
}
await this.deps.chmod(directory, 0o700)
if (!this.isRegistered(operation) || operation.terminating) {
this.removeOperationDirectory(operation)
return
}
const operationPath = join(directory, 'operation.json')
await this.deps.writeFile(operationPath, serializeDesktopScriptProviderRequest(request), {
encoding: 'utf8',
mode: 0o600
})
if (!this.isRegistered(operation) || operation.terminating) {
this.removeOperationDirectory(operation)
return
}
this.spawnOperation(operation, plan, operationPath)
} catch (error) {
if (this.isRegistered(operation)) {
this.failBeforeSpawn(operation, error)
} else {
this.removeOperationDirectory(operation)
}
}
}
private spawnOperation(
operation: DesktopProviderOperation,
plan: DesktopProviderExecutionPlan,
operationPath: string
): void {
const args = desktopProviderCommandArgs(plan, operationPath)
let child: ChildProcess | null = null
try {
child = this.deps.execFile(
plan.command,
args,
{
env: plan.env,
encoding: 'utf8',
maxBuffer: DESKTOP_PROVIDER_MAX_BUFFER_BYTES,
windowsHide: true
},
(error, stdout, stderr) => {
operation.callbackResult = desktopProviderExecutionResult(error, stdout, stderr)
this.completeAfterCallback(operation)
}
)
child.on('error', ignoreUnownedChildError)
if (typeof child.pid !== 'number' || child.pid <= 0) {
throw new Error('desktop provider process did not report a pid')
}
} catch (error) {
try {
child?.kill('SIGKILL')
} catch {}
this.failBeforeSpawn(operation, error)
return
}
operation.child = child
const onError = (error: Error): void => this.handleChildError(operation, error)
const onExit = (): void => this.handleChildExit(operation)
child.on('error', onError)
child.once('exit', onExit)
child.off('error', ignoreUnownedChildError)
operation.cleanupListeners = () => {
child.off('error', onError)
child.off('exit', onExit)
child.off('error', ignoreUnownedChildError)
child.on('error', ignoreUnownedChildError)
}
operation.deadlineTimer = this.deps.setTimer(
() => this.handleDeadline(operation),
DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS
)
}
private completeAfterCallback(operation: DesktopProviderOperation): void {
if (
!this.isRegistered(operation) ||
operation.settled ||
!operation.exitConfirmed ||
!operation.callbackResult
) {
return
}
const result = operation.callbackResult
operation.settled = true
this.finishOperation(operation)
operation.resolve(result)
}
private handleChildError(operation: DesktopProviderOperation, error: Error): void {
if (!this.isRegistered(operation) || operation.terminating) {
return
}
operation.terminating = true
this.clearOperationTimers(operation)
this.rejectRequest(
operation,
new RuntimeClientError('accessibility_error', `desktop provider failed: ${error.message}`)
)
try {
operation.child?.kill('SIGKILL')
} catch {}
}
private handleChildExit(operation: DesktopProviderOperation): void {
if (!this.isRegistered(operation)) {
return
}
operation.exitConfirmed = true
if (operation.settled) {
this.finishOperation(operation)
return
}
this.completeAfterCallback(operation)
}
private handleDeadline(operation: DesktopProviderOperation): void {
if (!this.isRegistered(operation) || operation.terminating) {
return
}
operation.terminating = true
operation.deadlineTimer = null
this.rejectRequest(
operation,
new RuntimeClientError(
'action_timeout',
`desktop provider timed out after ${DESKTOP_PROVIDER_REQUEST_TIMEOUT_MS}ms`
)
)
if (operation.exitConfirmed) {
this.finishOperation(operation)
return
}
try {
operation.child?.kill(operation.platform === 'windows' ? 'SIGKILL' : 'SIGTERM')
} catch {}
if (operation.platform === 'windows') {
return
}
operation.forceKillTimer = this.deps.setTimer(() => {
operation.forceKillTimer = null
try {
operation.child?.kill('SIGKILL')
} catch {}
}, DESKTOP_PROVIDER_FORCE_KILL_GRACE_MS)
}
private failBeforeSpawn(operation: DesktopProviderOperation, error: unknown): void {
if (!this.isRegistered(operation)) {
this.removeOperationDirectory(operation)
return
}
this.operations.delete(operation.id)
this.removeOperationDirectory(operation)
this.rejectRequest(
operation,
new RuntimeClientError(
'accessibility_error',
error instanceof Error ? error.message : String(error)
)
)
}
private finishOperation(operation: DesktopProviderOperation): void {
if (!this.isRegistered(operation)) {
return
}
this.operations.delete(operation.id)
this.clearOperationTimers(operation)
operation.cleanupListeners()
this.removeOperationDirectory(operation)
}
private rejectRequest(operation: DesktopProviderOperation, error: Error): void {
if (operation.settled) {
return
}
operation.settled = true
operation.reject(error)
}
private clearOperationTimers(operation: DesktopProviderOperation): void {
if (operation.deadlineTimer) {
this.deps.clearTimer(operation.deadlineTimer)
operation.deadlineTimer = null
}
if (operation.forceKillTimer) {
this.deps.clearTimer(operation.forceKillTimer)
operation.forceKillTimer = null
}
}
private removeOperationDirectory(operation: DesktopProviderOperation): void {
if (!operation.directory) {
return
}
const directory = operation.directory
operation.directory = null
try {
this.deps.rmSync(directory, {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 100
})
} catch {}
}
private isRegistered(operation: DesktopProviderOperation): boolean {
return this.operations.get(operation.id) === operation
}
}
@@ -1,80 +1,52 @@
import { expect, vi } from 'vitest'
const { execFileMock, operationFiles, mkdtempMock, rmMock, writeFileMock } = vi.hoisted(() => {
const files = new Map<string, string>()
return {
execFileMock: vi.fn(),
operationFiles: files,
mkdtempMock: vi.fn(async (prefix: string) => `${prefix}${files.size}`),
rmMock: vi.fn(async () => undefined),
writeFileMock: vi.fn(async (filePath: string, data: string | Buffer) => {
files.set(filePath, Buffer.isBuffer(data) ? data.toString('utf8') : data)
})
}
})
vi.mock('child_process', () => ({
execFile: execFileMock
const { executeSupervisedDesktopProviderMock } = vi.hoisted(() => ({
executeSupervisedDesktopProviderMock: vi.fn()
}))
vi.mock('fs/promises', () => ({
mkdtemp: mkdtempMock,
rm: rmMock,
writeFile: writeFileMock
vi.mock('./computer-provider-supervisor-client', () => ({
executeSupervisedDesktopProvider: executeSupervisedDesktopProviderMock
}))
export async function createDesktopScriptProviderClient(
platform: 'linux' | 'windows',
executablePath: string
_platform: 'linux' | 'windows',
_executablePath: string
) {
const { DesktopScriptProviderClient } = await import('./desktop-script-provider-client')
return new DesktopScriptProviderClient(platform, executablePath)
return new DesktopScriptProviderClient()
}
export function resetDesktopScriptProviderTestHarness(): void {
vi.useRealTimers()
execFileMock.mockReset()
operationFiles.clear()
mkdtempMock.mockClear()
rmMock.mockClear()
writeFileMock.mockClear()
}
export function mockDesktopProviderSubprocessThatIgnoresTimeout({
kill,
once
}: {
kill: (signal: string) => void
once: () => void
}): void {
execFileMock.mockImplementationOnce(() => ({ kill, once }) as never)
}
export function expectDesktopProviderSubprocessStarted(): void {
expect(execFileMock).toHaveBeenCalled()
executeSupervisedDesktopProviderMock.mockReset()
}
export function expectDesktopProviderSubprocessStartCount(count: number): void {
expect(execFileMock).toHaveBeenCalledTimes(count)
expect(executeSupervisedDesktopProviderMock).toHaveBeenCalledTimes(count)
}
export function mockBridgeExecutionError(message: string): void {
executeSupervisedDesktopProviderMock.mockResolvedValueOnce({
stdout: '',
stderr: message,
error: { message, killed: false }
})
}
export function mockBridgeResponse(
response: unknown,
inspectOperation?: (operation: Record<string, unknown>) => void
): void {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
const operationPath = _args?.at(-1)
if (inspectOperation && typeof operationPath === 'string') {
const operation = operationFiles.get(operationPath)
if (!operation) {
throw new Error(`Missing mocked operation file: ${operationPath}`)
executeSupervisedDesktopProviderMock.mockImplementationOnce(
async (operation: Record<string, unknown>) => {
inspectOperation?.(operation)
return {
stdout: JSON.stringify(response),
stderr: '',
error: null
}
inspectOperation(JSON.parse(operation) as Record<string, unknown>)
}
const done = callback as (error: Error | null, stdout: string, stderr: string) => void
done(null, JSON.stringify(response), '')
return null as never
})
)
}
export function sampleBridgeSnapshot(name: string, value: string) {
@@ -134,6 +134,7 @@ export type BridgeRequest = {
to_y?: number
click_count?: number
mouse_button?: string
modifiers?: string
action?: string
direction?: string
pages?: number
@@ -1,38 +1,22 @@
import { EventEmitter } from 'node:events'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
chmodSyncMock,
claimSupervisedMacOSProviderMock,
connectMacOSProviderSocketMock,
mkdtempSyncMock,
resolveMacOSComputerUseExecutablePathMock,
rmSyncMock,
spawnMock,
writeFileSyncMock
releaseSupervisedMacOSProviderMock,
startSupervisedMacOSProviderMock
} = vi.hoisted(() => ({
chmodSyncMock: vi.fn(),
claimSupervisedMacOSProviderMock: vi.fn(),
connectMacOSProviderSocketMock: vi.fn(),
mkdtempSyncMock: vi.fn(),
resolveMacOSComputerUseExecutablePathMock: vi.fn(),
rmSyncMock: vi.fn(),
spawnMock: vi.fn(),
writeFileSyncMock: vi.fn()
releaseSupervisedMacOSProviderMock: vi.fn(),
startSupervisedMacOSProviderMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
vi.mock('fs', () => ({
chmodSync: chmodSyncMock,
mkdtempSync: mkdtempSyncMock,
rmSync: rmSyncMock,
writeFileSync: writeFileSyncMock
}))
vi.mock('./macos-native-provider-paths', () => ({
resolveMacOSComputerUseExecutablePath: resolveMacOSComputerUseExecutablePathMock
vi.mock('./computer-provider-supervisor-client', () => ({
claimSupervisedMacOSProvider: claimSupervisedMacOSProviderMock,
releaseSupervisedMacOSProvider: releaseSupervisedMacOSProviderMock,
startSupervisedMacOSProvider: startSupervisedMacOSProviderMock
}))
vi.mock('./macos-native-provider-socket', () => ({
@@ -62,11 +46,6 @@ class FakeSocket extends EventEmitter {
}
}
class FakeProvider extends EventEmitter {
kill = vi.fn()
unref = vi.fn()
}
function pendingConnectThatRejectsOnAbort(signal?: AbortSignal): Promise<never> {
return new Promise((_resolve, reject) => {
signal?.addEventListener(
@@ -84,20 +63,22 @@ async function loadClientModule() {
describe('MacOSNativeProviderClient', () => {
const sockets: FakeSocket[] = []
const providers: FakeProvider[] = []
let nextSessionId = 1
beforeEach(() => {
vi.useFakeTimers()
sockets.length = 0
providers.length = 0
mkdtempSyncMock.mockImplementation((prefix: string) => `${prefix}${sockets.length}`)
resolveMacOSComputerUseExecutablePathMock.mockReturnValue(
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos'
)
spawnMock.mockImplementation(() => {
const provider = new FakeProvider()
providers.push(provider)
return provider
nextSessionId = 1
claimSupervisedMacOSProviderMock.mockResolvedValue(undefined)
releaseSupervisedMacOSProviderMock.mockResolvedValue(undefined)
startSupervisedMacOSProviderMock.mockImplementation(async () => {
const sessionId = `session-${nextSessionId++}`
return {
sessionId,
socketPath: `/tmp/${sessionId}/provider.sock`,
socketToken: `token-${sessionId}`,
termination: new Promise<never>(() => {})
}
})
connectMacOSProviderSocketMock.mockImplementation(async () => {
const socket = new FakeSocket()
@@ -107,13 +88,10 @@ describe('MacOSNativeProviderClient', () => {
})
afterEach(() => {
chmodSyncMock.mockReset()
claimSupervisedMacOSProviderMock.mockReset()
connectMacOSProviderSocketMock.mockReset()
mkdtempSyncMock.mockReset()
resolveMacOSComputerUseExecutablePathMock.mockReset()
rmSyncMock.mockReset()
spawnMock.mockReset()
writeFileSyncMock.mockReset()
releaseSupervisedMacOSProviderMock.mockReset()
startSupervisedMacOSProviderMock.mockReset()
vi.useRealTimers()
})
@@ -167,7 +145,6 @@ describe('MacOSNativeProviderClient', () => {
const firstRejection = expect(firstCall).rejects.toThrow('active helper failed')
await vi.waitFor(() => expect(sockets).toHaveLength(1))
const firstSocket = sockets[0]!
const firstSocketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
await vi.waitFor(() => expect(firstSocket.writes).toHaveLength(1))
firstSocket.emit('error', new Error('active helper failed'))
@@ -176,10 +153,7 @@ describe('MacOSNativeProviderClient', () => {
expect(firstSocket.listenerCount('data')).toBe(0)
expect(firstSocket.listenerCount('close')).toBe(0)
expect(firstSocket.listenerCount('error')).toBe(1)
expect(rmSyncMock).toHaveBeenCalledWith(firstSocketDirectory, {
recursive: true,
force: true
})
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
const secondCall = client.capabilities()
await vi.waitFor(() => expect(sockets).toHaveLength(2))
@@ -200,24 +174,25 @@ describe('MacOSNativeProviderClient', () => {
})
it('invalidates the active socket when a helper write fails', async () => {
const firstSocket = new FakeSocket()
firstSocket.writeError = new Error('write EPIPE')
connectMacOSProviderSocketMock.mockImplementationOnce(async () => {
sockets.push(firstSocket)
return firstSocket
})
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
const firstCall = client.capabilities()
const firstRejection = expect(firstCall).rejects.toThrow('write EPIPE')
await vi.waitFor(() => expect(sockets).toHaveLength(1))
const firstSocket = sockets[0]!
const firstSocketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
firstSocket.writeError = new Error('write EPIPE')
await expect(firstCall).rejects.toThrow('write EPIPE')
await firstRejection
expect(firstSocket.destroyed).toBe(true)
expect(firstSocket.listenerCount('data')).toBe(0)
expect(firstSocket.listenerCount('close')).toBe(0)
expect(firstSocket.listenerCount('error')).toBe(1)
expect(rmSyncMock).toHaveBeenCalledWith(firstSocketDirectory, {
recursive: true,
force: true
})
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
const secondCall = client.capabilities()
await vi.waitFor(() => expect(sockets).toHaveLength(2))
@@ -278,6 +253,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')
@@ -293,9 +278,8 @@ describe('MacOSNativeProviderClient', () => {
message: expect.stringContaining('Hotkey requires')
})
expect(providers).toHaveLength(0)
expect(sockets).toHaveLength(0)
expect(spawnMock).not.toHaveBeenCalled()
expect(startSupervisedMacOSProviderMock).not.toHaveBeenCalled()
expect(connectMacOSProviderSocketMock).not.toHaveBeenCalled()
})
@@ -342,7 +326,7 @@ describe('MacOSNativeProviderClient', () => {
})
})
it('removes the parent-owned token file after the helper socket connects', async () => {
it('claims the parent-owned helper session after the socket connects', async () => {
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
@@ -351,11 +335,8 @@ describe('MacOSNativeProviderClient', () => {
const socket = sockets[0]!
await vi.waitFor(() => expect(socket.writes).toHaveLength(1))
const request = JSON.parse(socket.writes[0]!) as { id: number }
const socketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
expect(rmSyncMock).toHaveBeenCalledWith(join(socketDirectory, 'provider.token'), {
force: true
})
expect(claimSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
socket.emit(
'data',
@@ -368,7 +349,7 @@ describe('MacOSNativeProviderClient', () => {
await expect(call).resolves.toMatchObject({ protocolVersion: 1 })
})
it('does not let a superseded startup clean up the replacement helper token', async () => {
it('does not let a superseded startup release the replacement helper session', async () => {
const pendingConnects: {
resolve: (socket: FakeSocket) => void
}[] = []
@@ -383,7 +364,6 @@ describe('MacOSNativeProviderClient', () => {
const firstCall = client.capabilities()
await vi.waitFor(() => expect(pendingConnects).toHaveLength(1))
const firstSocketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
client.shutdown()
@@ -399,9 +379,8 @@ describe('MacOSNativeProviderClient', () => {
await expect(firstCall).rejects.toThrow('native macOS provider startup was superseded')
expect(firstSocket.destroyed).toBe(true)
expect(rmSyncMock).toHaveBeenCalledWith(join(firstSocketDirectory, 'provider.token'), {
force: true
})
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
expect(releaseSupervisedMacOSProviderMock).not.toHaveBeenCalledWith('session-2')
secondSocket.emit(
'data',
@@ -414,23 +393,64 @@ describe('MacOSNativeProviderClient', () => {
await expect(secondCall).resolves.toMatchObject({ protocolVersion: 1 })
})
it('terminates the helper process when socket startup fails', async () => {
const providerKill = vi.fn()
spawnMock.mockReturnValueOnce({ unref: vi.fn(), kill: providerKill })
it('releases a claimed session when shutdown lands before client adoption', async () => {
let resolveClaim = (): void => {}
claimSupervisedMacOSProviderMock.mockImplementationOnce(
async () =>
await new Promise<void>((resolve) => {
resolveClaim = resolve
})
)
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
const call = client.capabilities()
await vi.waitFor(() => expect(claimSupervisedMacOSProviderMock).toHaveBeenCalledTimes(1))
const socket = sockets[0]!
resolveClaim()
queueMicrotask(() => client.shutdown())
await expect(call).rejects.toThrow('native macOS provider startup was superseded')
expect(socket.destroyed).toBe(true)
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
})
it('releases the supervised helper session when socket startup fails', async () => {
connectMacOSProviderSocketMock.mockRejectedValueOnce(new Error('socket did not open'))
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
await expect(client.capabilities()).rejects.toThrow('socket did not open')
expect(providerKill).toHaveBeenCalledWith('SIGTERM')
expect(rmSyncMock).toHaveBeenCalledWith(expect.stringContaining('orca-computer-use-'), {
recursive: true,
force: true
})
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
})
it('rejects helper spawn errors before socket connection and removes temp state', async () => {
it('rejects supervisor startup errors before opening a socket', async () => {
startSupervisedMacOSProviderMock.mockRejectedValueOnce(
new Error('native macOS helper app failed to start: helper missing')
)
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
await expect(client.capabilities()).rejects.toThrow(
'native macOS helper app failed to start: helper missing'
)
expect(connectMacOSProviderSocketMock).not.toHaveBeenCalled()
})
it('aborts a pending socket connection when the supervised helper exits', async () => {
let rejectTermination = (_error: Error): void => {}
const termination = new Promise<never>((_resolve, reject) => {
rejectTermination = reject
})
void termination.catch(() => undefined)
startSupervisedMacOSProviderMock.mockResolvedValueOnce({
sessionId: 'session-1',
socketPath: '/tmp/session-1/provider.sock',
socketToken: 'token-session-1',
termination
})
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
connectMacOSProviderSocketMock.mockImplementation((_path, _timeout, signal?: AbortSignal) =>
@@ -438,36 +458,13 @@ describe('MacOSNativeProviderClient', () => {
)
const call = client.capabilities()
await vi.waitFor(() => expect(providers).toHaveLength(1))
const socketDirectory = mkdtempSyncMock.mock.results[0]?.value as string
await vi.waitFor(() => expect(connectMacOSProviderSocketMock).toHaveBeenCalledTimes(1))
const connectSignal = connectMacOSProviderSocketMock.mock.calls[0]?.[2] as AbortSignal
expect(connectSignal.aborted).toBe(false)
providers[0]!.emit('error', new Error('helper missing'))
rejectTermination(new Error('native macOS helper app exited with code 13'))
await expect(call).rejects.toThrow('native macOS helper app failed to start: helper missing')
await expect(call).rejects.toThrow('native macOS helper app exited with code 13')
expect(connectSignal.aborted).toBe(true)
expect(providers[0]!.kill).toHaveBeenCalledWith('SIGTERM')
expect(rmSyncMock).toHaveBeenCalledWith(socketDirectory, {
recursive: true,
force: true
})
})
it('rejects helper exits before socket connection and aborts the pending connect', async () => {
const { MacOSNativeProviderClient } = await loadClientModule()
const client = new MacOSNativeProviderClient()
connectMacOSProviderSocketMock.mockImplementation((_path, _timeout, signal?: AbortSignal) =>
pendingConnectThatRejectsOnAbort(signal)
)
const call = client.capabilities()
await vi.waitFor(() => expect(providers).toHaveLength(1))
const connectSignal = connectMacOSProviderSocketMock.mock.calls[0]?.[2] as AbortSignal
providers[0]!.emit('exit', 13, null)
await expect(call).rejects.toThrow('native macOS helper app exited before connecting: code 13')
expect(connectSignal.aborted).toBe(true)
expect(providers[0]!.kill).toHaveBeenCalledWith('SIGTERM')
expect(releaseSupervisedMacOSProviderMock).toHaveBeenCalledWith('session-1')
})
})
@@ -1,4 +1,3 @@
import { rmSync } from 'node:fs'
import type net from 'node:net'
import type {
ComputerActionResult,
@@ -17,10 +16,10 @@ import {
type PendingNativeRequest,
writeNativeProviderLine
} from './macos-native-provider-contract'
import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths'
import {
attachMacOSNativeProviderSocketListeners,
consumeNativeProviderLines,
releaseMacOSNativeProviderSocketSession,
startMacOSNativeProviderSocket
} from './macos-native-provider-transport'
import { validateComputerProviderActionParams } from './computer-provider-action-validation'
@@ -33,7 +32,7 @@ export class MacOSNativeProviderClient {
private socket: net.Socket | null = null
private socketStartPromise: Promise<net.Socket> | null = null
private socketPath: string | null = null
private socketDirectory: string | null = null
private socketSessionId: string | null = null
private socketToken: string | null = null
private nextId = 1
private pending = new Map<number, PendingNativeRequest>()
@@ -84,7 +83,7 @@ export class MacOSNativeProviderClient {
)
this.pending.delete(id)
}
this.cleanupSocketDirectory()
this.releaseSocketSession()
}
private async call(method: NativeMethod, params: unknown): Promise<unknown> {
if (method !== 'handshake') {
@@ -94,11 +93,7 @@ export class MacOSNativeProviderClient {
}
private async send(method: NativeMethod, params: unknown): Promise<unknown> {
const id = this.nextId++
const helperExecutablePath = resolveMacOSComputerUseExecutablePath()
if (!helperExecutablePath) {
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
const transport = await this.ensureSocketStarted(helperExecutablePath)
const transport = await this.ensureSocketStarted()
const token = this.socketToken
const line = `${JSON.stringify({ id, method, params, token })}\n`
const result = new Promise<unknown>((resolve, reject) => {
@@ -167,7 +162,7 @@ export class MacOSNativeProviderClient {
private async ensureActionSupported(method: NativeActionMethod): Promise<void> {
await this.ensureCapability('actions', macOSActionCapabilityKey(method))
}
private async ensureSocketStarted(helperExecutablePath: string): Promise<net.Socket> {
private async ensureSocketStarted(): Promise<net.Socket> {
if (this.socket && !this.socket.destroyed) {
return this.socket
}
@@ -176,7 +171,7 @@ export class MacOSNativeProviderClient {
if (this.socketStartPromise) {
return await this.socketStartPromise
}
const socketStartPromise = this.startSocket(helperExecutablePath)
const socketStartPromise = this.startSocket()
this.socketStartPromise = socketStartPromise
try {
return await socketStartPromise
@@ -186,15 +181,22 @@ export class MacOSNativeProviderClient {
}
}
}
private async startSocket(helperExecutablePath: string): Promise<net.Socket> {
private async startSocket(): Promise<net.Socket> {
const startGeneration = ++this.socketStartGeneration
const started = await startMacOSNativeProviderSocket({
helperExecutablePath,
isCurrent: (socketPath) =>
this.socketStartGeneration === startGeneration &&
(this.socketPath === null || this.socketPath === socketPath)
})
this.socketDirectory = started.socketDirectory
if (this.socketStartGeneration !== startGeneration) {
started.socket.destroy()
void releaseMacOSNativeProviderSocketSession(started.sessionId).catch(() => undefined)
throw new RuntimeClientError(
'accessibility_error',
'native macOS provider startup was superseded'
)
}
this.socketSessionId = started.sessionId
this.socketPath = started.socketPath
this.socketToken = started.socketToken
const socket = started.socket
@@ -246,7 +248,7 @@ export class MacOSNativeProviderClient {
this.cleanupActiveSocketListeners()
this.socket = null
this.socketBuffer = ''
this.cleanupSocketDirectory()
this.releaseSocketSession()
this.rejectPending(
new RuntimeClientError('accessibility_error', 'native macOS helper app connection closed')
)
@@ -263,7 +265,7 @@ export class MacOSNativeProviderClient {
if (!socket.destroyed) {
socket.destroy()
}
this.cleanupSocketDirectory()
this.releaseSocketSession()
this.rejectPending(new RuntimeClientError('accessibility_error', error.message))
}
private invalidateActiveSocketAfterWriteFailure(
@@ -279,16 +281,17 @@ export class MacOSNativeProviderClient {
if (!socket.destroyed) {
socket.destroy()
}
this.cleanupSocketDirectory()
this.releaseSocketSession()
this.rejectPending(error)
}
private cleanupSocketDirectory(): void {
if (!this.socketDirectory) {
private releaseSocketSession(): void {
if (!this.socketSessionId) {
return
}
rmSync(this.socketDirectory, { recursive: true, force: true })
this.socketDirectory = null
const sessionId = this.socketSessionId
this.socketSessionId = null
this.socketPath = null
void releaseMacOSNativeProviderSocketSession(sessionId).catch(() => undefined)
}
private rejectPending(error: Error): void {
for (const [id, pending] of this.pending) {
@@ -3,36 +3,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text'
const {
chmodSyncMock,
claimSupervisedMacOSProviderMock,
connectMacOSProviderSocketMock,
mkdtempSyncMock,
resolveMacOSComputerUseExecutablePathMock,
rmSyncMock,
spawnMock,
writeFileSyncMock
releaseSupervisedMacOSProviderMock,
startSupervisedMacOSProviderMock
} = vi.hoisted(() => ({
chmodSyncMock: vi.fn(),
claimSupervisedMacOSProviderMock: vi.fn(),
connectMacOSProviderSocketMock: vi.fn(),
mkdtempSyncMock: vi.fn(),
resolveMacOSComputerUseExecutablePathMock: vi.fn(),
rmSyncMock: vi.fn(),
spawnMock: vi.fn(),
writeFileSyncMock: vi.fn()
releaseSupervisedMacOSProviderMock: vi.fn(),
startSupervisedMacOSProviderMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
vi.mock('fs', () => ({
chmodSync: chmodSyncMock,
mkdtempSync: mkdtempSyncMock,
rmSync: rmSyncMock,
writeFileSync: writeFileSyncMock
}))
vi.mock('./macos-native-provider-paths', () => ({
resolveMacOSComputerUseExecutablePath: resolveMacOSComputerUseExecutablePathMock
vi.mock('./computer-provider-supervisor-client', () => ({
claimSupervisedMacOSProvider: claimSupervisedMacOSProviderMock,
releaseSupervisedMacOSProvider: releaseSupervisedMacOSProviderMock,
startSupervisedMacOSProvider: startSupervisedMacOSProviderMock
}))
vi.mock('./macos-native-provider-socket', () => ({
@@ -61,11 +46,6 @@ class FakeSocket extends EventEmitter {
}
}
class FakeProvider extends EventEmitter {
kill = vi.fn()
unref = vi.fn()
}
async function loadClientModule() {
vi.resetModules()
return await import('./macos-native-provider-client')
@@ -87,20 +67,17 @@ function macOSProviderCapabilities() {
describe('MacOSNativeProviderClient paste validation', () => {
const sockets: FakeSocket[] = []
const providers: FakeProvider[] = []
beforeEach(() => {
vi.useFakeTimers()
sockets.length = 0
providers.length = 0
mkdtempSyncMock.mockImplementation((prefix: string) => `${prefix}${sockets.length}`)
resolveMacOSComputerUseExecutablePathMock.mockReturnValue(
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos'
)
spawnMock.mockImplementation(() => {
const provider = new FakeProvider()
providers.push(provider)
return provider
claimSupervisedMacOSProviderMock.mockResolvedValue(undefined)
releaseSupervisedMacOSProviderMock.mockResolvedValue(undefined)
startSupervisedMacOSProviderMock.mockResolvedValue({
sessionId: 'session-1',
socketPath: '/tmp/session-1/provider.sock',
socketToken: 'token-session-1',
termination: new Promise<never>(() => {})
})
connectMacOSProviderSocketMock.mockImplementation(async () => {
const socket = new FakeSocket()
@@ -110,13 +87,10 @@ describe('MacOSNativeProviderClient paste validation', () => {
})
afterEach(() => {
chmodSyncMock.mockReset()
claimSupervisedMacOSProviderMock.mockReset()
connectMacOSProviderSocketMock.mockReset()
mkdtempSyncMock.mockReset()
resolveMacOSComputerUseExecutablePathMock.mockReset()
rmSyncMock.mockReset()
spawnMock.mockReset()
writeFileSyncMock.mockReset()
releaseSupervisedMacOSProviderMock.mockReset()
startSupervisedMacOSProviderMock.mockReset()
vi.useRealTimers()
})
@@ -128,9 +102,8 @@ describe('MacOSNativeProviderClient paste validation', () => {
const call = client.action('pasteText', { app: 'TextEdit', text })
await Promise.resolve()
expect(providers).toHaveLength(0)
expect(sockets).toHaveLength(0)
expect(spawnMock).not.toHaveBeenCalled()
expect(startSupervisedMacOSProviderMock).not.toHaveBeenCalled()
expect(connectMacOSProviderSocketMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(0)
@@ -0,0 +1,240 @@
import { EventEmitter } from 'node:events'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
MACOS_HELPER_CLAIM_TIMEOUT_MS,
MACOS_HELPER_FORCE_KILL_GRACE_MS,
MacOSNativeProviderSupervisor,
type MacOSNativeProviderSupervisorDeps
} from './macos-native-provider-supervisor'
class FakeChild extends EventEmitter {
pid: number | undefined = 4321
kill = vi.fn(() => true)
unref = vi.fn()
}
describe('MacOSNativeProviderSupervisor', () => {
let child: FakeChild
let deps: MacOSNativeProviderSupervisorDeps
let events: unknown[]
beforeEach(() => {
vi.useFakeTimers()
child = new FakeChild()
events = []
let uuid = 0
deps = {
resolveExecutablePath: vi.fn(
() => '/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos'
),
spawn: vi.fn(() => child as never),
randomUUID: vi.fn(() => `uuid-${++uuid}`),
mkdtempSync: vi.fn(
() => '/private/tmp/orca-computer-use-session'
) as unknown as MacOSNativeProviderSupervisorDeps['mkdtempSync'],
chmodSync: vi.fn(),
writeFileSync: vi.fn(),
rmSync: vi.fn(),
setTimer: (callback, timeoutMs) => setTimeout(callback, timeoutMs),
clearTimer: (timer) => clearTimeout(timer)
}
})
afterEach(() => {
vi.useRealTimers()
})
it('registers the helper pid and private session state before returning', () => {
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
const started = supervisor.start()
expect(started).toEqual({
sessionId: 'uuid-2',
socketPath: join('/private/tmp/orca-computer-use-session', 'provider.sock'),
socketToken: 'uuid-1'
})
expect(deps.spawn).toHaveBeenCalledWith(
'/Applications/Orca Computer Use.app/Contents/MacOS/orca-computer-use-macos',
[
'--agent',
join('/private/tmp/orca-computer-use-session', 'provider.sock'),
'--token-file',
join('/private/tmp/orca-computer-use-session', 'provider.token')
],
{ detached: true, stdio: 'ignore' }
)
expect(deps.chmodSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', 0o700)
expect(deps.writeFileSync).toHaveBeenCalledWith(
join('/private/tmp/orca-computer-use-session', 'provider.token'),
'uuid-1',
{ encoding: 'utf8', mode: 0o600 }
)
expect(child.unref).toHaveBeenCalledTimes(1)
expect(events).toEqual([])
})
it('claims a live session and removes only its token file', async () => {
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
const started = supervisor.start()
supervisor.claim(started.sessionId)
await vi.advanceTimersByTimeAsync(MACOS_HELPER_CLAIM_TIMEOUT_MS)
expect(deps.rmSync).toHaveBeenCalledWith(
join('/private/tmp/orca-computer-use-session', 'provider.token'),
{ force: true }
)
expect(child.kill).not.toHaveBeenCalled()
expect(events).toEqual([])
})
it('reaps an unclaimed helper and removes private state on the parent deadline', async () => {
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
supervisor.start()
await vi.advanceTimersByTimeAsync(MACOS_HELPER_CLAIM_TIMEOUT_MS)
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', {
recursive: true,
force: true
})
expect(events).toEqual([
expect.objectContaining({
event: 'macos.sessionTerminated',
sessionId: 'uuid-2',
error: expect.objectContaining({ message: expect.stringContaining('not claimed') })
})
])
await vi.advanceTimersByTimeAsync(MACOS_HELPER_FORCE_KILL_GRACE_MS)
expect(child.kill).toHaveBeenLastCalledWith('SIGKILL')
expect(child.listenerCount('exit')).toBe(1)
child.emit('exit', null, 'SIGKILL')
expect(child.listenerCount('exit')).toBe(0)
})
it('releases a claimed session with graceful-to-force escalation', async () => {
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
const started = supervisor.start()
supervisor.claim(started.sessionId)
supervisor.release(started.sessionId)
expect(child.kill).toHaveBeenCalledWith('SIGTERM')
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', {
recursive: true,
force: true
})
await vi.advanceTimersByTimeAsync(MACOS_HELPER_FORCE_KILL_GRACE_MS)
expect(child.kill).toHaveBeenLastCalledWith('SIGKILL')
expect(child.listenerCount('exit')).toBe(1)
child.emit('exit', null, 'SIGKILL')
expect(child.listenerCount('exit')).toBe(0)
expect(events).toEqual([])
})
it('kills every registered helper immediately when its sidecar owner dies', () => {
const first = child
const second = new FakeChild()
second.pid = 4322
vi.mocked(deps.spawn)
.mockReturnValueOnce(first as never)
.mockReturnValueOnce(second as never)
vi.mocked(deps.mkdtempSync)
.mockReturnValueOnce('/private/tmp/orca-computer-use-first')
.mockReturnValueOnce('/private/tmp/orca-computer-use-second')
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
supervisor.start()
supervisor.start()
supervisor.shutdown()
expect(first.kill).toHaveBeenCalledWith('SIGKILL')
expect(second.kill).toHaveBeenCalledWith('SIGKILL')
expect(first.listenerCount('exit')).toBe(1)
expect(second.listenerCount('exit')).toBe(1)
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-first', {
recursive: true,
force: true
})
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-second', {
recursive: true,
force: true
})
first.emit('exit', null, 'SIGKILL')
second.emit('exit', null, 'SIGKILL')
expect(first.listenerCount('exit')).toBe(0)
expect(second.listenerCount('exit')).toBe(0)
})
it('reports unexpected helper exit and removes its private directory', () => {
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
supervisor.start()
child.emit('exit', 13, null)
expect(events).toEqual([
expect.objectContaining({
sessionId: 'uuid-2',
error: expect.objectContaining({ message: expect.stringContaining('code 13') })
})
])
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', {
recursive: true,
force: true
})
})
it('reports a registered helper error and retains ownership until exit', () => {
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
const started = supervisor.start()
child.emit('error', new Error('helper handle failed'))
expect(events).toEqual([
expect.objectContaining({
sessionId: started.sessionId,
error: expect.objectContaining({ message: expect.stringContaining('helper handle failed') })
})
])
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
expect(child.listenerCount('exit')).toBe(1)
expect(() => supervisor.claim(started.sessionId)).toThrow('no longer active')
child.emit('exit', null, 'SIGKILL')
expect(child.listenerCount('exit')).toBe(0)
})
it('does not publish a session when spawn returns no pid', () => {
child.pid = undefined
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
expect(() => supervisor.start()).toThrow('helper process did not report a pid')
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', {
recursive: true,
force: true
})
expect(() => child.emit('error', new Error('spawn failed asynchronously'))).not.toThrow()
expect(events).toEqual([])
})
it('removes the private directory when token setup fails before spawn', () => {
vi.mocked(deps.writeFileSync).mockImplementationOnce(() => {
throw new Error('token write failed')
})
const supervisor = new MacOSNativeProviderSupervisor((event) => events.push(event), deps)
expect(() => supervisor.start()).toThrow('token write failed')
expect(deps.spawn).not.toHaveBeenCalled()
expect(deps.rmSync).toHaveBeenCalledWith('/private/tmp/orca-computer-use-session', {
recursive: true,
force: true
})
})
})
@@ -0,0 +1,260 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
type ComputerProviderSupervisorEvent,
type StartedSupervisedMacOSProvider
} from './computer-provider-supervisor-protocol'
import { resolveMacOSComputerUseExecutablePath } from './macos-native-provider-paths'
import { RuntimeClientError } from './runtime-client-error'
// Why: the parent deadline must outlast the 10-second connect budget and claim IPC.
export const MACOS_HELPER_CLAIM_TIMEOUT_MS = 20_000
export const MACOS_HELPER_FORCE_KILL_GRACE_MS = 1_000
type SupervisedMacOSProviderSession = {
id: string
pid: number
child: ChildProcess
socketDirectory: string
socketTokenPath: string
claimTimer: NodeJS.Timeout
forceKillTimer: NodeJS.Timeout | null
terminating: boolean
cleanupListeners: () => void
}
export type MacOSNativeProviderSupervisorDeps = {
resolveExecutablePath: () => string | null
spawn: typeof spawn
randomUUID: () => string
mkdtempSync: typeof mkdtempSync
chmodSync: typeof chmodSync
writeFileSync: typeof writeFileSync
rmSync: typeof rmSync
setTimer: (callback: () => void, timeoutMs: number) => NodeJS.Timeout
clearTimer: (timer: NodeJS.Timeout) => void
}
function ignoreUnownedChildError(): void {}
export class MacOSNativeProviderSupervisor {
private readonly sessions = new Map<string, SupervisedMacOSProviderSession>()
private readonly deps: MacOSNativeProviderSupervisorDeps
constructor(
private readonly emitEvent: (event: ComputerProviderSupervisorEvent) => void = () => {},
deps?: MacOSNativeProviderSupervisorDeps
) {
this.deps = deps ?? createDefaultDeps()
}
start(): StartedSupervisedMacOSProvider {
const executablePath = this.deps.resolveExecutablePath()
if (!executablePath) {
throw new RuntimeClientError('accessibility_error', 'Orca Computer Use.app was not found')
}
let socketDirectory: string | null = null
let child: ChildProcess | null = null
try {
socketDirectory = this.deps.mkdtempSync(join(tmpdir(), 'orca-computer-use-'))
this.deps.chmodSync(socketDirectory, 0o700)
const socketPath = join(socketDirectory, 'provider.sock')
const socketToken = this.deps.randomUUID()
const socketTokenPath = join(socketDirectory, 'provider.token')
this.deps.writeFileSync(socketTokenPath, socketToken, { encoding: 'utf8', mode: 0o600 })
child = this.deps.spawn(
executablePath,
['--agent', socketPath, '--token-file', socketTokenPath],
{ detached: true, stdio: 'ignore' }
)
child.on('error', ignoreUnownedChildError)
if (typeof child.pid !== 'number' || child.pid <= 0) {
throw new Error('helper process did not report a pid')
}
const sessionId = this.deps.randomUUID()
child.unref()
const session = this.createSession(sessionId, child, socketDirectory, socketTokenPath)
this.sessions.set(sessionId, session)
child.off('error', ignoreUnownedChildError)
return { sessionId, socketPath, socketToken }
} catch (error) {
try {
child?.kill('SIGKILL')
} catch {}
if (socketDirectory) {
this.removeSocketDirectory(socketDirectory)
}
throw new RuntimeClientError(
'accessibility_error',
`native macOS helper app failed to start: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
claim(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (!session || session.terminating) {
throw new RuntimeClientError(
'accessibility_error',
'native macOS helper session is no longer active'
)
}
this.deps.clearTimer(session.claimTimer)
this.deps.rmSync(session.socketTokenPath, { force: true })
}
release(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (!session || session.terminating) {
return
}
this.beginTermination(session)
}
shutdown(): void {
for (const session of this.sessions.values()) {
session.terminating = true
this.deps.clearTimer(session.claimTimer)
if (session.forceKillTimer) {
this.deps.clearTimer(session.forceKillTimer)
session.forceKillTimer = null
}
this.removeSocketDirectory(session.socketDirectory)
try {
session.child.kill('SIGKILL')
} catch {}
}
}
private createSession(
id: string,
child: ChildProcess,
socketDirectory: string,
socketTokenPath: string
): SupervisedMacOSProviderSession {
const onError = (error: Error): void => {
const session = this.sessions.get(id)
if (!session || session.terminating) {
return
}
this.emitTermination(session, `native macOS helper app failed: ${error.message}`)
session.terminating = true
this.deps.clearTimer(session.claimTimer)
this.removeSocketDirectory(session.socketDirectory)
// Why: retain ownership until exit even if the signal cannot be delivered.
try {
child.kill('SIGKILL')
} catch {}
}
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
const session = this.sessions.get(id)
if (!session) {
return
}
if (!session.terminating) {
const detail = typeof code === 'number' ? `code ${code}` : `signal ${signal ?? 'unknown'}`
this.emitTermination(session, `native macOS helper app exited with ${detail}`)
}
this.finishSession(session)
}
child.on('error', onError)
child.once('exit', onExit)
const session = {
id,
pid: child.pid!,
child,
socketDirectory,
socketTokenPath,
claimTimer: null as unknown as NodeJS.Timeout,
forceKillTimer: null,
terminating: false,
cleanupListeners: () => {
child.off('error', onError)
child.off('exit', onExit)
child.off('error', ignoreUnownedChildError)
child.on('error', ignoreUnownedChildError)
}
}
session.claimTimer = this.deps.setTimer(() => {
const active = this.sessions.get(id)
if (!active || active.terminating) {
return
}
this.emitTermination(
active,
`native macOS helper app was not claimed within ${MACOS_HELPER_CLAIM_TIMEOUT_MS}ms`
)
this.beginTermination(active)
}, MACOS_HELPER_CLAIM_TIMEOUT_MS)
return session
}
private beginTermination(session: SupervisedMacOSProviderSession): void {
session.terminating = true
this.deps.clearTimer(session.claimTimer)
this.removeSocketDirectory(session.socketDirectory)
try {
session.child.kill('SIGTERM')
} catch {}
session.forceKillTimer = this.deps.setTimer(() => {
session.forceKillTimer = null
try {
session.child.kill('SIGKILL')
} catch {}
}, MACOS_HELPER_FORCE_KILL_GRACE_MS)
}
private finishSession(session: SupervisedMacOSProviderSession): void {
if (this.sessions.get(session.id) !== session) {
return
}
this.sessions.delete(session.id)
this.deps.clearTimer(session.claimTimer)
if (session.forceKillTimer) {
this.deps.clearTimer(session.forceKillTimer)
}
session.cleanupListeners()
this.removeSocketDirectory(session.socketDirectory)
}
private emitTermination(session: SupervisedMacOSProviderSession, message: string): void {
this.emitEvent({
channel: COMPUTER_PROVIDER_SUPERVISOR_CHANNEL,
kind: 'event',
event: 'macos.sessionTerminated',
sessionId: session.id,
error: { code: 'accessibility_error', message }
})
}
private removeSocketDirectory(socketDirectory: string): void {
try {
this.deps.rmSync(socketDirectory, { recursive: true, force: true })
} catch {
// Why: cleanup failure must not prevent signaling or reaping the owned helper PID.
}
}
}
function createDefaultDeps(): MacOSNativeProviderSupervisorDeps {
return {
resolveExecutablePath: resolveMacOSComputerUseExecutablePath,
spawn,
randomUUID,
mkdtempSync,
chmodSync,
writeFileSync,
rmSync,
setTimer: setTimeout,
clearTimer: clearTimeout
}
}
@@ -1,9 +1,10 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import type net from 'node:net'
import { release, tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { release } from 'node:os'
import {
claimSupervisedMacOSProvider,
releaseSupervisedMacOSProvider,
startSupervisedMacOSProvider
} from './computer-provider-supervisor-client'
import { connectMacOSProviderSocket } from './macos-native-provider-socket'
import { RuntimeClientError } from './runtime-client-error'
@@ -11,7 +12,7 @@ const HELPER_CONNECT_TIMEOUT_MS = 10_000
export type StartedMacOSProviderSocket = {
socket: net.Socket
socketDirectory: string
sessionId: string
socketPath: string
socketToken: string
}
@@ -64,100 +65,51 @@ export function consumeNativeProviderLines(
}
export async function startMacOSNativeProviderSocket({
helperExecutablePath,
isCurrent
}: {
helperExecutablePath: string
isCurrent: (socketPath: string) => boolean
}): Promise<StartedMacOSProviderSocket> {
const socketDirectory = mkdtempSync(join(tmpdir(), 'orca-computer-use-'))
chmodSync(socketDirectory, 0o700)
const socketPath = join(socketDirectory, 'provider.sock')
const socketToken = randomUUID()
const socketTokenPath = join(socketDirectory, 'provider.token')
writeFileSync(socketTokenPath, socketToken, { encoding: 'utf8', mode: 0o600 })
// Why: launching the nested helper via LaunchServices can make TCC evaluate
// Orca.app as responsible; the signed helper executable owns this grant.
const provider = spawnProvider(helperExecutablePath, socketPath, socketTokenPath)
const providerFailure = waitForProviderLaunchFailure(provider)
const started = await startSupervisedMacOSProvider()
const connectAbort = new AbortController()
let socket: net.Socket | null = null
try {
const socket = await Promise.race([
connectMacOSProviderSocket(socketPath, HELPER_CONNECT_TIMEOUT_MS, connectAbort.signal),
providerFailure.promise
socket = await Promise.race([
connectMacOSProviderSocket(
started.socketPath,
HELPER_CONNECT_TIMEOUT_MS,
connectAbort.signal
),
started.termination
])
providerFailure.cleanup()
rmSync(socketTokenPath, { force: true })
if (!isCurrent(socketPath)) {
if (!isCurrent(started.socketPath)) {
socket.destroy()
cleanupSocketDirectory(socketDirectory)
throw new RuntimeClientError(
'accessibility_error',
'native macOS provider startup was superseded'
)
}
return { socket, socketDirectory, socketPath, socketToken }
await claimSupervisedMacOSProvider(started.sessionId)
if (!isCurrent(started.socketPath)) {
socket.destroy()
throw new RuntimeClientError(
'accessibility_error',
'native macOS provider startup was superseded'
)
}
return {
socket,
sessionId: started.sessionId,
socketPath: started.socketPath,
socketToken: started.socketToken
}
} catch (error) {
connectAbort.abort()
providerFailure.cleanup()
// Why: connect failures happen after spawn; terminate the detached helper
// so repeated startup attempts do not leave orphan providers.
provider.kill('SIGTERM')
if (isCurrent(socketPath)) {
cleanupSocketDirectory(socketDirectory)
}
socket?.destroy()
await releaseSupervisedMacOSProvider(started.sessionId).catch(() => undefined)
throw error
}
}
function cleanupSocketDirectory(socketDirectory: string): void {
rmSync(socketDirectory, { recursive: true, force: true })
}
function spawnProvider(
helperExecutablePath: string,
socketPath: string,
socketTokenPath: string
): ChildProcess {
const provider = spawn(
helperExecutablePath,
['--agent', socketPath, '--token-file', socketTokenPath],
{ detached: true, stdio: 'ignore' }
)
provider.unref()
return provider
}
function waitForProviderLaunchFailure(provider: ChildProcess): {
promise: Promise<never>
cleanup: () => void
} {
let cleanup = (): void => {}
const promise = new Promise<never>((_resolve, reject) => {
const fail = (error: Error) => {
reject(
new RuntimeClientError(
'accessibility_error',
`native macOS helper app failed to start: ${error.message}`
)
)
}
const exit = (code: number | null, signal: NodeJS.Signals | null) => {
reject(
new RuntimeClientError(
'accessibility_error',
`native macOS helper app exited before connecting: ${
typeof code === 'number' ? `code ${code}` : `signal ${signal ?? 'unknown'}`
}`
)
)
}
provider.once('error', fail)
provider.once('exit', exit)
cleanup = () => {
provider.off('error', fail)
provider.off('exit', exit)
}
})
return { promise, cleanup }
export function releaseMacOSNativeProviderSocketSession(sessionId: string): Promise<void> {
return releaseSupervisedMacOSProvider(sessionId)
}
+81 -8
View File
@@ -10,15 +10,35 @@ import {
callComputerSidecarCapabilities,
resetComputerSidecarForTest
} from './sidecar-client'
import { COMPUTER_SIDECAR_FORCE_KILL_GRACE_MS } from './computer-sidecar-process'
const { forkMock } = vi.hoisted(() => ({
forkMock: vi.fn()
}))
const { forkMock, supervisorHandleMock, supervisorSenderState, supervisorShutdownMock } =
vi.hoisted(() => ({
forkMock: vi.fn(),
supervisorHandleMock: vi.fn(() => false),
supervisorSenderState: {
current: null as null | ((message: Record<string, unknown>) => void)
},
supervisorShutdownMock: vi.fn()
}))
vi.mock('child_process', () => ({
fork: forkMock
}))
vi.mock('./computer-provider-supervisor-host', () => ({
ComputerProviderSupervisorHost: class {
attach(sender: (message: Record<string, unknown>) => void): void {
supervisorSenderState.current = sender
}
handle = supervisorHandleMock
shutdown(): void {
supervisorSenderState.current = null
supervisorShutdownMock()
}
}
}))
type SentRequest = {
id: number
method: string
@@ -27,6 +47,7 @@ type SentRequest = {
class FakeChildProcess extends EventEmitter {
killed = false
killSignals: NodeJS.Signals[] = []
sent: SentRequest[] = []
deferSendCallback = false
sendCallbacks: ((error: Error | null) => void)[] = []
@@ -42,8 +63,9 @@ class FakeChildProcess extends EventEmitter {
return true
}
kill(): boolean {
kill(signal: NodeJS.Signals = 'SIGTERM'): boolean {
this.killed = true
this.killSignals.push(signal)
return true
}
@@ -60,6 +82,8 @@ describe('computer sidecar client', () => {
vi.useFakeTimers()
children.length = 0
deferNextSendCallback = false
supervisorSenderState.current = null
supervisorHandleMock.mockReturnValue(false)
forkMock.mockImplementation(() => {
const child = new FakeChildProcess()
child.deferSendCallback = deferNextSendCallback
@@ -72,6 +96,8 @@ describe('computer sidecar client', () => {
afterEach(() => {
resetComputerSidecarForTest()
forkMock.mockReset()
supervisorHandleMock.mockReset()
supervisorShutdownMock.mockReset()
vi.useRealTimers()
})
@@ -86,7 +112,7 @@ describe('computer sidecar client', () => {
await firstRejection
expect(firstChild.killed).toBe(true)
expect(firstChild.listenerCount('message')).toBe(0)
expect(firstChild.listenerCount('exit')).toBe(0)
expect(firstChild.listenerCount('exit')).toBe(1)
expect(firstChild.listenerCount('error')).toBe(1)
const secondCall = callComputerSidecarCapabilities()
@@ -102,6 +128,7 @@ describe('computer sidecar client', () => {
})
expect(() => firstChild.emit('error', new Error('old sidecar failed late'))).not.toThrow()
firstChild.emit('exit', 1, null)
expect(firstChild.listenerCount('exit')).toBe(0)
secondChild.emit('message', {
id: secondRequest.id,
@@ -121,7 +148,7 @@ describe('computer sidecar client', () => {
await firstRejection
expect(firstChild.killed).toBe(true)
expect(firstChild.listenerCount('message')).toBe(0)
expect(firstChild.listenerCount('exit')).toBe(0)
expect(firstChild.listenerCount('exit')).toBe(1)
expect(firstChild.listenerCount('error')).toBe(1)
const secondCall = callComputerSidecarCapabilities()
@@ -331,7 +358,7 @@ describe('computer sidecar client', () => {
await secondRejection
expect(firstChild.killed).toBe(true)
expect(firstChild.listenerCount('message')).toBe(0)
expect(firstChild.listenerCount('exit')).toBe(0)
expect(firstChild.listenerCount('exit')).toBe(1)
expect(firstChild.listenerCount('error')).toBe(1)
const thirdCall = callComputerSidecarCapabilities()
@@ -359,7 +386,53 @@ describe('computer sidecar client', () => {
await expect(call).rejects.toThrow('computer sidecar IPC is unavailable')
expect(children[0]!.killed).toBe(true)
expect(children[0]!.listenerCount('message')).toBe(0)
expect(children[0]!.listenerCount('exit')).toBe(0)
expect(children[0]!.listenerCount('exit')).toBe(1)
expect(children[0]!.listenerCount('error')).toBe(1)
})
it('force-kills a sidecar that does not exit after the grace period', async () => {
const call = callComputerSidecarCapabilities()
const rejection = expect(call).rejects.toThrow('computer sidecar shut down')
const child = children[0]!
resetComputerSidecarForTest()
await rejection
expect(child.killSignals).toEqual(['SIGTERM'])
await vi.advanceTimersByTimeAsync(COMPUTER_SIDECAR_FORCE_KILL_GRACE_MS)
expect(child.killSignals).toEqual(['SIGTERM', 'SIGKILL'])
})
it('cancels sidecar force-kill escalation after confirmed exit', async () => {
const call = callComputerSidecarCapabilities()
const rejection = expect(call).rejects.toThrow('computer sidecar shut down')
const child = children[0]!
resetComputerSidecarForTest()
child.emit('exit', 0, null)
await rejection
await vi.advanceTimersByTimeAsync(COMPUTER_SIDECAR_FORCE_KILL_GRACE_MS)
expect(child.killSignals).toEqual(['SIGTERM'])
})
it('reaps the sidecar when a supervisor response cannot be delivered', async () => {
const call = callComputerSidecarCapabilities()
const rejection = expect(call).rejects.toThrow('supervisor response failed')
const child = children[0]!
child.deferSendCallback = true
supervisorSenderState.current?.({
channel: 'orca:computer-provider-supervisor',
kind: 'response',
id: 99,
ok: true,
result: {}
})
child.flushSendCallback(new Error('supervisor response failed'))
await rejection
expect(child.killed).toBe(true)
expect(supervisorShutdownMock).toHaveBeenCalled()
})
})
+4 -262
View File
@@ -1,5 +1,3 @@
import { fork, type ChildProcess } from 'node:child_process'
import { join } from 'node:path'
import type {
ComputerActionResult,
ComputerListAppsResult,
@@ -8,46 +6,13 @@ import type {
ComputerSnapshotResult
} from '../../shared/runtime-types'
import { normalizeComputerActionResult } from './computer-action-verification-normalization'
import { ComputerSidecarProcess, type ComputerSidecarMethod } from './computer-sidecar-process'
import { validateComputerSidecarPasteText } from './computer-sidecar-paste-validation'
import { RuntimeClientError } from './runtime-client-error'
type ComputerSidecarMethod =
| 'capabilities'
| 'listApps'
| 'listWindows'
| 'getAppState'
| 'click'
| 'performSecondaryAction'
| 'scroll'
| 'drag'
| 'typeText'
| 'pressKey'
| 'hotkey'
| 'pasteText'
| 'setValue'
type ComputerSidecarRequest = {
id: number
method: ComputerSidecarMethod
params: unknown
}
type ComputerSidecarResponse =
| { id: number; ok: true; result: unknown }
| { id: number; ok: false; error: { code: string; message: string } }
type PendingRequest = {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: NodeJS.Timeout
}
const REQUEST_TIMEOUT_MS = 60_000
let sidecar: ComputerSidecarProcess | null = null
// Why: Node treats unhandled child 'error' events as process exceptions, so
// stale children keep a no-op listener that does not retain the sidecar owner.
function ignoreStaleChildError(): void {}
// Why: app.exit() skips Electron quit events; sync supervisor cleanup must still run.
process.once('exit', () => sidecar?.shutdown())
export async function callComputerSidecarListApps(): Promise<ComputerListAppsResult> {
return (await getComputerSidecar().call('listApps', {})) as ComputerListAppsResult
@@ -92,230 +57,7 @@ export function resetComputerSidecarForTest(): void {
function getComputerSidecar(): ComputerSidecarProcess {
if (!sidecar) {
sidecar = new ComputerSidecarProcess(getComputerSidecarEntryPath())
sidecar = new ComputerSidecarProcess()
}
return sidecar
}
function getComputerSidecarEntryPath(): string {
const app = loadElectronApp()
const appPath = app?.getAppPath() ?? process.cwd()
const isPackaged = app?.isPackaged ?? false
// Why: packaged sidecars must be forked from app.asar.unpacked because
// ELECTRON_RUN_AS_NODE bypasses Electron's asar require integration.
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
return join(basePath, 'out', 'main', 'computer-sidecar.js')
}
function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null {
try {
return require('electron').app
} catch {
return null
}
}
class ComputerSidecarProcess {
private child: ChildProcess | null = null
private childListenerCleanup: (() => void) | null = null
private nextId = 1
private pending = new Map<number, PendingRequest>()
private queueTail: Promise<void> | null = null
private queueGeneration = 0
constructor(private readonly entryPath: string) {}
call(method: ComputerSidecarMethod, params: unknown): Promise<unknown> {
const generation = this.queueGeneration
const run = () => {
if (generation !== this.queueGeneration) {
throw new RuntimeClientError(
'accessibility_error',
'computer sidecar queue was invalidated; retry the computer-use request'
)
}
return this.send(method, params)
}
const result = this.queueTail ? this.queueTail.then(run, run) : run()
const tail = result.then(
() => undefined,
() => undefined
)
this.queueTail = tail
void tail.finally(() => {
if (this.queueTail === tail) {
this.queueTail = null
}
})
return result
}
private send(method: ComputerSidecarMethod, params: unknown): Promise<unknown> {
const child = this.ensureStarted()
if (!child.send) {
const error = new RuntimeClientError(
'accessibility_error',
'computer sidecar IPC is unavailable'
)
this.failActiveChild(child, error)
return Promise.reject(error)
}
const id = this.nextId++
const request: ComputerSidecarRequest = { id, method, params }
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
this.shutdown()
reject(new RuntimeClientError('action_timeout', `computer sidecar ${method} timed out`))
}, REQUEST_TIMEOUT_MS)
this.pending.set(id, { resolve, reject, timer })
child.send(request, (error) => {
if (!error) {
return
}
const wrapped = new RuntimeClientError('accessibility_error', error.message)
if (this.child === child) {
this.failActiveChild(child, wrapped)
return
}
clearTimeout(timer)
this.pending.delete(id)
reject(wrapped)
})
})
}
shutdown(): void {
const child = this.child
this.child = null
this.queueGeneration++
this.cleanupActiveChildListeners()
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(new RuntimeClientError('accessibility_error', 'computer sidecar shut down'))
this.pending.delete(id)
}
child?.kill('SIGTERM')
}
private ensureStarted(): ChildProcess {
if (this.child && !this.child.killed) {
return this.child
}
this.cleanupActiveChildListeners()
this.child = null
const child = fork(this.entryPath, [], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
ORCA_COMPUTER_SIDECAR: '1'
},
...(process.platform === 'win32' ? { windowsHide: true } : {})
})
const onMessage = (message: unknown) => {
if (this.child === child) {
this.handleMessage(message)
}
}
const onExit = (code: number | null, signal: NodeJS.Signals | null) =>
this.handleExit(child, code, signal)
const onError = (error: Error) => this.handleError(child, error)
child.on('message', onMessage)
child.on('exit', onExit)
child.on('error', onError)
this.childListenerCleanup = () => {
child.off('message', onMessage)
child.off('exit', onExit)
child.off('error', onError)
child.off('error', ignoreStaleChildError)
child.on('error', ignoreStaleChildError)
}
this.child = child
return child
}
private handleMessage(message: unknown): void {
if (!isSidecarResponse(message)) {
return
}
const pending = this.pending.get(message.id)
if (!pending) {
return
}
clearTimeout(pending.timer)
this.pending.delete(message.id)
if (message.ok) {
pending.resolve(message.result)
return
}
pending.reject(new RuntimeClientError(message.error.code, message.error.message))
}
private handleExit(
child: ChildProcess,
code: number | null,
signal: NodeJS.Signals | null
): void {
// Why: a timed-out child can exit after a replacement has started; stale
// exits must not clear the live child or reject its in-flight requests.
if (this.child !== child) {
return
}
this.cleanupActiveChildListeners()
this.child = null
this.queueGeneration++
const detail = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`
const error = new RuntimeClientError(
'accessibility_error',
`computer sidecar exited with ${detail}`
)
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(error)
this.pending.delete(id)
}
}
private handleError(child: ChildProcess, error: Error): void {
// Why: late errors from a prior child should not poison the current sidecar.
if (this.child !== child) {
return
}
this.cleanupActiveChildListeners()
// Why: an active process error makes the IPC sidecar unreliable; restart
// on the next call instead of reusing a broken helper.
this.failActiveChild(child, new RuntimeClientError('accessibility_error', error.message))
}
private failActiveChild(child: ChildProcess, error: RuntimeClientError): void {
this.cleanupActiveChildListeners()
this.child = null
this.queueGeneration++
child.kill('SIGTERM')
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer)
pending.reject(error)
this.pending.delete(id)
}
}
private cleanupActiveChildListeners(): void {
const cleanup = this.childListenerCleanup
this.childListenerCleanup = null
cleanup?.()
}
}
function isSidecarResponse(message: unknown): message is ComputerSidecarResponse {
if (!message || typeof message !== 'object') {
return false
}
const record = message as Record<string, unknown>
return typeof record.id === 'number' && typeof record.ok === 'boolean'
}
+8
View File
@@ -1,5 +1,9 @@
import { computerProviderUnavailableMessage } from './computer-provider-unavailable-message'
import { currentComputerProvider, shutdownComputerProviders } from './computer-provider-lifecycle'
import {
handleComputerProviderSupervisorMessage,
shutdownComputerProviderSupervisorClient
} from './computer-provider-supervisor-client'
import { RuntimeClientError } from './runtime-client-error'
type SidecarRequest = {
@@ -20,6 +24,9 @@ process.once('SIGINT', () => {
process.once('beforeExit', shutdownProviders)
process.on('message', (message: unknown) => {
if (handleComputerProviderSupervisorMessage(message)) {
return
}
void handleMessage(message)
})
@@ -122,4 +129,5 @@ function errorToResponse(error: unknown): { code: string; message: string } {
function shutdownProviders(): void {
shutdownComputerProviders()
shutdownComputerProviderSupervisorClient()
}
+45
View File
@@ -4,6 +4,7 @@ import { join } from 'node:path'
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
@@ -11,6 +12,7 @@ import {
writeFileSync
} from 'node:fs'
import { HistoryManager } from './history-manager'
import { flushPendingSessionTreeRemovals } from './terminal-history-session-tombstone'
import type { TerminalSnapshot, TerminalModes } from './types'
import { getHistorySessionDirName } from './history-paths'
import {
@@ -58,6 +60,8 @@ describe('HistoryManager', () => {
afterEach(async () => {
await mgr.dispose()
// Detached tombstone reclaims outlive the test that queued them; settle before the fixture goes.
await flushPendingSessionTreeRemovals()
rmSync(dir, { recursive: true, force: true })
})
@@ -486,4 +490,45 @@ describe('HistoryManager', () => {
}
)
})
describe('large session cleanup responsiveness', () => {
it('tombstones a large session tree instead of walking it on the teardown path', async () => {
const sessionId = 'bulky'
await mgr.openSession(sessionId, { cwd: '/tmp', cols: 80, rows: 24 })
const sessionDir = join(dir, getHistorySessionDirName(sessionId))
// Enough entries that a recursive walk would dominate removeSession's duration.
for (let i = 0; i < 3_000; i++) {
writeFileSync(join(sessionDir, `chunk-${i}.log`), `payload-${i}`)
}
// Why structural and not a turn count: worktree teardown awaits removeSession once per terminal,
// so the contract is that the awaited half only renames. The tree surviving under .pending-delete
// right after the await can only happen if the reclaim was detached.
await mgr.removeSession(sessionId)
expect(existsSync(sessionDir)).toBe(false)
const tombstones = readdirSync(join(dir, '.pending-delete'))
expect(tombstones).toHaveLength(1)
expect(readdirSync(join(dir, '.pending-delete', tombstones[0])).length).toBeGreaterThan(0)
await flushPendingSessionTreeRemovals()
expect(readdirSync(join(dir, '.pending-delete'))).toHaveLength(0)
})
it('reclaims tombstones left by a quit mid-removal on the next construction', async () => {
const sessionId = 'leftover'
await mgr.openSession(sessionId, { cwd: '/tmp', cols: 80, rows: 24 })
await mgr.removeSession(sessionId)
await flushPendingSessionTreeRemovals()
const orphan = join(dir, '.pending-delete', 'orphaned-tombstone')
mkdirSync(orphan, { recursive: true })
writeFileSync(join(orphan, 'output.log'), 'stranded')
new HistoryManager(dir)
await flushPendingSessionTreeRemovals()
expect(existsSync(orphan)).toBe(false)
})
})
})
+11 -11
View File
@@ -1,15 +1,18 @@
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { mkdirSync, writeFileSync, existsSync, rmSync, unlinkSync } from 'node:fs'
import { mkdirSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
import { getHistorySessionDirName } from './history-paths'
import {
fingerprintTerminalHistorySession,
hasTerminalHistoryRecoveryProtection,
quarantineTerminalHistorySession,
removeTerminalHistoryQuarantines,
type ActiveHistoryRecoveryFreeze,
type HistoryRecoveryFreeze
} from './terminal-history-recovery-quarantine'
import {
removeTerminalHistorySessionTrees,
schedulePendingSessionTreeRemovals
} from './terminal-history-session-tombstone'
import { TerminalHistorySessionWriter } from './terminal-history-session-writer'
import {
readTerminalHistoryMetaFromDir,
@@ -43,6 +46,8 @@ export class HistoryManager {
) {
this.onWriteError = opts?.onWriteError
this.checkpointMaxBytes = opts?.checkpointMaxBytes ?? TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES
// Why: a quit between tombstone and reclaim leaves the tree on disk; nothing else rescans the queue.
schedulePendingSessionTreeRemovals(this.basePath)
}
async openSession(sessionId: string, opts: OpenSessionOptions): Promise<void> {
@@ -270,16 +275,11 @@ export class HistoryManager {
async removeSession(sessionId: string): Promise<void> {
this.writers.delete(sessionId)
this.disabledSessions.delete(sessionId)
const activeFreeze = this.recoveryFreezes.get(sessionId)
if (activeFreeze) {
this.recoveryFreezes.delete(sessionId)
}
this.recoveryFreezes.delete(sessionId)
await this.mutations.wait(sessionId)
rmSync(join(this.basePath, getHistorySessionDirName(sessionId)), {
recursive: true,
force: true
})
removeTerminalHistoryQuarantines(this.basePath, sessionId)
// Why tombstoned: writer handles are closed by here, so the trees only have to become unreachable —
// they reach hundreds of MB and every terminal a worktree delete tears down awaits this.
await removeTerminalHistorySessionTrees(this.basePath, sessionId)
}
isSessionDisabled(sessionId: string): boolean {
+5 -1
View File
@@ -18,6 +18,7 @@ import {
hasTerminalHistoryRecoveryProtection,
isTerminalHistoryQuarantineEntry
} from './terminal-history-recovery-quarantine'
import { isTerminalHistoryPendingDeleteEntry } from './terminal-history-session-tombstone'
import {
readTerminalHistoryMeta,
type SessionMeta,
@@ -195,7 +196,10 @@ export class HistoryReader {
if (!entry.isDirectory()) {
continue
}
if (isTerminalHistoryQuarantineEntry(entry.name)) {
if (
isTerminalHistoryQuarantineEntry(entry.name) ||
isTerminalHistoryPendingDeleteEntry(entry.name)
) {
continue
}
let sessionId: string
@@ -5,7 +5,6 @@ import {
mkdirSync,
readdirSync,
renameSync,
rmSync,
unlinkSync,
writeFileSync
} from 'node:fs'
@@ -90,10 +89,3 @@ export function quarantineTerminalHistorySession(
renameSync(sessionDir, quarantineDir)
return quarantineDir
}
export function removeTerminalHistoryQuarantines(basePath: string, sessionId: string): void {
rmSync(getTerminalHistoryQuarantineOwnerDir(basePath, sessionId), {
recursive: true,
force: true
})
}
@@ -0,0 +1,77 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { removeHostTreeMock } = vi.hoisted(() => ({
removeHostTreeMock: vi.fn<(dir: string) => Promise<void>>()
}))
vi.mock('../host-tree-removal', () => ({
removeHostTree: removeHostTreeMock
}))
import { getHistorySessionDirName } from './history-paths'
import {
cancelPendingSessionTreeRemovalRetries,
removeTerminalHistorySessionTrees,
SESSION_TREE_REMOVAL_RETRY_DELAYS_MS
} from './terminal-history-session-tombstone'
/** Mirrors the worktree-level tombstone retry: a session tree whose rm fails must be re-queued
* in-process instead of waiting for the next HistoryManager construction. */
describe('tombstoned session tree removal retries', () => {
let basePath: string
beforeEach(() => {
basePath = mkdtempSync(join(tmpdir(), 'orca-session-tombstone-retry-'))
removeHostTreeMock.mockReset()
vi.useFakeTimers()
})
afterEach(() => {
cancelPendingSessionTreeRemovalRetries()
vi.useRealTimers()
rmSync(basePath, { recursive: true, force: true })
})
function seedSession(sessionId: string): void {
const dir = join(basePath, getHistorySessionDirName(sessionId))
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'segment-0.log'), 'x')
}
it('re-queues a tombstone whose removal failed, then stops after the last attempt', async () => {
seedSession('session-busy')
removeHostTreeMock.mockRejectedValue(
Object.assign(new Error('resource busy'), { code: 'EBUSY' })
)
await removeTerminalHistorySessionTrees(basePath, 'session-busy')
await vi.advanceTimersByTimeAsync(0)
expect(removeHostTreeMock).toHaveBeenCalledTimes(1)
for (const [index, retryDelayMs] of SESSION_TREE_REMOVAL_RETRY_DELAYS_MS.entries()) {
await vi.advanceTimersByTimeAsync(retryDelayMs)
expect(removeHostTreeMock).toHaveBeenCalledTimes(index + 2)
}
// Bounded: a permanently wedged tree waits for the next construction's drain.
await vi.advanceTimersByTimeAsync(60 * 60_000)
expect(removeHostTreeMock).toHaveBeenCalledTimes(
SESSION_TREE_REMOVAL_RETRY_DELAYS_MS.length + 1
)
expect(removeHostTreeMock).toHaveBeenLastCalledWith(expect.stringContaining('.pending-delete'))
})
it('does not re-arm a retry after the removal succeeds', async () => {
seedSession('session-clean')
removeHostTreeMock.mockResolvedValue(undefined)
await removeTerminalHistorySessionTrees(basePath, 'session-clean')
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(SESSION_TREE_REMOVAL_RETRY_DELAYS_MS[0])
expect(removeHostTreeMock).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,141 @@
// Why: worktree teardown stops every terminal one by one, and each stop used to await a full recursive
// delete of that session's history tree (hundreds of MB). Rename the tree into a tombstone queue instead
// so the stop-and-wait path is metadata-only, and drain the queue off the critical path.
import { randomUUID } from 'node:crypto'
import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs'
import { join } from 'node:path'
import { removeHostTree } from '../host-tree-removal'
import { getHistorySessionDirName } from './history-paths'
import { getTerminalHistoryQuarantineOwnerDir } from './terminal-history-recovery-quarantine'
const PENDING_DELETE_DIR_NAME = '.pending-delete'
const pendingSessionTreeRemovals = new Map<string, Promise<void>>()
// Why: a tombstone whose rm fails once (Windows EBUSY under AV) would otherwise sit on disk until the
// next HistoryManager construction. Bounded so a genuinely wedged tree stops burning timers.
export const SESSION_TREE_REMOVAL_RETRY_DELAYS_MS = [30_000, 120_000]
const sessionTreeRemovalAttempts = new Map<string, number>()
const sessionTreeRemovalRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
export function isTerminalHistoryPendingDeleteEntry(name: string): boolean {
return name === PENDING_DELETE_DIR_NAME
}
function getPendingDeleteRoot(basePath: string): string {
return join(basePath, PENDING_DELETE_DIR_NAME)
}
function tombstoneSessionTree(basePath: string, dir: string): string | null {
const pendingRoot = getPendingDeleteRoot(basePath)
try {
mkdirSync(pendingRoot, { recursive: true })
const tombstone = join(pendingRoot, randomUUID())
renameSync(dir, tombstone)
return tombstone
} catch {
return null
}
}
function scheduleSessionTreeRemovalRetry(dir: string): void {
const attempt = sessionTreeRemovalAttempts.get(dir) ?? 0
const retryDelayMs = SESSION_TREE_REMOVAL_RETRY_DELAYS_MS[attempt]
if (retryDelayMs === undefined) {
// Out of in-process attempts: the tombstone stays queued for the next construction's drain.
sessionTreeRemovalAttempts.delete(dir)
return
}
sessionTreeRemovalAttempts.set(dir, attempt + 1)
const timer = setTimeout(() => {
sessionTreeRemovalRetryTimers.delete(dir)
scheduleSessionTreeRemoval(dir)
}, retryDelayMs)
timer.unref?.()
sessionTreeRemovalRetryTimers.set(dir, timer)
}
function scheduleSessionTreeRemoval(dir: string): void {
if (pendingSessionTreeRemovals.has(dir)) {
return
}
// A rescan (startup drain) hitting the same tombstone supersedes its pending retry.
const pendingRetry = sessionTreeRemovalRetryTimers.get(dir)
if (pendingRetry) {
clearTimeout(pendingRetry)
sessionTreeRemovalRetryTimers.delete(dir)
}
const removal = removeHostTree(dir)
.then(() => {
sessionTreeRemovalAttempts.delete(dir)
})
.catch((err: unknown) => {
console.warn(
`[history] Failed to delete tombstoned session tree: ${err instanceof Error ? err.message : String(err)}`
)
scheduleSessionTreeRemovalRetry(dir)
})
.finally(() => {
if (pendingSessionTreeRemovals.get(dir) === removal) {
pendingSessionTreeRemovals.delete(dir)
}
})
pendingSessionTreeRemovals.set(dir, removal)
}
/** Remove everything a session owns on disk — its history tree and any quarantined generations —
* without holding the caller for the recursive walks. Both are unreachable once this resolves;
* only the reclaim is detached. */
export async function removeTerminalHistorySessionTrees(
basePath: string,
sessionId: string
): Promise<void> {
for (const dir of [
join(basePath, getHistorySessionDirName(sessionId)),
getTerminalHistoryQuarantineOwnerDir(basePath, sessionId)
]) {
await removeSessionOwnedTree(basePath, dir)
}
}
async function removeSessionOwnedTree(basePath: string, dir: string): Promise<void> {
if (!existsSync(dir)) {
return
}
const tombstone = tombstoneSessionTree(basePath, dir)
if (tombstone) {
scheduleSessionTreeRemoval(tombstone)
return
}
// Rename blocked (open handles under Windows AV); remove in place so the tree is still gone on return.
await removeHostTree(dir)
}
/** Queue tombstones left by a crash or quit mid-removal. Cheap no-op when the queue never formed. */
export function schedulePendingSessionTreeRemovals(basePath: string): void {
const pendingRoot = getPendingDeleteRoot(basePath)
try {
for (const entry of readdirSync(pendingRoot)) {
scheduleSessionTreeRemoval(join(pendingRoot, entry))
}
} catch {
// Missing or unreadable queue is non-fatal; the next construction rescans.
}
}
/** Drop every armed retry timer so a fixture teardown cannot resurrect a removal. Tests only. */
export function cancelPendingSessionTreeRemovalRetries(): void {
for (const timer of sessionTreeRemovalRetryTimers.values()) {
clearTimeout(timer)
}
sessionTreeRemovalRetryTimers.clear()
sessionTreeRemovalAttempts.clear()
}
/** Await the in-flight tombstone removals. Tests only — production reclaims off the critical path and
* re-queues whatever a quit interrupted on the next HistoryManager construction. */
export async function flushPendingSessionTreeRemovals(): Promise<void> {
while (pendingSessionTreeRemovals.size > 0) {
await Promise.all(pendingSessionTreeRemovals.values())
}
}
+65 -9
View File
@@ -4,8 +4,8 @@
// hour's loss; fsync stops it from happening.
import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs'
import { open, rename } from 'node:fs/promises'
import { dirname } from 'node:path'
import { open, readdir, rename, rm } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
/**
* fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a
@@ -57,16 +57,72 @@ export async function writeFileDurable(
finalPath: string,
payload: string
): Promise<void> {
const handle = await open(tmpPath, 'w')
await writeFileDurableIfCurrent(tmpPath, finalPath, payload, () => true)
}
/**
* `writeFileDurable` with a commit veto: `isCurrent` is consulted after the fsync and before the
* rename so a writer that was superseded mid-write doesn't publish a stale snapshot. Returns whether
* the rename happened; the temp file is removed on every path that doesn't commit, so a multi-MB
* payload can't orphan itself.
*/
export async function writeFileDurableIfCurrent(
tmpPath: string,
finalPath: string,
payload: string,
isCurrent: () => boolean
): Promise<boolean> {
let renamed = false
try {
await handle.writeFile(payload, 'utf-8')
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
await handle.sync()
const handle = await open(tmpPath, 'w')
try {
await handle.writeFile(payload, 'utf-8')
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
await handle.sync()
} finally {
await handle.close()
}
if (!isCurrent()) {
return false
}
await rename(tmpPath, finalPath)
renamed = true
await syncDirectory(dirname(finalPath))
return true
} finally {
await handle.close()
if (!renamed) {
await rm(tmpPath, { force: true }).catch(() => {})
}
}
}
/** Temp path for a durable write. Shared shape so `removeStaleDurableWriteTempFiles` can reclaim orphans. */
export function durableWriteTempPath(finalPath: string): string {
return `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
}
/**
* Sweep temp files orphaned by a death between write and rename — for multi-MB payloads they would
* otherwise accumulate forever. Racing another instance's in-flight save at worst loses that save,
* the trade already accepted for rename-based atomicity. This process's own temps are skipped: a
* `<file>.<our pid>.*.tmp` seen during a sweep is a live write, and deleting it fails its rename.
*/
export async function removeStaleDurableWriteTempFiles(finalPath: string): Promise<void> {
const directory = dirname(finalPath)
const prefix = `${basename(finalPath)}.`
const ownPrefix = `${prefix}${process.pid}.`
try {
const names = await readdir(directory)
await Promise.all(
names
.filter(
(name) => name.startsWith(prefix) && name.endsWith('.tmp') && !name.startsWith(ownPrefix)
)
.map((name) => rm(join(directory, name), { force: true }).catch(() => {}))
)
} catch {
// Directory missing or unreadable — nothing to sweep.
}
await rename(tmpPath, finalPath)
await syncDirectory(dirname(finalPath))
}
/** Synchronous counterpart for quit and crash paths that cannot await. */
+14
View File
@@ -7,6 +7,7 @@ import {
refreshBranchCleanupTargetRefs
} from '../../shared/git-branch-cleanup'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { withSpan } from '../observability/tracer'
import type {
GitWorktreeInfo,
LocalBaseRefRefreshResult,
@@ -1157,6 +1158,19 @@ async function performRemoveWorktree(
return {}
}
// Why its own span: branch cleanup can reach the network (`fetch --prune`), so a stall here reads as
// `git worktree remove` being slow unless it is timed separately.
return withSpan('worktree.remove.branch_delete', () =>
deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options)
)
}
async function deleteBranchAfterWorktreeRemoval(
repoPath: string,
branchName: string,
branchHead: string,
options: RemoveWorktreeOptions
): Promise<RemoveWorktreeResult> {
try {
// Why: also drop the now-orphaned branch so delete-worktree leaves none; `-d` (not `-D`) preserves
// unmerged work, and forceBranchDelete opts into `-D` for failed-creation rollback.
+67
View File
@@ -0,0 +1,67 @@
// Why: every recursive host delete Orca performs (worktrees, terminal history, quarantined recovery
// generations) hits the same Windows stickiness — AV/indexers/late handle releases surface transient
// EBUSY/ENOTEMPTY/EPERM on a tree Node just emptied. One helper so no call site forgets the retries.
import type { RmOptions } from 'node:fs'
import { rm } from 'node:fs/promises'
import { win32 } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
const WINDOWS_REMOVE_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000]
const WINDOWS_RM_MAX_RETRIES = 8
const WINDOWS_RM_RETRY_DELAY_MS = 150
export function toHostRemovalPath(targetPath: string): string {
// Why: Git for Windows can fail long recursive deletes even after Orca has
// proven the worktree target; Node's host deletion should use Win32 long paths.
return process.platform === 'win32' ? win32.toNamespacedPath(targetPath) : targetPath
}
function getHostRemovalOptions(): RmOptions {
const base = { recursive: true, force: true }
if (process.platform !== 'win32') {
return base
}
return {
...base,
// Why: large Windows trees commonly surface transient ENOTEMPTY/EPERM while
// Node walks and removes nested directories.
maxRetries: WINDOWS_RM_MAX_RETRIES,
retryDelay: WINDOWS_RM_RETRY_DELAY_MS
}
}
function isTransientWindowsRemovalError(error: unknown): boolean {
if (process.platform !== 'win32' || typeof error !== 'object' || error === null) {
return false
}
const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined
if (code && ['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code)) {
return true
}
const message = 'message' in error && typeof error.message === 'string' ? error.message : ''
return /directory not empty|resource busy|operation not permitted/i.test(message)
}
/** Recursively remove a host directory tree, retrying the transient Windows failures. */
export async function removeHostTree(targetPath: string): Promise<void> {
const removalPath = toHostRemovalPath(targetPath)
const retryDelays = process.platform === 'win32' ? WINDOWS_REMOVE_RETRY_DELAYS_MS : []
const rmOptions = getHostRemovalOptions()
let attempt = 0
while (true) {
try {
await rm(removalPath, rmOptions)
return
} catch (error) {
if (attempt >= retryDelays.length || !isTransientWindowsRemovalError(error)) {
throw error
}
// Why: Git/Node recursive deletes on Windows can observe a just-emptied
// directory before antivirus/indexers/handles release it.
await delay(retryDelays[attempt])
attempt += 1
}
}
}
@@ -58,6 +58,11 @@ describe('main-i18n lazy locale loading', () => {
expect(translateMain('menu.file', 'File')).not.toBe('File')
})
it('uses caller English when a target catalog omits a key', async () => {
await setMainUiLanguage(UI_LANGUAGE_SPANISH)
expect(translateMain('missing.main.feature', 'English fallback')).toBe('English fallback')
})
it('returns to English from a lazily-loaded locale', async () => {
await setMainUiLanguage(UI_LANGUAGE_SPANISH)
expect(translateMain('menu.file', 'File')).toBe('Archivo')
+13 -1
View File
@@ -80,6 +80,7 @@ import { DesktopRelayService } from './runtime/relay/desktop-relay-service'
import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker'
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
import { scheduleAllPendingHistoryTreeRemovals } from './terminal-history-deletion'
import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n'
import {
getNextDefaultOnAppearanceSettingValue,
@@ -2791,6 +2792,9 @@ void app.whenReady().then(async () => {
}
// Why: headless serve never opens a renderer, so arm scheduled automation dispatch here.
automations.start()
// Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is
// armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch.
scheduleAllPendingHistoryTreeRemovals()
await printServeReady(serveOptions)
return
}
@@ -2917,6 +2921,13 @@ app.on('will-quit', (e) => {
killAllPty()
const watcherShutdown = shutdownWatchersOnce()
store?.flush()
// Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a
// scan completion would drop the final snapshot. Captured before any await; joins the barrier below.
const usageCacheFlush = Promise.all([
claudeUsage?.flush(),
codexUsage?.flush(),
openCodeUsage?.flush()
]).then(() => {})
// Why: preventDefault to await disconnectDaemon's async checkpoint writes (else data lost); guard prevents an infinite quit loop on the re-fired will-quit.
if (!daemonDisconnectDone) {
@@ -2950,7 +2961,8 @@ app.on('will-quit', (e) => {
{ name: 'runtime-rpc', promise: rpcStopAndClear },
{ name: 'watchers', promise: watcherShutdown },
{ name: 'emulator', promise: emulatorShutdown },
{ name: 'plugin-hosts', promise: pluginHostShutdown }
{ name: 'plugin-hosts', promise: pluginHostShutdown },
{ name: 'usage-cache', promise: usageCacheFlush }
])
.then((pendingTeardowns) => {
if (pendingTeardowns.length > 0) {
@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ParcelWatcherProcess from './parcel-watcher-process'
const { handleMock } = vi.hoisted(() => ({
handleMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
}
}))
vi.mock('fs/promises', () => ({
stat: vi.fn()
}))
vi.mock('@parcel/watcher', () => ({
subscribe: vi.fn()
}))
vi.mock('./filesystem-watcher-wsl', () => ({
createWslWatcher: vi.fn()
}))
vi.mock('./parcel-watcher-process', async (importOriginal) => {
const actual = await importOriginal<typeof ParcelWatcherProcess>()
return {
...actual,
subscribeViaWatcherProcess: vi.fn(actual.subscribeViaWatcherProcess)
}
})
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn(),
onSshFilesystemProviderRegistered: () => () => {}
}))
import {
closeAllWatchers,
closeLocalWatcherForWorktreePath,
registerFilesystemWatcherHandlers
} from './filesystem-watcher'
import {
createWatcherRemovalDeadline,
drainBeforeWatcherRemoval,
WATCHER_REMOVAL_DRAIN_BUDGET_MS,
WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS
} from './watcher-removal-drain'
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
import { stat } from 'node:fs/promises'
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
import { subscribeViaWatcherProcess } from './parcel-watcher-process'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => Promise<unknown> | unknown>
describe('local filesystem watcher removal deadline', () => {
const handlers: HandlerMap = {}
beforeEach(async () => {
handleMock.mockReset()
vi.mocked(stat).mockReset()
vi.mocked(subscribeParcelWatcher).mockReset()
vi.mocked(subscribeViaWatcherProcess).mockClear()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
registerFilesystemWatcherHandlers()
await closeAllWatchers()
})
afterEach(async () => {
await closeAllWatchers()
})
it('bounds a wedged watcher install so worktree deletion cannot hang forever', async () => {
vi.useFakeTimers()
try {
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
// Why mock the process-backed subscribe: the in-process fallback rejects on abort, so only a
// subscribe that ignores the abort signal exercises the deadline rather than the cancel path.
// Once, so the wedge cannot leak into the next test.
vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce(() => new Promise(() => {}))
const sender = {
isDestroyed: () => false,
send: vi.fn(),
once: vi.fn(),
id: 1
}
const watchPromise = handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/tmp/repo' }
) as Promise<unknown>
await vi.waitFor(() => {
expect(subscribeViaWatcherProcess).toHaveBeenCalled()
})
let closed = false
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo').then(() => {
closed = true
})
// The install drain leaves the final unsubscribe its reserved tail slice.
await vi.advanceTimersByTimeAsync(
WATCHER_REMOVAL_DRAIN_BUDGET_MS - WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS - 1
)
expect(closed).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await closePromise
expect(closed).toBe(true)
void watchPromise
} finally {
vi.useRealTimers()
}
})
it('bounds a wedged live unsubscribe so worktree deletion cannot hang forever', async () => {
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
// The in-process Parcel fallback has no unsubscribe timeout of its own, so only the shared
// removal deadline can stop this from hanging delete forever.
let resolveUnsubscribe: () => void = () => {}
const unsubscribeMock = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveUnsubscribe = resolve
})
)
vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never)
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' })
vi.useFakeTimers()
try {
let closed = false
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo').then(() => {
closed = true
})
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_DRAIN_BUDGET_MS - 1)
expect(unsubscribeMock).toHaveBeenCalledTimes(1)
expect(closed).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await closePromise
expect(closed).toBe(true)
} finally {
resolveUnsubscribe()
vi.useRealTimers()
}
})
it('spends one shared budget across both drains instead of one per await', async () => {
vi.useFakeTimers()
try {
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
vi.mocked(subscribeViaWatcherProcess).mockImplementationOnce(() => new Promise(() => {}))
const sender = {
isDestroyed: () => false,
send: vi.fn(),
once: vi.fn(),
id: 1
}
const watchPromise = handlers['fs:watchWorktree'](
{ sender },
{ worktreePath: '/tmp/repo' }
) as Promise<unknown>
await vi.waitFor(() => {
expect(subscribeViaWatcherProcess).toHaveBeenCalled()
})
// Half the budget is already gone before the close starts; the drain may only spend the rest.
const deadline = createWatcherRemovalDeadline()
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_DRAIN_BUDGET_MS / 2)
let closed = false
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo', deadline).then(() => {
closed = true
})
// Why assert mid-drain: without this a fresh (unshared) budget would also pass the final check.
await vi.advanceTimersByTimeAsync(
WATCHER_REMOVAL_DRAIN_BUDGET_MS / 2 - WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS - 1
)
expect(closed).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await closePromise
expect(closed).toBe(true)
void watchPromise
} finally {
vi.useRealTimers()
}
})
it('does not let an abandoned unsubscribe poison a later close of the same root', async () => {
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
let rejectUnsubscribe: (error: unknown) => void = () => {}
const unsubscribeMock = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectUnsubscribe = reject
})
)
vi.mocked(subscribeParcelWatcher).mockResolvedValue({ unsubscribe: unsubscribeMock } as never)
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' })
vi.useFakeTimers()
try {
const closePromise = closeLocalWatcherForWorktreePath('/tmp/repo')
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_DRAIN_BUDGET_MS)
await expect(closePromise).resolves.toBeUndefined()
} finally {
vi.useRealTimers()
}
// The delete this drain guarded already finished; a late native failure is stale news, and
// retaining it would leave this root permanently undeletable until the watcher process exits.
rejectUnsubscribe(
new WatcherProcessFailure(
'file watcher process did not exit after termination deadline',
'supervisor',
'process_unavailable',
new Promise<void>(() => {})
)
)
await vi.waitFor(() => expect(unsubscribeMock).toHaveBeenCalledTimes(1))
await expect(closeLocalWatcherForWorktreePath('/tmp/repo')).resolves.toBeUndefined()
})
})
describe('watcher removal drain budget', () => {
it('reserves a tail slice so a slow final unsubscribe is not abandoned at zero', async () => {
vi.useFakeTimers()
try {
const deadline = createWatcherRemovalDeadline()
const earlyDrain = drainBeforeWatcherRemoval(
new Promise(() => {}),
deadline,
'wedged early drain',
{ reserveMs: WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS }
)
await vi.advanceTimersByTimeAsync(
WATCHER_REMOVAL_DRAIN_BUDGET_MS - WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS
)
await expect(earlyDrain).resolves.toBe('timeout')
expect(deadline.remainingMs()).toBe(WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS)
let finishFinalUnsubscribe: () => void = () => {}
const finalDrain = drainBeforeWatcherRemoval(
new Promise<void>((resolve) => {
finishFinalUnsubscribe = resolve
}),
deadline,
'slow final unsubscribe'
)
await vi.advanceTimersByTimeAsync(WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS - 1)
finishFinalUnsubscribe()
await expect(finalDrain).resolves.toBe('settled')
} finally {
vi.useRealTimers()
}
})
})
+73 -5
View File
@@ -23,6 +23,12 @@ import {
WatcherChildCapacityError
} from './parcel-watcher-child-registry'
import { beginWatcherInstall, isWatcherRemovalInProgressError } from './watcher-removal-gate'
import {
createWatcherRemovalDeadline,
drainBeforeWatcherRemoval,
WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS,
type WatcherRemovalDeadline
} from './watcher-removal-drain'
// Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows).
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
@@ -72,6 +78,10 @@ const suspendedLocalWatcherListeners = new Map<
let localWatchersClosed = false
let localWatcherLifecycleGeneration = 0
const failedLocalUnsubscribes = new Map<string, unknown>()
// Why: a drain that timed out no longer gates the delete — Git removal already proceeded past it. Its
// late failure must not fail-close a *later* close of the same root, which would leave that path
// undeletable until the watcher process physically exits.
const abandonedLocalUnsubscribes = new WeakSet<Promise<void>>()
type LocalWatcherInstallToken = {
cancelled: boolean
listeners: Map<number, WebContents>
@@ -478,12 +488,21 @@ function trackLocalUnsubscribe(rootKey: string, root: WatchedRoot): Promise<void
rootUnsubscribes.add(unsubscribePromise)
// Why: swallow here to avoid unhandled rejections, but keep the original promise rejected so later destructive cleanup can fail closed.
void unsubscribePromise.catch((error: unknown) => {
retainLocalWatcherPhysicalFailure(rootKey, error)
if (!abandonedLocalUnsubscribes.has(unsubscribePromise)) {
retainLocalWatcherPhysicalFailure(rootKey, error)
}
console.error(`[filesystem-watcher] unsubscribe error for ${rootKey}:`, error)
})
return unsubscribePromise
}
/** Mark unsubscribes whose drain timed out so their late failures stay out of failedLocalUnsubscribes. */
function abandonLocalUnsubscribes(promises: Iterable<Promise<void>>): void {
for (const promise of promises) {
abandonedLocalUnsubscribes.add(promise)
}
}
function retainLocalWatcherPhysicalFailure(rootKey: string, error: unknown): void {
if (!isWatcherProcessFailure(error) || !error.physicalExit) {
return
@@ -780,7 +799,10 @@ function unsubscribe(worktreePath: string, senderId: number): void {
}
}
export async function closeLocalWatcherForWorktreePath(worktreePath: string): Promise<void> {
export async function closeLocalWatcherForWorktreePath(
worktreePath: string,
deadline: WatcherRemovalDeadline = createWatcherRemovalDeadline()
): Promise<void> {
const { key: rootKey } = localWatcherRoot(worktreePath)
const suspended = suspendedLocalWatcherListeners.get(rootKey) ?? {
worktreePath,
@@ -814,10 +836,45 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr
inFlight.cancelled = true
inFlight.abortController.abort()
}
await pendingLocalInstallPromises.get(rootKey)
// Why: abort alone is not enough if the native subscribe never settles; bound so delete cannot hang the app.
const pendingInstall = pendingLocalInstallPromises.get(rootKey)
const installDrain = await drainBeforeWatcherRemoval(
pendingInstall,
deadline,
`local watcher install for ${rootKey}`,
{ reserveMs: WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS }
)
if (installDrain === 'timeout') {
// Why: an abandoned install never runs its own cleanup, so leaving these entries would make every
// later watch of this root queue behind the same wedged promise. Identity-checked so a late settle
// can't evict a newer install.
if (pendingLocalInstallPromises.get(rootKey) === pendingInstall) {
pendingLocalInstallPromises.delete(rootKey)
}
if (inFlight && inFlightLocalInstalls.get(rootKey) === inFlight) {
inFlightLocalInstalls.delete(rootKey)
}
}
const pendingUnsubscribes = pendingLocalUnsubscribesByRoot.get(rootKey)
if (pendingUnsubscribes) {
await Promise.all(Array.from(pendingUnsubscribes))
const draining = Array.from(pendingUnsubscribes)
const unsubscribeDrain = await drainBeforeWatcherRemoval(
// Why the per-promise catch: an already-abandoned unsubscribe belongs to a delete that finished
// without it; re-raising its rejection here would fail a later close on stale news.
Promise.all(
draining.map((unsubscribe) =>
abandonedLocalUnsubscribes.has(unsubscribe)
? unsubscribe.catch(() => undefined)
: unsubscribe
)
),
deadline,
`local watcher unsubscribe for ${rootKey}`,
{ reserveMs: WATCHER_REMOVAL_FINAL_DRAIN_RESERVE_MS }
)
if (unsubscribeDrain === 'timeout') {
abandonLocalUnsubscribes(draining)
}
}
if (failedLocalUnsubscribes.has(rootKey)) {
throw failedLocalUnsubscribes.get(rootKey)
@@ -831,7 +888,18 @@ export async function closeLocalWatcherForWorktreePath(worktreePath: string): Pr
clearTimeout(root.batch.timer)
}
watchedRoots.delete(rootKey)
await trackLocalUnsubscribe(rootKey, root)
// Why: the in-process Parcel fallback has no unsubscribe timeout of its own, so an unbounded await
// here would hang delete forever and hold the removal gate. The promise stays tracked in
// pendingLocalUnsubscribesByRoot, so a later close still observes its failure.
const finalUnsubscribe = trackLocalUnsubscribe(rootKey, root)
const finalDrain = await drainBeforeWatcherRemoval(
finalUnsubscribe,
deadline,
`local watcher unsubscribe for ${rootKey}`
)
if (finalDrain === 'timeout') {
abandonLocalUnsubscribes([finalUnsubscribe])
}
}
export async function restoreLocalWatcherAfterFailedRemoval(worktreePath: string): Promise<void> {
@@ -9,6 +9,8 @@ export type PendingPtyData = {
droppedOutput?: true
droppedMode2031Data?: string
droppedMode2031ScanState?: Mode2031ReplyScanState
projectionAdmissionIds?: readonly string[]
projectionAdmissionsTransferred?: true
}
export type PtyPendingDataDrainDisposition = 'active' | 'background' | 'blocked'
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'
import {
appendPendingProjectionAdmission,
compactPendingProjectionAdmissions,
PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS,
propagatePendingProjectionRemainder
} from './pty-pending-projection-admissions'
describe('pending PTY projection admissions', () => {
it('retains the exact cap then transfers the whole ordered run on overflow', () => {
const transfer = vi.fn()
let state = compactPendingProjectionAdmissions()
for (let index = 0; index < PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS; index++) {
state = appendPendingProjectionAdmission(state, `projection-${index}`, {
isPending: () => true,
transfer
})
}
expect(state.projectionAdmissionIds).toHaveLength(PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS)
expect(transfer).not.toHaveBeenCalled()
state = appendPendingProjectionAdmission(state, 'projection-overflow', {
isPending: () => true,
transfer
})
expect(state).toEqual({ projectionAdmissionsTransferred: true })
expect(transfer).toHaveBeenCalledOnce()
expect(transfer.mock.calls[0]?.[0]).toHaveLength(PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS + 1)
})
it('compacts terminal prefixes and transfers later admissions until the remainder drains', () => {
const transfer = vi.fn()
const compacted = propagatePendingProjectionRemainder(
{
projectionAdmissionIds: ['projection-published', 'projection-partial', 'projection-tail']
},
{ sent: true, projectionsTransferred: false },
{ isPending: (id) => id !== 'projection-published', transfer }
)
expect(compacted).toEqual({
projectionAdmissionIds: ['projection-partial', 'projection-tail']
})
const transferred = appendPendingProjectionAdmission(
{ projectionAdmissionsTransferred: true },
'projection-after-transfer',
{ isPending: () => true, transfer }
)
expect(transferred).toEqual({ projectionAdmissionsTransferred: true })
expect(transfer).toHaveBeenCalledWith(['projection-after-transfer'], 'pending-projection-cap')
expect(
propagatePendingProjectionRemainder(
compacted,
{ sent: true, projectionsTransferred: true },
{ isPending: () => true, transfer }
)
).toEqual({ projectionAdmissionsTransferred: true })
})
})
@@ -0,0 +1,57 @@
export const PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS = 1024
export type PendingProjectionAdmissions = Readonly<{
projectionAdmissionIds?: readonly string[]
projectionAdmissionsTransferred?: true
}>
type PendingProjectionAdmissionOptions = Readonly<{
isPending: (id: string) => boolean
transfer: (ids: readonly string[], reason: string) => void
}>
const DEFAULT_OPTIONS: PendingProjectionAdmissionOptions = {
isPending: () => true,
transfer: () => {}
}
export function compactPendingProjectionAdmissions(
state: PendingProjectionAdmissions = {},
options: PendingProjectionAdmissionOptions = DEFAULT_OPTIONS
): PendingProjectionAdmissions {
if (state.projectionAdmissionsTransferred) {
return { projectionAdmissionsTransferred: true }
}
const ids = Array.from(
new Set((state.projectionAdmissionIds ?? []).filter((id) => options.isPending(id)))
)
return ids.length > 0 ? { projectionAdmissionIds: ids } : {}
}
export function appendPendingProjectionAdmission(
state: PendingProjectionAdmissions,
id: string,
options: PendingProjectionAdmissionOptions
): PendingProjectionAdmissions {
if (state.projectionAdmissionsTransferred) {
options.transfer([id], 'pending-projection-cap')
return { projectionAdmissionsTransferred: true }
}
const compacted = compactPendingProjectionAdmissions(state, options)
const ids = [...(compacted.projectionAdmissionIds ?? []), id]
if (ids.length <= PTY_PENDING_PROJECTION_ADMISSION_MAX_IDS) {
return { projectionAdmissionIds: ids }
}
options.transfer(ids, 'pending-projection-cap')
return { projectionAdmissionsTransferred: true }
}
export function propagatePendingProjectionRemainder(
state: PendingProjectionAdmissions,
delivery: Readonly<{ sent: boolean; projectionsTransferred: boolean }>,
options: PendingProjectionAdmissionOptions
): PendingProjectionAdmissions {
return delivery.sent && !delivery.projectionsTransferred
? compactPendingProjectionAdmissions(state, options)
: { projectionAdmissionsTransferred: true }
}
+198 -1
View File
@@ -247,6 +247,11 @@ import { resolveWindowsShellLaunchArgs } from '../providers/windows-shell-args'
import { _resetWslCachesForTests, _setWslCachesForTests } from '../wsl'
import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager'
import { acquireWatcherRemovalGate } from './watcher-removal-gate'
import {
acceptSshPtyOutputData,
acceptSshPtyOutputExit,
closeSshPtyOutputGeneration
} from './ssh-pty-output-intake-registry'
// Why: Windows resolves a bare PowerShell name to an absolute exe before ConPTY, else CreateProcessW fails with error 5 (PR #6537 / #5161).
const RESOLVED_WINDOWS_POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
@@ -449,7 +454,8 @@ describe('registerPtyHandlers', () => {
'ssh-reattach-1',
'ssh-reattach-fail',
'ssh-reattach-ok',
'ssh-runtime-env'
'ssh-runtime-env',
'ssh-generation-replacement'
]) {
unregisterSshPtyProvider(leakedConnectionId)
}
@@ -11556,6 +11562,197 @@ describe('registerPtyHandlers', () => {
}
})
it('keeps negotiated source-credit overflow off the legacy PTY-global pause path', async () => {
vi.useFakeTimers()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const provider = installObservableDaemonTestProvider()
let modelSequence = 0
const runtime = {
setPtyController: vi.fn(),
setRemoteTerminalSourceRangeConsumerHooks: vi.fn(),
getPtyOutputSequence: vi.fn(() => modelSequence),
onPtyData: vi.fn(
(_id: string, data: string, _at: number, rawLength = data.length) =>
(modelSequence += rawLength)
),
acceptPtyDataBounded: vi.fn(
(_id: string, _data: string, _at: number, rawLength: number) => {
modelSequence += rawLength
return { sequence: modelSequence, completion: Promise.resolve() }
}
)
}
registerPtyHandlers(mainWindow as never, runtime as never)
mainWindow.webContents.send.mockClear()
const sourceChunk = 's'.repeat(128 * 1024)
for (let index = 0; index < 17; index++) {
const sourceStartSu = index * sourceChunk.length
await acceptSshPtyOutputData({
id: 'source-credit-pty',
data: sourceChunk,
providerGeneration: 41,
ptyIncarnation: 'source-incarnation',
rawLength: sourceChunk.length,
transformed: false,
source: {
relayPtyId: 'relay-source-pty',
spanId: `source-token:${sourceStartSu}:${sourceStartSu + sourceChunk.length}`,
clientGeneration: 2,
ownerGeneration: 3,
deliveryToken: 'source-token',
sourceStartSu,
sourceEndSu: sourceStartSu + sourceChunk.length
}
})
}
expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({
pendingPtyCount: 1,
pendingChars: 0
})
expect(provider.pauseProducer).not.toHaveBeenCalledWith('source-credit-pty')
expect(provider.resumeProducer).not.toHaveBeenCalledWith('source-credit-pty')
provider.emitData('legacy-pty', 'l'.repeat(320 * 1024))
expect(provider.pauseProducer).toHaveBeenCalledTimes(1)
expect(provider.pauseProducer).toHaveBeenCalledWith('legacy-pty')
expect(provider.pauseProducer).not.toHaveBeenCalledWith('unrelated-pty')
vi.runAllTimers()
expect(provider.resumeProducer).toHaveBeenCalledTimes(1)
expect(provider.resumeProducer).toHaveBeenCalledWith('legacy-pty')
} finally {
errorSpy.mockRestore()
vi.useRealTimers()
}
})
it('pauses and resumes the exact SSH provider generation across reconnect replacement', async () => {
vi.useFakeTimers()
const completion = makeDeferred()
let sequence = 0
let captures = 0
const runtime = {
setPtyController: vi.fn(),
setRemoteTerminalSourceRangeConsumerHooks: vi.fn(),
getPtyOutputSequence: vi.fn(() => sequence),
acceptPtyDataBounded: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => {
sequence += rawLength
captures++
return {
sequence,
completion: captures === 1 ? completion.promise : Promise.resolve()
}
})
}
const original = {
providerGeneration: 41,
hasPtyDeliveryPauseAdapter: () => true,
pauseProducer: vi.fn(),
resumeProducer: vi.fn()
}
const replacement = {
providerGeneration: 42,
hasPtyDeliveryPauseAdapter: () => true,
pauseProducer: vi.fn(),
resumeProducer: vi.fn()
}
const id = 'ssh:ssh-generation-replacement@@relay-pty'
const receipts: Promise<unknown>[] = []
try {
registerPtyHandlers(mainWindow as never, runtime as never)
registerSshPtyProvider('ssh-generation-replacement', original as never)
const running = acceptSshPtyOutputData({
id,
data: 'a'.repeat(256 * 1024),
providerGeneration: 41,
ptyIncarnation: 'incarnation-41',
rawLength: 256 * 1024,
transformed: false
})
receipts.push(running)
registerSshPtyProvider('ssh-generation-replacement', replacement as never)
const pressured = acceptSshPtyOutputData({
id,
data: 'b',
providerGeneration: 41,
ptyIncarnation: 'incarnation-41',
rawLength: 1,
transformed: false
})
receipts.push(pressured)
expect(original.pauseProducer).toHaveBeenCalledWith(id)
expect(replacement.pauseProducer).not.toHaveBeenCalled()
completion.resolve()
await Promise.all([running, pressured])
expect(original.resumeProducer).toHaveBeenCalledWith(id)
expect(replacement.resumeProducer).not.toHaveBeenCalled()
} finally {
completion.resolve()
await Promise.allSettled(receipts)
closeSshPtyOutputGeneration(41, 'test-cleanup')
unregisterSshPtyProvider('ssh-generation-replacement')
}
})
it('rejects local data while an SSH renderer exit waits for projection settlement', async () => {
const provider = installObservableDaemonTestProvider()
let sequence = 0
const runtime = {
setPtyController: vi.fn(),
setRemoteTerminalSourceRangeConsumerHooks: vi.fn(),
getPtyOutputSequence: vi.fn(() => sequence),
acceptPtyDataBounded: vi.fn((_id: string, _data: string, _at: number, rawLength: number) => {
sequence += rawLength
return { sequence, completion: Promise.resolve() }
}),
onPtyData: vi.fn(),
onPtyExit: vi.fn()
}
const id = 'ssh:exit-data-race@@relay-pty'
registerPtyHandlers(mainWindow as never, runtime as never)
mainWindow.webContents.send.mockClear()
await acceptSshPtyOutputData({
id,
data: 'before-exit',
providerGeneration: 51,
ptyIncarnation: 'incarnation-51',
rawLength: 'before-exit'.length,
transformed: false
})
const exit = acceptSshPtyOutputExit({
id,
code: 0,
providerGeneration: 51,
ptyIncarnation: 'incarnation-51'
})
await Promise.resolve()
provider.emitData(id, 'must-not-follow-exit')
expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('pty:data', {
id,
data: 'must-not-follow-exit'
})
getPtyAckDataListener()(null, { id, processedChars: 'before-exit'.length })
await exit
expect(mainWindow.webContents.send.mock.calls.at(-1)).toEqual([
'pty:exit',
{
id,
code: 0,
providerGeneration: 51,
ptyIncarnation: 'incarnation-51'
}
])
})
it('resumes a paused producer when the PTY exits before draining', async () => {
vi.useFakeTimers()
try {
+471 -168
View File
@@ -158,6 +158,19 @@ import {
unmarkHiddenRendererPty
} from './pty-hidden-delivery-gate'
import { PtyPendingDataDrainQueue, type PendingPtyData } from './pty-pending-data-drain-queue'
import {
appendPendingProjectionAdmission,
compactPendingProjectionAdmissions,
propagatePendingProjectionRemainder,
type PendingProjectionAdmissions
} from './pty-pending-projection-admissions'
import { SshPtyOutputIntake } from './ssh-pty-output-intake'
import {
cancelSshPtySourceDelivery,
installSshPtyOutputIntake,
publishSshPtySourceAck
} from './ssh-pty-output-intake-registry'
import type { LegacySshProjectionSemantics } from './ssh-pty-legacy-projection'
import {
clearNativeWindowsConptyPty,
isNativeWindowsLocalPtySpawn,
@@ -202,6 +215,7 @@ type FreshLocalFallbackProvider = IPtyProvider & {
routesFreshSpawnsToLocalProvider?: true
}
const sshProviders = new Map<string, IPtyProvider>()
const sshProvidersByGeneration = new Map<number, IPtyProvider>()
type RegisteredPtyProvider = {
provider: IPtyProvider
@@ -1296,10 +1310,19 @@ function beginPtySpawnForWorktree(
/** Register an SSH PTY provider for a connection. */
export function registerSshPtyProvider(connectionId: string, provider: IPtyProvider): void {
sshProviders.set(connectionId, provider)
const generation = (provider as { providerGeneration?: number }).providerGeneration
if (Number.isSafeInteger(generation) && generation! > 0) {
sshProvidersByGeneration.set(generation!, provider)
}
}
/** Remove an SSH PTY provider when a connection is closed. */
export function unregisterSshPtyProvider(connectionId: string): void {
const provider = sshProviders.get(connectionId)
const generation = (provider as { providerGeneration?: number } | undefined)?.providerGeneration
if (generation !== undefined && sshProvidersByGeneration.get(generation) === provider) {
sshProvidersByGeneration.delete(generation)
}
sshProviders.delete(connectionId)
}
@@ -1467,6 +1490,7 @@ let rendererDidStartLoadingHandler: (() => void) | null = null
// Why: Restart daemon must re-bind provider→renderer listeners after replaceDaemonProvider swaps localProvider, else subscribers stay bound to the disposed adapter and new PTY data silently drops.
let rebindProviderListeners: (() => void) | null = null
let sshOutputIntakeCleanup: (() => void) | null = null
export function rebindLocalProviderListeners(): void {
rebindProviderListeners?.()
@@ -1840,8 +1864,10 @@ export function registerPtyHandlers(
},
() => isHiddenPtyDeliveryGateEnabled(getSettings?.())
)
let sshOutputIntake: SshPtyOutputIntake | null = null
// Why: resuming a paused producer during exit can synchronously emit; those bytes must not queue behind pty:exit.
const rendererExitingPtyIds = new Set<string>()
const rendererCreditBeforeExitByPty = new Map<string, boolean>()
const rendererDeliveryRestoreNeededPtys = new Set<string>()
function transitionHiddenRendererPtyDeliveryState(id: string, hidden: boolean) {
@@ -1927,11 +1953,18 @@ export function registerPtyHandlers(
pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id),
resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id)
})
const sourceCreditPendingPtys = new Set<string>()
function updateProducerFlowControl(id: string): void {
if (!PRODUCER_FLOW_CONTROL_ENABLED) {
return
}
if (sourceCreditPendingPtys.has(id)) {
if (pendingData.get(id)) {
return
}
sourceCreditPendingPtys.delete(id)
}
producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0)
}
@@ -2000,7 +2033,16 @@ export function registerPtyHandlers(
}
function clearPendingPtyData(): void {
for (const pending of pendingData.values()) {
if (pending.projectionAdmissionIds) {
sshOutputIntake?.transferProjections(
pending.projectionAdmissionIds,
'renderer-lifecycle-reset'
)
}
}
pendingData.clear()
sourceCreditPendingPtys.clear()
}
function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot {
@@ -2169,6 +2211,9 @@ export function registerPtyHandlers(
producerFlowControl.releaseAll()
clearDeliveryResyncProbe()
deliveryResyncUnansweredWarnLogged = false
for (const id of rendererDeliveryAccountingByPty.keys()) {
sshOutputIntake?.transferPtyProjections(id, 'renderer-lifecycle-reset')
}
rendererDeliveryAccountingByPty.clear()
rendererInFlightTotalChars = 0
clearPendingPtyData()
@@ -2265,6 +2310,9 @@ export function registerPtyHandlers(
accounting.lastAckAtMs = Date.now()
}
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged)
if (acknowledged > 0) {
sshOutputIntake?.settleProjectionPrefix(id, acknowledged)
}
return acknowledged
}
@@ -2336,6 +2384,12 @@ export function registerPtyHandlers(
// Why drop pending: everything at/before markerSeq comes from the snapshot, so flushing pre-marker bytes would double-paint the restore.
const pending = pendingData.get(id)
if (pending) {
if (pending.projectionAdmissionIds) {
sshOutputIntake?.transferProjections(
pending.projectionAdmissionIds,
'renderer-delivery-writeoff'
)
}
pendingDroppedChars += pending.data.length
deletePendingPtyData(id)
pendingOverflowMarkedPtys.delete(id)
@@ -2365,7 +2419,11 @@ export function registerPtyHandlers(
return writtenOff
}
function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): boolean {
function sendPtyDataToRenderer(
id: string,
payload: PtyDataPayload,
projectionAdmissionIds?: readonly string[]
): { sent: boolean; projectionsTransferred: boolean } {
const charCount = getPtyPayloadCharCount(payload)
const accounting = rendererDeliveryAccountingByPty.get(id)
const hadAccounting = accounting !== undefined
@@ -2400,12 +2458,28 @@ export function registerPtyHandlers(
}
}
rendererDeliveryRestoreNeededPtys.add(id)
if (projectionAdmissionIds) {
sshOutputIntake?.transferProjections(projectionAdmissionIds, 'renderer-send-failed')
}
mainDeliveryBreadcrumbs.record('pty-data-send-failed', {
id: redactPtyIdForDiagnostics(id),
chars: charCount
})
console.error('[pty] renderer data send failed; payload will not be retried', error)
return false
return { sent: false, projectionsTransferred: projectionAdmissionIds !== undefined }
}
let projectionsTransferred = false
if (projectionAdmissionIds) {
try {
sshOutputIntake?.publishProjectionPrefix(
projectionAdmissionIds,
payload.data.length,
charCount
)
} catch {
sshOutputIntake?.transferProjections(projectionAdmissionIds, 'projection-publish-failed')
projectionsTransferred = true
}
}
if (rendererDeliveryRestoreNeededPtys.has(id)) {
try {
@@ -2418,7 +2492,7 @@ export function registerPtyHandlers(
)
}
}
return true
return { sent: true, projectionsTransferred }
}
function rendererPtyIsKnownHidden(id: string): boolean {
@@ -2521,6 +2595,9 @@ export function registerPtyHandlers(
pendingOverflowMarkedPtys.add(id)
}
pendingDroppedChars += pending.data.length
if (pending.projectionAdmissionIds) {
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'pending-cap')
}
const mode2031 = scanDroppedMode2031Data(pending.data, INITIAL_MODE_2031_REPLY_SCAN_STATE)
// Why no trimmed content tail: a mid-stream gap would corrupt the pane; the droppedOutput sentinel repaints from the snapshot and realigns by sequence (only query bytes ride along).
return {
@@ -2531,6 +2608,39 @@ export function registerPtyHandlers(
}
}
function updatePendingProjectionAdmissions(
pending: PendingPtyData,
state: PendingProjectionAdmissions
): void {
delete pending.projectionAdmissionIds
delete pending.projectionAdmissionsTransferred
if (state.projectionAdmissionIds) {
pending.projectionAdmissionIds = state.projectionAdmissionIds
}
if (state.projectionAdmissionsTransferred) {
pending.projectionAdmissionsTransferred = true
}
}
function compactPendingProjectionState(
pending: PendingProjectionAdmissions,
projectionSemanticsId?: string
): PendingProjectionAdmissions {
const options = pendingProjectionAdmissionOptions()
const compacted = compactPendingProjectionAdmissions(pending, options)
return projectionSemanticsId
? appendPendingProjectionAdmission(compacted, projectionSemanticsId, options)
: compacted
}
function pendingProjectionAdmissionOptions() {
return {
isPending: (id: string) => sshOutputIntake?.hasUnpublishedProjection(id) ?? false,
transfer: (ids: readonly string[], reason: string) =>
sshOutputIntake?.transferProjections(ids, reason)
}
}
function appendPendingPtyData(
id: string,
existing: PendingPtyData | undefined,
@@ -2539,10 +2649,14 @@ export function registerPtyHandlers(
preservesSeq: boolean,
containsBackgroundOutput: boolean,
rawLength = data.length,
transformed = false
transformed = false,
projectionSemanticsId?: string
): PendingPtyData {
// Why stay dropped at O(1): once over the cap the restore sentinel supersedes interim bytes; queries still get carved out (bounded) so replies survive the whole episode.
if (existing?.droppedOutput === true) {
if (projectionSemanticsId) {
sshOutputIntake?.transferProjections([projectionSemanticsId], 'pending-cap')
}
const mode2031 = scanDroppedMode2031Data(
data,
existing.droppedMode2031ScanState ?? INITIAL_MODE_2031_REPLY_SCAN_STATE
@@ -2559,16 +2673,19 @@ export function registerPtyHandlers(
droppedMode2031ScanState: mode2031.state
}
}
const projectionState = compactPendingProjectionState(existing ?? {}, projectionSemanticsId)
const nextContainsBackgroundOutput =
existing?.containsBackgroundOutput === true || containsBackgroundOutput
if (!existing) {
return dropOversizedPendingPtyData(id, {
const pending: PendingPtyData = {
data,
...(typeof startSeq === 'number' ? { startSeq } : {}),
...(rawLength !== data.length ? { rawLength } : {}),
...(transformed ? { transformed: true } : {}),
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
})
}
updatePendingProjectionAdmissions(pending, projectionState)
return dropOversizedPendingPtyData(id, pending)
}
const existingRawLength = existing.rawLength ?? existing.data.length
const next: PendingPtyData = {
@@ -2578,6 +2695,7 @@ export function registerPtyHandlers(
: {}),
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
}
updatePendingProjectionAdmissions(next, projectionState)
if (typeof existing.startSeq === 'number') {
next.startSeq = existing.startSeq
}
@@ -2661,6 +2779,9 @@ export function registerPtyHandlers(
pendingOverflowMarkedPtys.delete(id)
updateProducerFlowControl(id)
const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length)
if (pending.projectionAdmissionIds) {
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop')
}
warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length)
if (drop.shouldEmitRestoreMarker) {
sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id))
@@ -2676,11 +2797,15 @@ export function registerPtyHandlers(
updateProducerFlowControl(id)
// Why droppedOutput sentinel: pending-cap drop means the pane must repaint from the snapshot, not continue a gapped stream (data = carved query bytes only).
if (
!sendPtyDataToRenderer(id, {
!sendPtyDataToRenderer(
id,
data: pending.data + getDroppedMode2031RendererData(pending),
droppedOutput: true
})
{
id,
data: pending.data + getDroppedMode2031RendererData(pending),
droppedOutput: true
},
pending.projectionAdmissionIds
).sent
) {
sendFailed = true
break
@@ -2692,33 +2817,50 @@ export function registerPtyHandlers(
const indivisible = pending.transformed === true
const chunk = indivisible ? data : data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS)
const remaining = indivisible ? '' : data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS)
let nextPending: PendingPtyData | undefined
if (remaining) {
const nextPending: PendingPtyData = { data: remaining }
nextPending = { data: remaining }
if (typeof pending.startSeq === 'number') {
nextPending.startSeq = pending.startSeq + chunk.length
}
if (pending.containsBackgroundOutput === true) {
nextPending.containsBackgroundOutput = true
}
if (pending.projectionAdmissionIds) {
nextPending.projectionAdmissionIds = pending.projectionAdmissionIds
}
if (pending.projectionAdmissionsTransferred) {
nextPending.projectionAdmissionsTransferred = true
}
pendingData.replaceWithRemainder(selection, nextPending)
} else {
pendingData.remove(selection)
pendingOverflowMarkedPtys.delete(id)
}
updateProducerFlowControl(id)
if (
!sendPtyDataToRenderer(
const delivery = sendPtyDataToRenderer(
id,
makePtyDataPayload(
id,
makePtyDataPayload(
id,
chunk,
pending.startSeq,
pending.containsBackgroundOutput,
pending.rawLength,
pending.transformed
chunk,
pending.startSeq,
pending.containsBackgroundOutput,
pending.rawLength,
pending.transformed
),
pending.projectionAdmissionIds
)
if (nextPending) {
updatePendingProjectionAdmissions(
nextPending,
propagatePendingProjectionRemainder(
nextPending,
delivery,
pendingProjectionAdmissionOptions()
)
)
) {
}
if (!delivery.sent) {
sendFailed = true
break
}
@@ -2781,27 +2923,45 @@ export function registerPtyHandlers(
return true
}
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
function preparePtyExitForRenderer(payload: { id: string; code: number }): (() => void) | null {
if (mainWindow.isDestroyed()) {
return
sshOutputIntake?.transferPtyProjections(payload.id, 'renderer-destroyed')
return () => {}
}
if (rendererExitingPtyIds.has(payload.id)) {
return
return null
}
rendererExitingPtyIds.add(payload.id)
let released = false
const release = (): void => {
if (released) {
return
}
released = true
rendererExitingPtyIds.delete(payload.id)
}
try {
const hadReleasableRendererCredit = getRendererInFlightCharsForPty(payload.id) > 0
if (!rendererCreditBeforeExitByPty.has(payload.id)) {
rendererCreditBeforeExitByPty.set(
payload.id,
getRendererInFlightCharsForPty(payload.id) > 0
)
}
// Why flush before exit: the renderer tears down the terminal on pty:exit, so any batched output not yet flushed would be silently lost.
const remaining = pendingData.delete(payload.id)
clearFlushTimerIfIdle()
if (remaining) {
if (remaining.droppedOutput === true) {
// Sentinel entry: only salvaged query bytes remain; keep the flag so the renderer knows the span was dropped.
sendPtyDataToRenderer(payload.id, {
id: payload.id,
data: remaining.data,
droppedOutput: true
})
sendPtyDataToRenderer(
payload.id,
{
id: payload.id,
data: remaining.data,
droppedOutput: true
},
remaining.projectionAdmissionIds
)
} else {
sendPtyDataToRenderer(
payload.id,
@@ -2812,35 +2972,63 @@ export function registerPtyHandlers(
remaining.containsBackgroundOutput,
remaining.rawLength,
remaining.transformed
)
),
remaining.projectionAdmissionIds
)
}
}
// Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id.
producerFlowControl.release(payload.id)
pendingOverflowMarkedPtys.delete(payload.id)
rendererDeliveryRestoreNeededPtys.delete(payload.id)
lastInputAtByPty.delete(payload.id)
interactiveOutputCharsByPty.delete(payload.id)
const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id)
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit)
// Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides.
rendererDeliveryAccountingByPty.delete(payload.id)
if (hadReleasableRendererCredit) {
if (pendingDataFlushActive) {
// Why: let the open round coalesce this wake into its one post-round continuation.
const reactivatedBlocked = pendingData.reactivateBlocked()
pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked
} else {
schedulePendingDataAfterCreditReport(true)
}
return release
} catch (error) {
release()
throw error
}
}
function finalizePtyExitForRenderer(payload: { id: string; code: number }): void {
if (mainWindow.isDestroyed()) {
rendererCreditBeforeExitByPty.delete(payload.id)
return
}
const hadReleasableRendererCredit =
rendererCreditBeforeExitByPty.get(payload.id) ??
getRendererInFlightCharsForPty(payload.id) > 0
rendererCreditBeforeExitByPty.delete(payload.id)
// Why resume a dead PTY (no-op): avoid leaving a stale paused mark behind for a reused id.
producerFlowControl.release(payload.id)
sourceCreditPendingPtys.delete(payload.id)
pendingOverflowMarkedPtys.delete(payload.id)
rendererDeliveryRestoreNeededPtys.delete(payload.id)
lastInputAtByPty.delete(payload.id)
interactiveOutputCharsByPty.delete(payload.id)
const releasedRendererCredit = getRendererInFlightCharsForPty(payload.id)
rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - releasedRendererCredit)
// Why: the renderer also drops its cumulative total on pty:exit, so a reused id restarts aligned at zero on both sides.
rendererDeliveryAccountingByPty.delete(payload.id)
if (hadReleasableRendererCredit) {
if (pendingDataFlushActive) {
// Why: let the open round coalesce this wake into its one post-round continuation.
const reactivatedBlocked = pendingData.reactivateBlocked()
pendingDataCreditReleasedDuringFlush ||= reactivatedBlocked
} else {
schedulePendingDataAfterCreditReport(true)
}
mainWindow.webContents.send('pty:exit', {
...payload,
...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {})
})
}
mainWindow.webContents.send('pty:exit', {
...payload,
...(reversibleStopOwnersByPtyId.has(payload.id) ? { preserveRendererBinding: true } : {})
})
}
function sendPtyExitToRenderer(payload: { id: string; code: number }): void {
const release = preparePtyExitForRenderer(payload)
if (!release) {
return
}
try {
sshOutputIntake?.transferPtyProjections(payload.id, 'legacy-pty-exit')
finalizePtyExitForRenderer(payload)
} finally {
rendererExitingPtyIds.delete(payload.id)
release()
}
}
@@ -2850,6 +3038,231 @@ export function registerPtyHandlers(
}
}
function acceptPtyDataForRenderer(
payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
},
outputSeq: number | undefined,
projection?: LegacySshProjectionSemantics
): void {
const rawLength = payload.sequenceChars ?? payload.data.length
const preservesSeq = !payload.transformed && rawLength === payload.data.length
const startSeq = typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
const projectionId = projection?.identity.projectionSemanticsId
if (mainWindow.isDestroyed()) {
if (projectionId) {
sshOutputIntake?.transferProjections([projectionId], 'renderer-destroyed')
}
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
producerFlowControl.releaseAll()
clearDeliveryResyncProbe()
clearPendingPtyData()
pendingOverflowMarkedPtys.clear()
rendererDeliveryAccountingByPty.clear()
rendererInFlightTotalChars = 0
clearDispatcherReadyWatchdog()
return
}
if (rendererExitingPtyIds.has(payload.id)) {
if (projectionId) {
sshOutputIntake?.transferProjections([projectionId], 'pty-exiting')
}
return
}
if (shouldDropHiddenRendererPtyData(payload.id, getSettings?.())) {
if (projectionId) {
sshOutputIntake?.transferProjections([projectionId], 'hidden-drop')
}
const droppedChars = projection ? rawLength : payload.data.length
const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars)
warnIfDroppingHiddenBytesForVisiblePty(payload.id, droppedChars)
if (drop.shouldEmitRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
}
return
}
if (payload.data.length === 0 && !payload.transformed) {
if (projectionId) {
sshOutputIntake?.transferProjections([projectionId], 'empty-projection')
}
return
}
const containsBackgroundOutput =
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
if (containsBackgroundOutput) {
markHiddenRendererResizeOutputDelivered(payload.id)
}
const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id)
if (projection?.desktopSpan) {
sourceCreditPendingPtys.add(payload.id)
}
const pending = appendPendingPtyData(
payload.id,
pendingData.get(payload.id),
payload.data,
startSeq,
preservesSeq,
containsBackgroundOutput,
rawLength,
payload.transformed === true,
projectionId
)
const shouldEmitPendingCapRestoreMarker =
pending.droppedOutput === true &&
!overflowMarkedBeforeAppend &&
pendingOverflowMarkedPtys.has(payload.id)
const nextData = pending.data + getDroppedMode2031RendererData(pending)
const isInteractiveOutput = shouldSendInteractiveOutputNow(
payload.id,
nextData,
performance.now()
)
if (isInteractiveOutput && rendererPtyDispatcherReady) {
if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) {
setPendingPtyData(payload.id, pending)
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
updateProducerFlowControl(payload.id)
requestDeliveryResyncForGatedPty()
return
}
deletePendingPtyData(payload.id)
clearFlushTimerIfIdle()
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
pendingOverflowMarkedPtys.delete(payload.id)
try {
sendPtyDataToRenderer(
payload.id,
{
id: payload.id,
data: nextData,
...(typeof pending.startSeq === 'number'
? {
seq: pending.startSeq + (pending.rawLength ?? nextData.length),
rawLength: pending.rawLength ?? nextData.length
}
: {}),
...(pending.transformed ? { transformed: true } : {}),
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
},
pending.projectionAdmissionIds
)
} finally {
updateProducerFlowControl(payload.id)
}
return
}
setPendingPtyData(payload.id, pending)
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
updateProducerFlowControl(payload.id)
if (
!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })
) {
requestDeliveryResyncForGatedPty()
}
if (!flushTimer) {
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
}
}
sshOutputIntakeCleanup?.()
sshOutputIntake = new SshPtyOutputIntake({
getModelSequence: (id) => runtime?.getPtyOutputSequence(id) ?? 0,
acceptModel: (event, projection) => {
if (!runtime) {
throw new Error('SSH PTY output requires the main terminal model')
}
return runtime.acceptPtyDataBounded(
event.id,
event.data,
Date.now(),
event.rawLength,
event.transformed,
projection.desktopSpan ? [projection.desktopSpan] : undefined
)
},
project: (event, projection) =>
acceptPtyDataForRenderer(
{
id: event.id,
data: event.data,
sequenceChars: event.rawLength,
transformed: event.transformed
},
projection.identity.sequenceEnd,
projection
),
prepareExit: (event) => {
const release = preparePtyExitForRenderer(event)
if (!release) {
throw new Error('pty_renderer_exit_in_progress')
}
return release
},
finalizeExit: (event) => {
runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation)
finalizePtyExitForRenderer(event)
},
pauseProvider: (generation, id) => {
const provider = sshProvidersByGeneration.get(generation) as
| (IPtyProvider & { hasPtyDeliveryPauseAdapter?: () => boolean })
| undefined
if (!provider?.hasPtyDeliveryPauseAdapter?.()) {
return false
}
provider.pauseProducer?.(id)
return true
},
resumeProvider: (generation, id) =>
sshProvidersByGeneration.get(generation)?.resumeProducer?.(id),
closeProvider: (generation, reason) => {
const provider = sshProvidersByGeneration.get(generation)
;(
provider as (IPtyProvider & { closeOutputIntake?: (reason: string) => void }) | undefined
)?.closeOutputIntake?.(reason)
},
resetModelForMigration: (_generation, id) => runtime?.resetPtyModelAfterMigrationFailure(id),
onGenerationClosed: (providerGeneration) => {
for (const id of pendingData.keys()) {
const pending = pendingData.get(id)
if (
pending?.projectionAdmissionIds &&
sshOutputIntake?.hasProjectionFromGeneration(
pending.projectionAdmissionIds,
providerGeneration
)
) {
pendingData.delete(id)
updateProducerFlowControl(id)
pendingOverflowMarkedPtys.delete(id)
}
}
sshProvidersByGeneration.delete(providerGeneration)
},
publishSourceAck: publishSshPtySourceAck,
cancelSourceDelivery: cancelSshPtySourceDelivery
})
runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(
sshOutputIntake.getRemoteSourceRangeConsumerHooks()
)
const cleanupSshOutputIntakeRegistry = installSshPtyOutputIntake(sshOutputIntake)
sshOutputIntakeCleanup = () => {
runtime?.setRemoteTerminalSourceRangeConsumerHooks?.(null)
cleanupSshOutputIntakeRegistry()
}
async function shutdownProviderAndDetectExit(
provider: IPtyProvider,
id: string,
@@ -2933,120 +3346,7 @@ export function registerPtyHandlers(
const outputSeq = isLocalProvider
? runtime?.getPtyOutputSequence(payload.id)
: runtime?.onPtyData(payload.id, payload.data, Date.now(), rawLength, payload.transformed)
const rendererData = payload.data
const preservesSeq = !payload.transformed && rawLength === payload.data.length
const startSeq =
typeof outputSeq === 'number' ? Math.max(0, outputSeq - rawLength) : undefined
if (mainWindow.isDestroyed()) {
// Why clear the flush timer: macOS app re-activation otherwise leaks orphaned timers from the previous window's registration.
if (flushTimer) {
clearTimeout(flushTimer)
flushTimer = null
}
producerFlowControl.releaseAll()
clearDeliveryResyncProbe()
clearPendingPtyData()
pendingOverflowMarkedPtys.clear()
rendererDeliveryAccountingByPty.clear()
rendererInFlightTotalChars = 0
clearDispatcherReadyWatchdog()
return
}
if (rendererExitingPtyIds.size > 0 && rendererExitingPtyIds.has(payload.id)) {
return
}
const settings = getSettings?.()
// Why drop before the interactive bypass: runtime already ingested the chunk, so gated PTYs skip both renderer paths and reveal restores from the snapshot.
if (shouldDropHiddenRendererPtyData(payload.id, settings)) {
const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length)
warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length)
if (drop.shouldEmitRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq)
}
return
}
if (rendererData.length === 0 && !payload.transformed) {
return
}
const containsBackgroundOutput =
rendererPtyIsKnownHidden(payload.id) || ptyHasHiddenRendererResizeOutput(payload.id)
if (containsBackgroundOutput) {
markHiddenRendererResizeOutputDelivered(payload.id)
}
const existing = pendingData.get(payload.id)
const overflowMarkedBeforeAppend = pendingOverflowMarkedPtys.has(payload.id)
const pending = appendPendingPtyData(
payload.id,
existing,
rendererData,
startSeq,
preservesSeq,
containsBackgroundOutput,
rawLength,
payload.transformed === true
)
const shouldEmitPendingCapRestoreMarker =
pending.droppedOutput === true &&
!overflowMarkedBeforeAppend &&
pendingOverflowMarkedPtys.has(payload.id)
const nextData = pending.data + getDroppedMode2031RendererData(pending)
const isInteractiveOutput = shouldSendInteractiveOutputNow(
payload.id,
nextData,
performance.now()
)
// Why gate the fast path on the handshake too: else boot-window keystroke echo is sent into a listener-less page and pins the gate.
if (isInteractiveOutput && rendererPtyDispatcherReady) {
// Why the reserve: keep input echo from being pinned behind unrelated bulk output; it's bounded and the per-PTY cap still prevents an active TUI runaway.
if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) {
setPendingPtyData(payload.id, pending)
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
updateProducerFlowControl(payload.id)
requestDeliveryResyncForGatedPty()
return
}
deletePendingPtyData(payload.id)
clearFlushTimerIfIdle()
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
pendingOverflowMarkedPtys.delete(payload.id)
// Why immediate: agent TUIs redraw small prompt regions per keystroke; the throughput batch timer would add visible input latency.
try {
sendPtyDataToRenderer(payload.id, {
id: payload.id,
data: nextData,
...(typeof pending.startSeq === 'number'
? {
seq: pending.startSeq + (pending.rawLength ?? nextData.length),
rawLength: pending.rawLength ?? nextData.length
}
: {}),
...(pending.transformed ? { transformed: true } : {}),
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
})
} finally {
updateProducerFlowControl(payload.id)
}
return
}
setPendingPtyData(payload.id, pending)
if (shouldEmitPendingCapRestoreMarker) {
sendModelRestoreNeededMarker(payload.id, 'pending-cap', outputSeq)
}
updateProducerFlowControl(payload.id)
// Why probe on data arrival (not flush skips): new output for a fully gated PTY is the moment stuck delivery becomes observable.
if (
!canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) })
) {
requestDeliveryResyncForGatedPty()
}
if (!flushTimer) {
schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS)
}
acceptPtyDataForRenderer(payload, outputSeq)
})
localExitUnsub = localProvider.onExit((payload) => {
if (!isCurrentPtyExit(payload)) {
@@ -5646,6 +5946,9 @@ export function registerPtyHandlers(
const pending = pendingData.get(args.id)
if (pending && transition.droppable) {
pendingData.delete(args.id)
if (pending.projectionAdmissionIds) {
sshOutputIntake?.transferProjections(pending.projectionAdmissionIds, 'hidden-drop')
}
updateProducerFlowControl(args.id)
pendingOverflowMarkedPtys.delete(args.id)
const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length)
+71
View File
@@ -1115,6 +1115,7 @@ describe('repos:addRemote', () => {
})
mockStore.getRepos.mockReset().mockReturnValue([])
mockStore.addRepo.mockReset()
mockStore.removeProject.mockReset()
mockStore.getSshTarget.mockReset()
mockStore.updateRepo.mockReset()
mockGitProvider.isGitRepoAsync.mockReset()
@@ -2066,6 +2067,76 @@ describe('repos:add + repos:clone', () => {
})
})
it('sets up a folder when the selected project exists only on another host', async () => {
const added: Record<string, unknown>[] = []
mockStore.getRepos.mockImplementation(() => added)
mockStore.addRepo.mockImplementation((repo: Record<string, unknown>) => added.push(repo))
mockStore.updateRepo.mockImplementation((id, updates) => {
const repo = added.find((entry) => entry.id === id)
if (!repo) {
return null
}
Object.assign(repo, updates)
return { ...repo }
})
mockStore.getProjects.mockImplementation(() => {
const repo = added.find((entry) => 'upstream' in entry)
return repo
? [
{
id: 'github:github.acme.test/acme/orca',
displayName: 'Orca',
providerIdentity: {
provider: 'github',
owner: 'acme',
repo: 'orca',
host: 'github.acme.test'
}
}
]
: []
})
const result = await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: 'github:github.acme.test/acme/orca',
projectProviderIdentity: {
provider: 'github',
owner: 'acme',
repo: 'orca',
host: 'github.acme.test'
},
hostId: 'local',
path: '/tmp/orca-local',
kind: 'git'
})
expect(added[0]?.upstream).toEqual({
owner: 'acme',
repo: 'orca',
host: 'github.acme.test'
})
expect(result).toHaveProperty('project.id', 'github:github.acme.test/acme/orca')
})
it('rolls back a new repo when the supplied identity does not match the project', async () => {
const added: Record<string, unknown>[] = []
mockStore.getRepos.mockImplementation(() => added)
mockStore.addRepo.mockImplementation((repo: Record<string, unknown>) => added.push(repo))
await expect(
handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: 'github:acme/orca',
projectProviderIdentity: { provider: 'github', owner: 'other', repo: 'orca' },
hostId: 'local',
path: '/tmp/mismatched-project',
kind: 'git'
})
).rejects.toThrow('Imported folder does not match the selected project identity.')
expect(mockStore.removeProject).toHaveBeenCalledWith(added[0]?.id)
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
})
it('prepares and invalidates roots when repos:update changes worktree base path', () => {
const updated = {
id: 'repo-update-root',
+37 -17
View File
@@ -89,7 +89,10 @@ import type {
} from '../../shared/host-repo-catalog-contract'
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
import {
getProjectHostSetupForRepo,
getProjectIdForProviderIdentity
} from '../../shared/project-host-setup-projection'
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
@@ -235,20 +238,23 @@ function alignRepoWithRequestedProject(
store: Store,
repo: Repo,
projectId: string,
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder'
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder',
requestedProviderIdentity?: ProjectHostSetupExistingFolderArgs['projectProviderIdentity']
): ProjectHostSetupResult {
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
if (setup.projectId !== projectId) {
const project = store.getProjects().find((entry) => entry.id === projectId)
if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') {
// Why: the selected project can exist only on the source host, so its structured identity travels with the request.
const identity = project?.providerIdentity ?? requestedProviderIdentity
if (!identity || getProjectIdForProviderIdentity(identity) !== projectId) {
throw new Error('Imported folder does not match the selected project identity.')
}
// Why: stamp the selected project's provider identity when the folder lacks upstream, so projection can merge it.
const updated = store.updateRepo(repo.id, {
upstream: {
owner: project.providerIdentity.owner,
repo: project.providerIdentity.repo,
...(project.providerIdentity.host ? { host: project.providerIdentity.host } : {})
owner: identity.owner,
repo: identity.repo,
...(identity.host ? { host: identity.host } : {})
}
})
if (!updated) {
@@ -801,6 +807,14 @@ const ProjectGroupMoveProjectArgs = z.object({
const ProjectHostSetupExistingFolderIpcArgs = z.object({
projectId: z.string().min(1),
projectProviderIdentity: z
.object({
provider: z.literal('github'),
owner: z.string().min(1),
repo: z.string().min(1),
host: z.string().min(1).optional()
})
.optional(),
hostId: z.string().min(1),
path: z.string().min(1),
kind: z.enum(['git', 'folder']).optional(),
@@ -1378,11 +1392,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if (!parsedHost) {
throw new Error(`Unsupported host: ${args.hostId}`)
}
const existingProject = store.getProjects().find((project) => project.id === args.projectId)
if (!existingProject) {
throw new Error(`Project not found: ${args.projectId}`)
}
const result =
parsedHost.kind === 'local'
? await addLocalRepoFromPath(store, args.path, args.kind)
@@ -1400,15 +1409,26 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if ('error' in result) {
throw new Error(result.error)
}
let aligned: ProjectHostSetupResult
try {
aligned = alignRepoWithRequestedProject(
store,
result.repo,
args.projectId,
args.setupMethod,
args.projectProviderIdentity
)
} catch (err) {
// Why: an import that cannot be linked must not leave a new repo registration or authorization root behind.
if (!result.alreadyExisted) {
store.removeProject(result.repo.id)
invalidateAuthorizedRootsCache()
}
throw err
}
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted)
const aligned = alignRepoWithRequestedProject(
store,
result.repo,
args.projectId,
args.setupMethod
)
if (result.alreadyExisted) {
await prepareLocalWorktreeRootForRepo(store, aligned.repo)
}
@@ -0,0 +1,53 @@
type ClosedGenerationRange = {
start: number
end: number
}
export class SshPtyClosedGenerationRanges {
private readonly ranges: ClosedGenerationRange[] = []
add(generation: number): void {
let index = 0
while (index < this.ranges.length && this.ranges[index]!.end + 1 < generation) {
index++
}
const current = this.ranges[index]
if (!current || generation + 1 < current.start) {
this.ranges.splice(index, 0, { start: generation, end: generation })
return
}
current.start = Math.min(current.start, generation)
current.end = Math.max(current.end, generation)
const next = this.ranges[index + 1]
if (next && current.end + 1 >= next.start) {
current.end = Math.max(current.end, next.end)
this.ranges.splice(index + 1, 1)
}
}
has(generation: number): boolean {
for (const range of this.ranges) {
if (generation < range.start) {
return false
}
if (generation <= range.end) {
return true
}
}
return false
}
get size(): number {
return this.ranges.length
}
get activeGaps(): number {
const highWater = this.ranges.at(-1)?.end ?? 0
let closedGenerations = 0
for (const range of this.ranges) {
closedGenerations += range.end - range.start + 1
}
// Why: provider generations allocate from 1, so unclosed IDs below high-water remain active.
return highWater - closedGenerations
}
}
@@ -0,0 +1,115 @@
import {
projectionError,
reclaimProjectionRecord,
type DesktopProjectionSpan,
type ProjectionRecord
} from './ssh-pty-legacy-projection-record'
export function publishLegacyProjectionPrefix(
records: Map<string, ProjectionRecord>,
ids: readonly string[],
displayChars: number,
accountingChars: number
): void {
let displayRemaining = Math.max(0, displayChars)
let accountingRemaining = Math.max(0, accountingChars)
for (const id of ids) {
const record = records.get(id)
if (!record) {
continue
}
const displayLength =
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
const unpublishedDisplay = displayLength - record.publishedDisplay
const unpublishedAccounting = record.semantics.identity.rawLength - record.publishedAccounting
if (displayLength === 0 && unpublishedAccounting > 0) {
const publishAccounting = Math.min(accountingRemaining, unpublishedAccounting)
if (publishAccounting !== unpublishedAccounting) {
throw projectionError('ssh_projection_indivisible_split')
}
record.publishedAccounting += publishAccounting
record.state = 'published'
accountingRemaining -= publishAccounting
continue
}
if (unpublishedDisplay <= 0) {
continue
}
const publishDisplay = Math.min(displayRemaining, unpublishedDisplay)
if (publishDisplay <= 0) {
break
}
if (record.semantics.identity.transformed && publishDisplay !== unpublishedDisplay) {
throw projectionError('ssh_projection_indivisible_split')
}
const publishAccounting =
publishDisplay === unpublishedDisplay
? Math.min(accountingRemaining, unpublishedAccounting)
: publishDisplay
record.publishedDisplay += publishDisplay
record.publishedAccounting += publishAccounting
record.state = 'published'
displayRemaining -= publishDisplay
accountingRemaining -= publishAccounting
}
if (displayRemaining !== 0 || accountingRemaining !== 0) {
throw projectionError('ssh_projection_publish_range_mismatch')
}
}
export function hasUnpublishedLegacyProjection(
records: ReadonlyMap<string, ProjectionRecord>,
id: string
): boolean {
const record = records.get(id)
if (!record || record.state === 'reserved') {
return false
}
const displayLength =
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
return (
record.publishedDisplay < displayLength ||
record.publishedAccounting < record.semantics.identity.rawLength
)
}
export function settlePublishedLegacyProjectionPrefix(
records: Map<string, ProjectionRecord>,
idsByPty: Map<string, string[]>,
ptyId: string,
accountingChars: number,
onSettled: ((span: DesktopProjectionSpan, reason: string) => void) | undefined
): { settled: number; completed: number } {
let remaining = Math.max(0, accountingChars)
let settled = 0
let completed = 0
for (const id of idsByPty.get(ptyId)?.slice() ?? []) {
const record = records.get(id)
if (!record) {
continue
}
const available = record.publishedAccounting - record.settledAccounting
if (available <= 0) {
continue
}
const take = Math.min(remaining, available)
const finishes =
record.settledAccounting + take === record.semantics.identity.rawLength &&
record.publishedDisplay ===
record.semantics.identity.displayEnd - record.semantics.identity.displayStart
if (finishes && record.semantics.desktopSpan) {
onSettled?.(record.semantics.desktopSpan, 'renderer-parse')
}
record.settledAccounting += take
settled += take
remaining -= take
if (finishes) {
completed++
reclaimProjectionRecord(records, idsByPty, id, ptyId)
}
if (remaining === 0) {
break
}
}
return { settled, completed }
}
@@ -0,0 +1,218 @@
import type {
Mode2031ReplyDecision,
Mode2031ReplyScanState
} from '../../shared/terminal-color-scheme-protocol'
import { INITIAL_MODE_2031_REPLY_SCAN_STATE } from '../../shared/terminal-color-scheme-protocol'
import type { TerminalOutputSourceRange } from '../../shared/terminal-output-source-range'
export type LegacySshProjectionIdentity = Readonly<{
projectionSemanticsId: string
ptyId: string
providerGeneration: number
ptyIncarnation: string
displayStart: number
displayEnd: number
sequenceEnd: number
rawLength: number
transformed: boolean
}>
export type LegacySshProjectionSemantics = Readonly<{
identity: LegacySshProjectionIdentity
desktopSpan?: DesktopProjectionSpan
beforeScanner: Readonly<Mode2031ReplyScanState>
afterScanner: Readonly<Mode2031ReplyScanState>
decision: Mode2031ReplyDecision
}>
export type DesktopProjectionSpan = Readonly<
TerminalOutputSourceRange & {
projectionSemanticsId: string
transform: Readonly<{
transformed: boolean
rawLengthSu: number
scalarSafe: boolean
}>
}
>
export type ProjectionState = 'reserved' | 'committed' | 'published'
export type ProjectionRecord = {
semantics: LegacySshProjectionSemantics
state: ProjectionState
publishedDisplay: number
publishedAccounting: number
settledAccounting: number
}
export type PtyProjectionCursor = {
providerGeneration: number
ptyIncarnation: string
displayEnd: number
scanner: Mode2031ReplyScanState
}
export type LegacySshProjectionReservation = Readonly<{
semantics: LegacySshProjectionSemantics
}>
export type LegacySshProjectionDebugSnapshot = {
reserved: number
committed: number
published: number
settled: number
transferred: number
rolledBack: number
records: number
cursors: number
}
export function scannerSnapshot(state: Mode2031ReplyScanState): Readonly<Mode2031ReplyScanState> {
return Object.freeze({ tail: state.tail, pendingSubscribe: state.pendingSubscribe })
}
export function resetProjectionCursorForGap(
cursors: ReadonlyMap<string, PtyProjectionCursor>,
ptyId: string
): void {
const cursor = cursors.get(ptyId)
if (cursor) {
cursor.scanner = { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
}
}
export function projectionError(code: string): Error {
return Object.assign(new Error(code), { code })
}
export function projectionDebugSnapshot(
records: ReadonlyMap<string, ProjectionRecord>,
cursors: ReadonlyMap<string, PtyProjectionCursor>,
terminalCounts: { settled: number; transferred: number; rolledBack: number }
): LegacySshProjectionDebugSnapshot {
const result = {
reserved: 0,
committed: 0,
published: 0,
...terminalCounts,
records: records.size,
cursors: cursors.size
}
for (const record of records.values()) {
result[record.state]++
}
return result
}
export function reclaimProjectionRecord(
records: Map<string, ProjectionRecord>,
idsByPty: Map<string, string[]>,
id: string,
ptyId: string
): void {
records.delete(id)
const ids = idsByPty.get(ptyId)
if (!ids) {
return
}
const index = ids.indexOf(id)
if (index >= 0) {
ids.splice(index, 1)
}
if (ids.length === 0) {
idsByPty.delete(ptyId)
}
}
export function requireProjectionRecord(
records: ReadonlyMap<string, ProjectionRecord>,
id: string
): ProjectionRecord {
const record = records.get(id)
if (!record) {
throw projectionError('ssh_projection_reservation_missing')
}
return record
}
export function rollbackCommittedProjectionRecord(
records: Map<string, ProjectionRecord>,
idsByPty: Map<string, string[]>,
cursors: Map<string, PtyProjectionCursor>,
reservation: LegacySshProjectionReservation
): string | null {
const id = reservation.semantics.identity.projectionSemanticsId
const record = records.get(id)
if (!record || record.state !== 'committed') {
return null
}
const { identity, beforeScanner } = record.semantics
const cursor = cursors.get(identity.ptyId)
if (
idsByPty.get(identity.ptyId)?.at(-1) !== id ||
!cursor ||
cursor.providerGeneration !== identity.providerGeneration ||
cursor.ptyIncarnation !== identity.ptyIncarnation ||
cursor.displayEnd !== identity.displayEnd
) {
return null
}
cursor.displayEnd = identity.displayStart
cursor.scanner = { ...beforeScanner }
reclaimProjectionRecord(records, idsByPty, id, identity.ptyId)
return identity.ptyId
}
export function closeProjectionPty(
cursors: Map<string, PtyProjectionCursor>,
ptyId: string,
providerGeneration: number,
ptyIncarnation: string,
beforeDelete: () => void
): void {
const cursor = cursors.get(ptyId)
if (
!cursor ||
cursor.providerGeneration !== providerGeneration ||
cursor.ptyIncarnation !== ptyIncarnation
) {
return
}
beforeDelete()
cursors.delete(ptyId)
}
export function getOrCreateProjectionCursor(
cursors: Map<string, PtyProjectionCursor>,
args: { ptyId: string; providerGeneration: number; ptyIncarnation: string },
replaceGeneration: (providerGeneration: number) => void
): PtyProjectionCursor {
const existing = cursors.get(args.ptyId)
if (!existing) {
const cursor = {
providerGeneration: args.providerGeneration,
ptyIncarnation: args.ptyIncarnation,
displayEnd: 0,
scanner: { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
}
cursors.set(args.ptyId, cursor)
return cursor
}
if (args.providerGeneration < existing.providerGeneration) {
throw projectionError('ssh_projection_stale_generation')
}
if (
args.providerGeneration === existing.providerGeneration &&
args.ptyIncarnation !== existing.ptyIncarnation
) {
throw projectionError('ssh_projection_stale_incarnation')
}
if (args.providerGeneration > existing.providerGeneration) {
replaceGeneration(existing.providerGeneration)
existing.providerGeneration = args.providerGeneration
existing.ptyIncarnation = args.ptyIncarnation
existing.scanner = { ...INITIAL_MODE_2031_REPLY_SCAN_STATE }
}
return existing
}
@@ -0,0 +1,244 @@
import { describe, expect, it } from 'vitest'
import { SshPtyLegacyProjectionLedger } from './ssh-pty-legacy-projection'
import { SshPtyProjectionTerminality } from './ssh-pty-projection-terminality'
function reserve(
ledger: SshPtyLegacyProjectionLedger,
overrides: Partial<Parameters<SshPtyLegacyProjectionLedger['reserve']>[0]> = {}
) {
return ledger.reserve({
ptyId: 'pty-1',
providerGeneration: 3,
ptyIncarnation: 'incarnation-1',
data: 'abc',
sequenceEnd: 3,
rawLength: 3,
transformed: false,
...overrides
})
}
function source(sourceStartSu: number, sourceEndSu: number, deliveryToken = 'token-1') {
return {
spanId: `span-${sourceStartSu}`,
clientGeneration: 2,
ownerGeneration: 4,
deliveryToken,
sourceStartSu,
sourceEndSu
}
}
describe('SshPtyLegacyProjectionLedger', () => {
it('drains exact terminality waiters when their provider generation closes', async () => {
const terminality = new SshPtyProjectionTerminality()
const terminal = terminality.whenTerminal('pty-1', 3, 'incarnation-1', () => true)
let nextResolved = false
const next = terminality
.whenTerminal('pty-1', 4, 'incarnation-2', () => true)
.then(() => {
nextResolved = true
})
terminality.closeGeneration(3)
await expect(terminal).resolves.toBeUndefined()
expect(nextResolved).toBe(false)
terminality.closeGeneration(4)
await next
})
it('rolls back scanner and display reservations before commit', () => {
const ledger = new SshPtyLegacyProjectionLedger()
const partial = reserve(ledger, { data: '\x1b[?20', rawLength: 5, sequenceEnd: 5 })
expect(ledger.rollback(partial)).toBe(true)
const next = reserve(ledger, { data: '31h', sequenceEnd: 3 })
expect(next.semantics.identity.displayStart).toBe(0)
expect(next.semantics.beforeScanner).toEqual({ tail: '', pendingSubscribe: false })
expect(next.semantics.decision).toBeNull()
})
it('keeps immutable generation, incarnation, display, sequence, raw length, and scanner facts', () => {
const ledger = new SshPtyLegacyProjectionLedger()
const first = reserve(ledger, {
data: '\x1b[?2031h',
rawLength: 11,
sequenceEnd: 11
})
const semantics = ledger.commit(first)
expect(semantics.identity).toMatchObject({
providerGeneration: 3,
ptyIncarnation: 'incarnation-1',
displayStart: 0,
displayEnd: 8,
sequenceEnd: 11,
rawLength: 11
})
expect(semantics.decision).toBe('subscribed')
expect(Object.isFrozen(semantics)).toBe(true)
expect(Object.isFrozen(semantics.identity)).toBe(true)
})
it('rejects stale generations and resets cross-chunk scanner state on gaps', () => {
const ledger = new SshPtyLegacyProjectionLedger()
ledger.commit(reserve(ledger, { data: '\x1b[?20', rawLength: 5, sequenceEnd: 5 }))
ledger.resetForGap('pty-1')
const next = reserve(ledger, { data: '31h', sequenceEnd: 8 })
expect(next.semantics.beforeScanner).toEqual({ tail: '', pendingSubscribe: false })
expect(() => reserve(ledger, { providerGeneration: 2 })).toThrow(
'ssh_projection_stale_generation'
)
})
it('publishes, settles, and transfers explicit ranges', () => {
const ledger = new SshPtyLegacyProjectionLedger()
const first = ledger.commit(reserve(ledger))
ledger.publishPrefix([first.identity.projectionSemanticsId], 3, 3)
expect(ledger.settlePublishedPrefix('pty-1', 2)).toBe(2)
expect(ledger.transfer([first.identity.projectionSemanticsId], 'renderer-reload')).toBe(1)
expect(ledger.getDebugSnapshot()).toMatchObject({ transferred: 1, records: 0 })
})
it('resolves exact PTY terminal waiters only after settlement or transfer', async () => {
const ledger = new SshPtyLegacyProjectionLedger()
const projection = ledger.commit(reserve(ledger))
ledger.publishPrefix([projection.identity.projectionSemanticsId], 3, 3)
let settled = false
const terminal = ledger.whenPtyTerminal('pty-1', 3, 'incarnation-1').then(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
ledger.settlePublishedPrefix('pty-1', 2)
await Promise.resolve()
expect(settled).toBe(false)
ledger.transfer([projection.identity.projectionSemanticsId], 'renderer-reload')
await terminal
expect(settled).toBe(true)
})
it('publishes and settles transformed source accounting with no display text', () => {
const ledger = new SshPtyLegacyProjectionLedger()
const projection = ledger.commit(
reserve(ledger, {
data: '',
sequenceEnd: 9,
rawLength: 9,
transformed: true
})
)
ledger.publishPrefix([projection.identity.projectionSemanticsId], 0, 9)
expect(ledger.settlePublishedPrefix('pty-1', 9)).toBe(9)
expect(ledger.getDebugSnapshot()).toMatchObject({ settled: 1, records: 0 })
})
it('reclaims a closed PTY cursor so its id can be reused by a new incarnation', () => {
const ledger = new SshPtyLegacyProjectionLedger()
ledger.commit(reserve(ledger))
ledger.closePty('pty-1', 3, 'incarnation-1', 'pty-exit')
const next = reserve(ledger, {
ptyIncarnation: 'incarnation-2',
data: 'next',
sequenceEnd: 4,
rawLength: 4
})
expect(next.semantics.identity).toMatchObject({
ptyIncarnation: 'incarnation-2',
displayStart: 0
})
expect(ledger.getDebugSnapshot()).toMatchObject({ records: 1, cursors: 1 })
})
it('keeps split publication attached to one immutable desktop span', () => {
const settled: unknown[] = []
const ledger = new SshPtyLegacyProjectionLedger({
onSettled: (span) => settled.push(span)
})
const projection = ledger.commit(
reserve(ledger, {
data: 'abcd',
rawLength: 4,
sequenceEnd: 4,
source: source(0, 4)
})
)
const id = projection.identity.projectionSemanticsId
expect(ledger.hasUnpublished(id)).toBe(true)
ledger.publishPrefix([id], 2, 2)
expect(ledger.hasUnpublished(id)).toBe(true)
ledger.settlePublishedPrefix('pty-1', 2)
expect(settled).toEqual([])
ledger.publishPrefix([id], 2, 2)
expect(ledger.hasUnpublished(id)).toBe(false)
ledger.settlePublishedPrefix('pty-1', 2)
expect(settled).toEqual([
expect.objectContaining({
spanId: 'span-0',
projectionSemanticsId: id,
sourceStartSu: 0,
sourceEndSu: 4,
displayStart: 0,
displayEnd: 4
})
])
})
it('preserves scanner and display facts after transfer and delivery-token replacement', () => {
const transferred: unknown[] = []
const ledger = new SshPtyLegacyProjectionLedger({
onTransferred: (span) => transferred.push(span)
})
const partial = ledger.commit(
reserve(ledger, {
data: '\x1b[?20',
rawLength: 5,
sequenceEnd: 5,
source: source(0, 5)
})
)
ledger.transfer([partial.identity.projectionSemanticsId], 'renderer-reload')
const continuation = reserve(ledger, {
data: '31h',
rawLength: 3,
sequenceEnd: 8,
source: source(5, 8, 'token-2')
})
expect(continuation.semantics.identity.displayStart).toBe(5)
expect(continuation.semantics.beforeScanner.tail).toBe('\x1b[?20')
expect(continuation.semantics.decision).toBe('subscribed')
expect(transferred).toEqual([
expect.objectContaining({
deliveryToken: 'token-1',
sourceStartSu: 0,
sourceEndSu: 5
})
])
})
it('leaves a projection intact when its terminal transition cannot commit', () => {
const ledger = new SshPtyLegacyProjectionLedger({
onTransferred: () => {
throw new Error('replacement unavailable')
}
})
const projection = ledger.commit(reserve(ledger, { source: source(0, 3) }))
expect(() =>
ledger.transfer([projection.identity.projectionSemanticsId], 'renderer-reload')
).toThrow('replacement unavailable')
expect(ledger.getDebugSnapshot()).toMatchObject({
transferred: 0,
records: 1
})
})
})
+318
View File
@@ -0,0 +1,318 @@
import { scanMode2031ReplyDecision } from '../../shared/terminal-color-scheme-protocol'
import {
closeProjectionPty,
getOrCreateProjectionCursor,
projectionDebugSnapshot,
projectionError,
reclaimProjectionRecord,
resetProjectionCursorForGap,
requireProjectionRecord,
rollbackCommittedProjectionRecord,
scannerSnapshot,
type DesktopProjectionSpan,
type LegacySshProjectionDebugSnapshot,
type LegacySshProjectionReservation,
type LegacySshProjectionSemantics,
type ProjectionRecord,
type PtyProjectionCursor
} from './ssh-pty-legacy-projection-record'
import {
projectionHasOpen,
resolveProjectionTerminality,
SshPtyProjectionTerminality,
unpublishedProjectionIds
} from './ssh-pty-projection-terminality'
import {
hasUnpublishedLegacyProjection,
publishLegacyProjectionPrefix,
settlePublishedLegacyProjectionPrefix
} from './ssh-pty-legacy-projection-publication'
export type {
DesktopProjectionSpan,
LegacySshProjectionIdentity,
LegacySshProjectionReservation,
LegacySshProjectionSemantics
} from './ssh-pty-legacy-projection-record'
export type SshPtyLegacyProjectionLedgerOptions = {
onSettled?: (span: DesktopProjectionSpan, reason: string) => void
onTransferred?: (span: DesktopProjectionSpan, reason: string) => void
}
export class SshPtyLegacyProjectionLedger {
private nextId = 1
private readonly records = new Map<string, ProjectionRecord>()
private readonly cursorByPty = new Map<string, PtyProjectionCursor>()
private readonly idsByPty = new Map<string, string[]>()
private readonly terminality = new SshPtyProjectionTerminality()
private settledCount = 0
private transferredCount = 0
private rolledBackCount = 0
constructor(private readonly options: SshPtyLegacyProjectionLedgerOptions = {}) {}
reserve(args: {
ptyId: string
providerGeneration: number
ptyIncarnation: string
data: string
sequenceEnd: number
rawLength: number
transformed: boolean
source?: Readonly<{
relayPtyId?: string
spanId: string
clientGeneration: number
ownerGeneration: number
deliveryToken: string
sourceStartSu: number
sourceEndSu: number
}>
}): LegacySshProjectionReservation {
const cursor = getOrCreateProjectionCursor(this.cursorByPty, args, (generation) =>
this.transferGeneration(generation, 'provider-generation-replaced')
)
if (
cursor.providerGeneration !== args.providerGeneration ||
cursor.ptyIncarnation !== args.ptyIncarnation
) {
throw projectionError('ssh_projection_stale_generation')
}
const scan = scanMode2031ReplyDecision(cursor.scanner, args.data)
const projectionSemanticsId = `ssh-projection:${args.providerGeneration}:${this.nextId++}`
const identity = Object.freeze({
projectionSemanticsId,
ptyId: args.ptyId,
providerGeneration: args.providerGeneration,
ptyIncarnation: args.ptyIncarnation,
displayStart: cursor.displayEnd,
displayEnd: cursor.displayEnd + args.data.length,
sequenceEnd: args.sequenceEnd,
rawLength: args.rawLength,
transformed: args.transformed
})
const semantics = Object.freeze({
identity,
...(args.source
? {
desktopSpan: Object.freeze({
...args.source,
id: args.source.relayPtyId ?? args.ptyId,
projectionSemanticsId,
providerGeneration: args.providerGeneration,
ptyIncarnation: args.ptyIncarnation,
displayStart: identity.displayStart,
displayEnd: identity.displayEnd,
splittable: !args.transformed,
transform: Object.freeze({
transformed: args.transformed,
rawLengthSu: args.rawLength,
scalarSafe: !args.transformed
})
})
}
: {}),
beforeScanner: scannerSnapshot(cursor.scanner),
afterScanner: scannerSnapshot(scan.state),
decision: scan.decision
})
this.records.set(projectionSemanticsId, {
semantics,
state: 'reserved',
publishedDisplay: 0,
publishedAccounting: 0,
settledAccounting: 0
})
return Object.freeze({ semantics })
}
commit(reservation: LegacySshProjectionReservation): LegacySshProjectionSemantics {
const record = requireProjectionRecord(
this.records,
reservation.semantics.identity.projectionSemanticsId
)
if (record.state !== 'reserved') {
throw projectionError('ssh_projection_commit_invalid')
}
const { identity, afterScanner } = record.semantics
const cursor = this.cursorByPty.get(identity.ptyId)
if (
!cursor ||
cursor.providerGeneration !== identity.providerGeneration ||
cursor.ptyIncarnation !== identity.ptyIncarnation ||
cursor.displayEnd !== identity.displayStart
) {
this.records.delete(identity.projectionSemanticsId)
throw projectionError('ssh_projection_commit_stale')
}
cursor.displayEnd = identity.displayEnd
cursor.scanner = { ...afterScanner }
record.state = 'committed'
const ids = this.idsByPty.get(identity.ptyId) ?? []
ids.push(identity.projectionSemanticsId)
this.idsByPty.set(identity.ptyId, ids)
return record.semantics
}
rollback(reservation: LegacySshProjectionReservation): boolean {
const id = reservation.semantics.identity.projectionSemanticsId
const record = this.records.get(id)
if (!record || record.state !== 'reserved') {
return false
}
this.records.delete(id)
this.rolledBackCount++
return true
}
rollbackCommitted(reservation: LegacySshProjectionReservation): boolean {
const ptyId = rollbackCommittedProjectionRecord(
this.records,
this.idsByPty,
this.cursorByPty,
reservation
)
if (!ptyId) {
return false
}
this.rolledBackCount++
resolveProjectionTerminality(this.terminality, this.records, this.idsByPty, ptyId)
return true
}
publishPrefix(ids: readonly string[], displayChars: number, accountingChars: number): void {
publishLegacyProjectionPrefix(this.records, ids, displayChars, accountingChars)
}
settlePublishedPrefix(ptyId: string, accountingChars: number): number {
const result = settlePublishedLegacyProjectionPrefix(
this.records,
this.idsByPty,
ptyId,
accountingChars,
this.options.onSettled
)
this.settledCount += result.completed
resolveProjectionTerminality(this.terminality, this.records, this.idsByPty, ptyId)
return result.settled
}
transfer(ids: readonly string[], reason: string): number {
let transferred = 0
const touchedPtys = new Set<string>()
for (const id of ids.slice()) {
const record = this.records.get(id)
if (!record || record.state === 'reserved') {
continue
}
const ptyId = record.semantics.identity.ptyId
if (record.semantics.desktopSpan) {
this.options.onTransferred?.(record.semantics.desktopSpan, reason)
}
this.transferredCount++
reclaimProjectionRecord(this.records, this.idsByPty, id, ptyId)
touchedPtys.add(ptyId)
transferred++
}
for (const ptyId of touchedPtys) {
resolveProjectionTerminality(this.terminality, this.records, this.idsByPty, ptyId)
}
return transferred
}
whenPtyTerminal(
ptyId: string,
providerGeneration: number,
ptyIncarnation: string
): Promise<void> {
return this.terminality.whenTerminal(
ptyId,
providerGeneration,
ptyIncarnation,
projectionHasOpen(this.records, this.idsByPty, ptyId)
)
}
transferGeneration(providerGeneration: number, reason: string): number {
const ids: string[] = []
for (const [id, record] of this.records) {
if (record.semantics.identity.providerGeneration === providerGeneration) {
ids.push(id)
}
}
return this.transfer(ids, reason)
}
transferPty(ptyId: string, reason: string): number {
return this.transfer(this.idsByPty.get(ptyId) ?? [], reason)
}
transferUnpublishedPty(
ptyId: string,
providerGeneration: number,
ptyIncarnation: string,
reason: string
): number {
const ids = unpublishedProjectionIds(
this.records,
this.idsByPty.get(ptyId) ?? [],
providerGeneration,
ptyIncarnation
)
return this.transfer(ids, reason)
}
closePty(
ptyId: string,
providerGeneration: number,
ptyIncarnation: string,
reason: string
): void {
closeProjectionPty(this.cursorByPty, ptyId, providerGeneration, ptyIncarnation, () => {
this.transferPty(ptyId, reason)
this.idsByPty.delete(ptyId)
})
}
closeGeneration(providerGeneration: number, reason: string): void {
for (const [id, record] of this.records) {
if (record.semantics.identity.providerGeneration !== providerGeneration) {
continue
}
if (record.state === 'reserved') {
this.records.delete(id)
this.rolledBackCount++
} else {
this.transfer([id], reason)
}
}
for (const [ptyId, cursor] of this.cursorByPty) {
if (cursor.providerGeneration === providerGeneration) {
this.cursorByPty.delete(ptyId)
this.idsByPty.delete(ptyId)
}
}
this.terminality.closeGeneration(providerGeneration)
}
resetForGap(ptyId: string): void {
resetProjectionCursorForGap(this.cursorByPty, ptyId)
}
get(id: string): LegacySshProjectionSemantics | undefined {
return this.records.get(id)?.semantics
}
hasUnpublished(id: string): boolean {
return hasUnpublishedLegacyProjection(this.records, id)
}
getDebugSnapshot(): LegacySshProjectionDebugSnapshot {
return projectionDebugSnapshot(this.records, this.cursorByPty, {
settled: this.settledCount,
transferred: this.transferredCount,
rolledBack: this.rolledBackCount
})
}
}
@@ -0,0 +1,35 @@
export type SshPtyModelAdmissionKey = Readonly<{
ptyId: string
providerGeneration: number
}>
export type SshPtyModelAdmissionReceipt = Readonly<{
ptyId: string
providerGeneration: number
sequence: number
}>
export type SshPtyModelAdmissionDebugSnapshot = Readonly<{
sourceUnits: number
bytes: number
pressureFrames: number
pressureBytes: number
pausedPtys: number
migratingPtys: number
}>
export type SshPtyModelAdmissionOptions = {
perPtyHighSourceUnits?: number
perPtyHighBytes?: number
perPtyLowSourceUnits?: number
perPtyLowBytes?: number
globalHighSourceUnits?: number
globalHighBytes?: number
globalLowSourceUnits?: number
globalLowBytes?: number
pressureMaxFrames?: number
pressureMaxBytes?: number
pauseProvider?: (key: SshPtyModelAdmissionKey) => boolean
resumeProvider?: (key: SshPtyModelAdmissionKey) => void
closeProvider?: (providerGeneration: number, reason: string) => void
}

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