mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into better-linear-guide
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Terminal IME E2E
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/terminal-ime-e2e.yml'
|
||||
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
|
||||
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
|
||||
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
|
||||
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
|
||||
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
|
||||
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
|
||||
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
|
||||
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
- 'tests/e2e/terminal-ime-*.ts'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 9 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux-x11:
|
||||
name: Linux X11 terminal IME
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install native build and IME tools
|
||||
run: >-
|
||||
sudo apt-get update &&
|
||||
sudo apt-get install -y
|
||||
build-essential
|
||||
dbus-x11
|
||||
dconf-gsettings-backend
|
||||
ibus
|
||||
ibus-hangul
|
||||
libglib2.0-bin
|
||||
python3
|
||||
xdotool
|
||||
xfwm4
|
||||
xvfb
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use external node-gyp to avoid pnpm bundled copy
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for E2E
|
||||
run: pnpm exec electron-vite build --mode e2e
|
||||
|
||||
- name: Run deterministic terminal IME boundary tests
|
||||
run: >-
|
||||
xvfb-run --auto-servernum
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
|
||||
pnpm run test:e2e --
|
||||
tests/e2e/terminal-ime-exact-byte.spec.ts
|
||||
--workers=1
|
||||
|
||||
- name: Run native IBus Hangul exact-byte tests
|
||||
env:
|
||||
SKIP_BUILD: '1'
|
||||
run: pnpm run test:e2e:terminal-ime-native
|
||||
|
||||
- name: Upload terminal IME evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: terminal-ime-evidence
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -52,11 +52,27 @@ Sync catalog keys after adding or removing `translate(...)` calls:
|
||||
pnpm run sync:localization-catalog
|
||||
```
|
||||
|
||||
The sync command adds missing `en.json` entries from each call's string fallback,
|
||||
copies untranslated English placeholders into other locale catalogs to keep
|
||||
parity, removes locale entries whose English key was deleted, and repairs
|
||||
placeholder mismatches. Run the machine-translation bootstrap commands only when
|
||||
refreshing real translations, not for ordinary UI copy changes.
|
||||
The sync command adds missing `en.json` entries from each call's string fallback.
|
||||
It never edits target catalogs: missing values remain absent and use the existing
|
||||
runtime English fallback. Existing placeholder mismatches fail validation until
|
||||
a localization PR fixes or retires the target entry.
|
||||
|
||||
Run maintained source extraction without committing a second English catalog:
|
||||
|
||||
```sh
|
||||
pnpm run verify:localization-extraction
|
||||
```
|
||||
|
||||
Extraction fails when a statically extracted key is absent from `en.json` or an
|
||||
inline default has incompatible placeholders. Existing unreferenced English keys
|
||||
and wording-only fallback drift are reported as migration debt; the permanent
|
||||
bilingual translation source will reconcile them without a large disposition
|
||||
database. Reviewed and stale translation state is likewise deferred to that
|
||||
source rather than inferred permanently from Git history.
|
||||
|
||||
The legacy free-endpoint bootstrap and whole-catalog repair scripts intentionally
|
||||
have no package-script entry points. Ordinary product and localization work must
|
||||
not invoke tools that can overwrite an entire target catalog.
|
||||
|
||||
The coverage gate compares current candidates against
|
||||
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
|
||||
@@ -97,8 +113,8 @@ Recommended migration order:
|
||||
The final gate should combine three checks:
|
||||
|
||||
1. Scanner coverage: no unclassified localizable candidates remain.
|
||||
2. Catalog coverage: every supported locale has the same keys as English, with
|
||||
matching interpolation variables.
|
||||
2. Catalog correctness: existing translations have matching interpolation
|
||||
variables; missing target entries are reported rather than rejected.
|
||||
3. Runtime coverage: pseudo-localization and real locale smoke tests show no
|
||||
obvious English leftovers or layout clipping in core screens.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1200
-889
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()
|
||||
})
|
||||
})
|
||||
@@ -51,30 +51,39 @@ describe('orchestration skill guidance', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('teaches the hard cutover without reviving a legacy executor', () => {
|
||||
it('teaches attested adoption without reviving the retired scheduler', () => {
|
||||
const skill = readSkill()
|
||||
const migration = getSection(skill, 'Contract Migration')
|
||||
|
||||
expect(migration).toContain('hard cutover')
|
||||
expect(migration).toContain('effectsApplied')
|
||||
expect(migration).toContain('skills get orchestration --full')
|
||||
expect(migration).toContain('Do not retry the rejected command unchanged')
|
||||
expect(migration).toContain('no longer supervised')
|
||||
expect(migration).toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('Read-only inspection never consumes legacy mail')
|
||||
expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain')
|
||||
expect(migration).toContain('does not cancel the prior assignment')
|
||||
expect(migration).toContain('invalidate its worktree')
|
||||
expect(migration).toContain('discard filesystem changes')
|
||||
expect(migration).toContain('leave it as the only editor in that worktree')
|
||||
expect(migration).toContain('observe it manually, read-only')
|
||||
expect(migration).toContain('until it reaches a stable handoff point')
|
||||
expect(migration).toContain('visible activity is a reason to keep observing')
|
||||
expect(migration).toContain(
|
||||
'Never launch a replacement editor in the same worktree while the legacy worker may still write there.'
|
||||
'adopts a live pre-update orchestration assignment into an ordinary Run'
|
||||
)
|
||||
expect(migration).toContain('if remaining work needs new lifecycle supervision')
|
||||
expect(migration).not.toContain('restart the work using Run -> Task -> `worker-start`')
|
||||
expect(migration).toContain(
|
||||
'preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch'
|
||||
)
|
||||
expect(migration).toContain('never restarts or replaces the worker')
|
||||
expect(migration).toContain('The retired scheduler is not revived')
|
||||
expect(migration).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(migration).toContain('[LEGACY READ-ONLY]')
|
||||
expect(migration).toContain(
|
||||
'Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.'
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal.'
|
||||
)
|
||||
expect(migration).not.toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('run_legacy_local is an empty audit tombstone')
|
||||
expect(migration).toContain('Recovered orchestration work from a contract update')
|
||||
expect(migration).toContain('run-show --id <adopted_run_id>')
|
||||
expect(migration).toContain('task-list --run <adopted_run_id>')
|
||||
expect(migration).toContain('Legacy inspection remains available without consuming mail')
|
||||
expect(migration).toContain('run-use --id <adopted_run_id> --takeover-legacy')
|
||||
expect(migration).toContain('Takeover fences only the old coordinator')
|
||||
expect(migration).toContain('Live legacy workers keep their original Tasks, Dispatches')
|
||||
expect(migration).toContain(
|
||||
'keep the original worker as the only editor until it reaches a stable handoff point'
|
||||
)
|
||||
expect(migration).toContain('a conflict-free placement for any remaining work')
|
||||
})
|
||||
|
||||
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
|
||||
|
||||
@@ -37,13 +37,19 @@ describe('PR workflow parallelism', () => {
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
})
|
||||
|
||||
it('shards the general test suite across sixteen runners', () => {
|
||||
it('shards the general test suite across Node 24 and Node 26', () => {
|
||||
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
|
||||
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 16 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
|
||||
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
|
||||
const installStep = workflow.jobs.test.steps.find(
|
||||
(step) => step.uses === './.github/actions/install-node-dependencies'
|
||||
)
|
||||
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
|
||||
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
}
|
||||
@@ -95,9 +101,15 @@ describe('PR workflow parallelism', () => {
|
||||
const steps = dependencyAction.runs.steps
|
||||
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
|
||||
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
|
||||
const requestedNodeIndex = steps.findIndex((step) => step.name === 'Setup requested Node.js')
|
||||
|
||||
expect(pnpmIndex).toBeLessThan(nodeIndex)
|
||||
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
|
||||
expect(steps[nodeIndex].with.cache).toBe('pnpm')
|
||||
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
|
||||
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
|
||||
expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}')
|
||||
expect(steps[requestedNodeIndex].with.cache).toBe('pnpm')
|
||||
})
|
||||
|
||||
it('restores Electron downloads before preparing the package runtime', () => {
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const projectDir = path.resolve(import.meta.dirname, '../..')
|
||||
const scriptPath = import.meta.filename
|
||||
const insideSessionFlag = '--inside-session'
|
||||
const processStopTimeoutMs = 5_000
|
||||
const processKillTimeoutMs = 1_000
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function waitForExit(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
|
||||
})
|
||||
}
|
||||
|
||||
function processGroupMembers(processGroupId) {
|
||||
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
if (result.status !== 0) {
|
||||
return []
|
||||
}
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function stopOwnedProcessGroup(processGroupId) {
|
||||
let members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
console.error(
|
||||
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
|
||||
)
|
||||
try {
|
||||
process.kill(-processGroupId, 'SIGTERM')
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + processStopTimeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-processGroupId, 'SIGKILL')
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const killDeadline = Date.now() + processKillTimeoutMs
|
||||
do {
|
||||
members = processGroupMembers(processGroupId)
|
||||
if (members.length === 0) {
|
||||
return []
|
||||
}
|
||||
await delay(100)
|
||||
} while (Date.now() < killDeadline)
|
||||
return members
|
||||
}
|
||||
|
||||
function commandOutput(command, args) {
|
||||
const result = spawnSync(command, args, { encoding: 'utf8' })
|
||||
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
|
||||
}
|
||||
|
||||
function configureHangulEngine() {
|
||||
for (const [key, value] of [
|
||||
['initial-input-mode', 'hangul'],
|
||||
['hangul-keyboard', '2']
|
||||
]) {
|
||||
const result = spawnSync(
|
||||
'gsettings',
|
||||
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHangulEngine(ibusProcess) {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
if (ibusProcess.exitCode !== null) {
|
||||
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
|
||||
}
|
||||
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
|
||||
if (result.status === 0) {
|
||||
return
|
||||
}
|
||||
await delay(100)
|
||||
}
|
||||
throw new Error('Timed out while selecting the IBus Hangul engine')
|
||||
}
|
||||
|
||||
async function runInsideSession(evidenceDir) {
|
||||
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
|
||||
const ibusLogFd = openSync(ibusLogPath, 'w')
|
||||
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
|
||||
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
|
||||
const evidence = {
|
||||
display: process.env.DISPLAY ?? null,
|
||||
ibusDaemonPid: null,
|
||||
ibusGroupBeforeCleanup: [],
|
||||
ibusGroupAfterCleanup: [],
|
||||
playwrightPid: null,
|
||||
windowManagerPid: null,
|
||||
windowManagerGroupAfterCleanup: []
|
||||
}
|
||||
let ibusProcess
|
||||
let windowManagerProcess
|
||||
let testExitCode = 1
|
||||
|
||||
try {
|
||||
configureHangulEngine()
|
||||
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
|
||||
detached: true,
|
||||
env: process.env,
|
||||
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
|
||||
})
|
||||
if (!windowManagerProcess.pid) {
|
||||
throw new Error('xfwm4 did not return a PID')
|
||||
}
|
||||
evidence.windowManagerPid = windowManagerProcess.pid
|
||||
console.error(`[terminal-ime] started xfwm4 PID ${windowManagerProcess.pid}`)
|
||||
|
||||
ibusProcess = spawn(
|
||||
'ibus-daemon',
|
||||
['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'],
|
||||
{
|
||||
detached: true,
|
||||
env: process.env,
|
||||
stdio: ['ignore', ibusLogFd, ibusLogFd]
|
||||
}
|
||||
)
|
||||
if (!ibusProcess.pid) {
|
||||
throw new Error('ibus-daemon did not return a PID')
|
||||
}
|
||||
evidence.ibusDaemonPid = ibusProcess.pid
|
||||
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
|
||||
await waitForHangulEngine(ibusProcess)
|
||||
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
|
||||
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
|
||||
console.error(
|
||||
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
|
||||
'get',
|
||||
'org.freedesktop.ibus.engine.hangul',
|
||||
'initial-input-mode'
|
||||
])}`
|
||||
)
|
||||
console.error(
|
||||
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
|
||||
'get',
|
||||
'org.freedesktop.ibus.engine.hangul',
|
||||
'hangul-keyboard'
|
||||
])}`
|
||||
)
|
||||
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
|
||||
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
|
||||
|
||||
const testProcess = spawn(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
[
|
||||
'run',
|
||||
'test:e2e:headful',
|
||||
'--workers=1',
|
||||
'--',
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
|
||||
],
|
||||
{
|
||||
cwd: projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_E2E_FORWARD_APP_LOGS: '1',
|
||||
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
if (!testProcess.pid) {
|
||||
throw new Error('Playwright did not return a PID')
|
||||
}
|
||||
evidence.playwrightPid = testProcess.pid
|
||||
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
|
||||
testExitCode = await waitForExit(testProcess)
|
||||
} finally {
|
||||
if (ibusProcess?.pid) {
|
||||
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
|
||||
evidence.ibusGroupAfterCleanup = await stopOwnedProcessGroup(ibusProcess.pid)
|
||||
}
|
||||
if (windowManagerProcess?.pid) {
|
||||
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
|
||||
windowManagerProcess.pid
|
||||
)
|
||||
}
|
||||
closeSync(ibusLogFd)
|
||||
closeSync(windowManagerLogFd)
|
||||
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
|
||||
copyFileSync(
|
||||
ibusLogPath,
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
|
||||
)
|
||||
copyFileSync(
|
||||
windowManagerLogPath,
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
|
||||
`${JSON.stringify(evidence, null, 2)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
if (evidence.ibusGroupAfterCleanup.length > 0) {
|
||||
throw new Error(
|
||||
`Owned IBus processes survived cleanup: ${evidence.ibusGroupAfterCleanup.join('; ')}`
|
||||
)
|
||||
}
|
||||
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
|
||||
throw new Error(
|
||||
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
|
||||
)
|
||||
}
|
||||
return testExitCode
|
||||
}
|
||||
|
||||
async function runOuter() {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
|
||||
}
|
||||
|
||||
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
|
||||
const runtimeDir = path.join(evidenceDir, 'runtime')
|
||||
mkdirSync(runtimeDir, { mode: 0o700 })
|
||||
mkdirSync(path.join(evidenceDir, 'config'))
|
||||
mkdirSync(path.join(evidenceDir, 'cache'))
|
||||
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
|
||||
|
||||
const sessionProcess = spawn(
|
||||
'xvfb-run',
|
||||
[
|
||||
'--auto-servernum',
|
||||
'dbus-run-session',
|
||||
'--',
|
||||
process.execPath,
|
||||
scriptPath,
|
||||
insideSessionFlag,
|
||||
evidenceDir
|
||||
],
|
||||
{
|
||||
cwd: projectDir,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
GTK_IM_MODULE: 'ibus',
|
||||
IBUS_ENABLE_SYNC_MODE: '1',
|
||||
LANG: process.env.LANG || 'C.UTF-8',
|
||||
QT_IM_MODULE: 'ibus',
|
||||
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
|
||||
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
|
||||
XDG_RUNTIME_DIR: runtimeDir,
|
||||
XMODIFIERS: '@im=ibus'
|
||||
},
|
||||
stdio: 'inherit'
|
||||
}
|
||||
)
|
||||
if (!sessionProcess.pid) {
|
||||
throw new Error('xvfb-run did not return a PID')
|
||||
}
|
||||
console.error(`[terminal-ime] started isolated X11 session PID ${sessionProcess.pid}`)
|
||||
const exitCode = await waitForExit(sessionProcess)
|
||||
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
|
||||
if (remaining.length > 0) {
|
||||
throw new Error(`Owned X11 session processes survived cleanup: ${remaining.join('; ')}`)
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
|
||||
const insideSession = process.argv[2] === insideSessionFlag
|
||||
try {
|
||||
if (insideSession && !process.argv[3]) {
|
||||
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
|
||||
}
|
||||
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
|
||||
} catch (error) {
|
||||
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
describe('terminal IME e2e workflow', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
|
||||
)
|
||||
|
||||
it('runs for xterm patch and terminal IME regression changes', () => {
|
||||
expect(workflow.on.pull_request.paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
|
||||
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
|
||||
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
|
||||
'src/renderer/src/components/terminal-pane/terminal-ime-*',
|
||||
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
|
||||
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
|
||||
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
|
||||
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
|
||||
'tests/e2e/terminal-ime-*.ts'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('installs native IBus Hangul and X11 input tools', () => {
|
||||
const runs = workflow.jobs['linux-x11'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const installRun = runs.find((run) => run.includes('apt-get install'))
|
||||
|
||||
expect(installRun).toBeDefined()
|
||||
expect(installRun).toContain('ibus-hangul')
|
||||
expect(installRun).toContain('xdotool')
|
||||
expect(installRun).toContain('xfwm4')
|
||||
expect(installRun).toContain('xvfb')
|
||||
expect(installRun).toContain('dbus-x11')
|
||||
expect(installRun).toContain('dconf-gsettings-backend')
|
||||
expect(installRun).toContain('libglib2.0-bin')
|
||||
})
|
||||
|
||||
it('runs deterministic boundaries before the real IBus suite', () => {
|
||||
const runs = workflow.jobs['linux-x11'].steps
|
||||
.map((step) => step.run)
|
||||
.filter((run) => typeof run === 'string')
|
||||
const deterministicIndex = runs.findIndex((run) =>
|
||||
run.includes('terminal-ime-exact-byte.spec.ts')
|
||||
)
|
||||
const nativeIndex = runs.findIndex((run) => run.includes('test:e2e:terminal-ime-native'))
|
||||
|
||||
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
|
||||
})
|
||||
|
||||
it('keeps IBus lifecycle scoped to owned processes', () => {
|
||||
const runner = readFileSync(
|
||||
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(runner).toContain(
|
||||
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
|
||||
)
|
||||
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
|
||||
expect(runner).toContain("['initial-input-mode', 'hangul']")
|
||||
expect(runner).toContain("['hangul-keyboard', '2']")
|
||||
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
|
||||
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
|
||||
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
|
||||
expect(runner).toMatch(
|
||||
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
|
||||
)
|
||||
expect(runner).not.toContain("'--replace'")
|
||||
expect(runner).not.toContain('killall')
|
||||
expect(runner).not.toContain('pkill')
|
||||
})
|
||||
|
||||
it('bounds blocking native input commands', () => {
|
||||
const nativeSpec = readFileSync(
|
||||
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,9 @@
|
||||
"../src/main/agent-hooks/hooks-json-read.ts",
|
||||
"../src/main/agent-hooks/installer-utils.ts",
|
||||
"../src/main/agent-hooks/installer-utils-remote.ts",
|
||||
"../src/main/agent-hooks/local-agent-cli-presence.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
@@ -68,6 +70,7 @@
|
||||
"../src/main/kimi/kimi-hook-config-toml.ts",
|
||||
"../src/main/openclaude/hook-service.ts",
|
||||
"../src/main/rolling-file-backup.ts",
|
||||
"../src/main/startup/hydrate-shell-path.ts",
|
||||
"../src/main/runtime/runtime-metadata.ts",
|
||||
"../src/main/win32-utils.ts"
|
||||
],
|
||||
|
||||
@@ -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
@@ -1,213 +0,0 @@
|
||||
# STA-2694 — garbled terminal after switching away from an AI workspace
|
||||
|
||||
Handoff snapshot: 2026-07-27, branch
|
||||
`neil/sta-2694-fix-ui-rendering-artifacts-when-switching-away-from-ai`.
|
||||
|
||||
## The report
|
||||
|
||||
> When I use an AI tool—such as OpenCode—and switch away to the desktop or
|
||||
> another task, the page appears garbled or distorted upon returning; I have to
|
||||
> resize the window to restore the display.
|
||||
|
||||
Reporter hints: related to terminal parking / workspace switching; also happens
|
||||
with Claude Code and probably grok.
|
||||
|
||||
## Status
|
||||
|
||||
**One defect, root-caused and fixed, with a mechanism-level test that fails
|
||||
without the fix.** The garble window is *unbounded*, not the ~1s I originally
|
||||
claimed — see [the correction](#correction-the-window-is-unbounded).
|
||||
|
||||
A second hypothesis was implemented, then **refuted by measurement and
|
||||
reverted**. That refutation is the other half of the value here: it rules out a
|
||||
whole class of "just force a fuller repaint" fixes, and it is pinned by a test so
|
||||
it cannot be reintroduced.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Fix | `ed1eaf55f1` — release an abandoned synchronized-output frame on reveal |
|
||||
| Refuted + reverted | `0f7ec4458d`, reverted in `8d5eacecb4` |
|
||||
| Oracle + scope correction | `3ccffc17ec` |
|
||||
| Unit tests | 8 passing across 2 files |
|
||||
| e2e | 5 draw-command oracle + 10 headless convergence + 1 headful |
|
||||
| Teeth-verified | Yes — removing the fix fails the stranded-latch test |
|
||||
|
||||
## The defect
|
||||
|
||||
Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint in
|
||||
DEC 2026 synchronized output — `\x1b[?2026h … \x1b[?2026l` — many times a second.
|
||||
Hide a pane mid-bracket (a worktree switch, a cold park, or an OS-level occlusion
|
||||
lands there routinely) and xterm keeps
|
||||
`decPrivateModes.synchronizedOutput` latched `true`.
|
||||
|
||||
`RenderService.refreshRows` checks that latch **before** rendering
|
||||
(`RenderService.ts:162`), so while it holds, every repaint Orca owns is a no-op:
|
||||
the forced render-pause repaint, the plain `refresh()` fallback, and the shared
|
||||
glyph-atlas rebuild all render zero rows against a perfectly correct buffer.
|
||||
|
||||
The fix (`terminal-synchronized-output-release.ts`) clears the latch and flushes
|
||||
the handler's buffered row range at both reveal repaint entry points, before the
|
||||
repaint. Both reveal paths reach it:
|
||||
|
||||
- `schedulePaneRevealPresent` (plain refocus / desktop return)
|
||||
- `resetWebglTextureAtlas` (worktree switch, cold park, tab reveal — also reached
|
||||
from `resetAndRefreshAllTerminalWebglAtlases` via the light tab-resume path)
|
||||
|
||||
### Correction: the window is unbounded
|
||||
|
||||
`ed1eaf55f1`'s commit message says this "closes a bounded window rather than the
|
||||
whole STA-2694 report", because xterm arms a 1s watchdog that clears the latch.
|
||||
**That was wrong.** The watchdog is armed only inside
|
||||
`SynchronizedOutputHandler.bufferRows`, and `refreshRows` returns at its
|
||||
`_isPaused` check *first*:
|
||||
|
||||
```ts
|
||||
public refreshRows(start, end, sync = false, isRedrawOnly = false): void {
|
||||
if (this._isPaused) { this._needsFullRefresh = true; return } // ← occluded pane stops here
|
||||
if (this._coreService.decPrivateModes.synchronizedOutput) {
|
||||
this._syncOutputHandler.bufferRows(start, end) // ← only place the watchdog arms
|
||||
return
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
While a pane is occluded its `IntersectionObserver` sets `_isPaused`, so nothing
|
||||
ever reaches `bufferRows` and **no timer is ever pending**. A pane hidden
|
||||
mid-frame holds the latch with no watchdog behind it, indefinitely — which is the
|
||||
indefinite garble the report describes. `terminal-reveal-draw-command-probe.spec.ts`
|
||||
asserts exactly this: latch set, `_timeout === undefined`, and a repaint drawing
|
||||
0 instances.
|
||||
|
||||
This also explains the specific workaround: resizing the window makes the TUI
|
||||
repaint via SIGWINCH, and that repaint's closing `?2026l` clears the latch.
|
||||
|
||||
## The refuted hypothesis (do not reimplement)
|
||||
|
||||
The idea: xterm's renderers are diff-based, so a reveal `refresh()` skips cells
|
||||
whose model entry still matches the buffer, leaving an occluded canvas showing
|
||||
pre-hide pixels until a resize rebuilds the model. It is a *plausible* reading —
|
||||
`WebglRenderer._updateModel` really does early-continue per unchanged cell.
|
||||
|
||||
It is wrong, measured on a live pane:
|
||||
|
||||
| Refresh | `updateCell` calls | instances drawn |
|
||||
|---|---|---|
|
||||
| diff-skipped (model matches buffer) | 0 | 562 |
|
||||
| model cleared first | 561 | 562 |
|
||||
|
||||
`GlyphRenderer.render` copies vertices for **every** row up to `lineLengths[y]`
|
||||
into the active buffer and issues **one** full-viewport `drawElementsInstanced`.
|
||||
The diff only decides how much *vertex data* is rewritten, never how much is
|
||||
*drawn*. The DOM renderer likewise `replaceChildren()`s every row
|
||||
unconditionally. So clearing the model cannot change what reaches the screen.
|
||||
|
||||
Worse, it has a cost: `_clearModel(true)` zeroes every glyph vertex, and
|
||||
`RenderService.clear()` fires no repaint of its own — so any paint landing between
|
||||
the clear and the repopulating refresh draws an **empty** viewport (0 instances,
|
||||
also asserted in the spec). On a reveal that refresh is debounced through a RAF
|
||||
and can itself be swallowed, which would turn a merely-stale pane into a blank
|
||||
one. Reverted in `8d5eacecb4`.
|
||||
|
||||
The first test in the oracle spec pins the refutation so this cannot come back as
|
||||
a "fix".
|
||||
|
||||
## Why pixel oracles failed, and what replaced them
|
||||
|
||||
The field defect is "the buffer is correct but the compositor shows pre-hide
|
||||
pixels". Two pixel oracles were built and **both were proven blind** by injecting
|
||||
that exact defect (freeze `RenderService.refreshRows`, then write new content):
|
||||
|
||||
1. **Canvas-vs-buffer ink sampling.** `drawImage` on a
|
||||
non-`preserveDrawingBuffer` WebGL canvas hands back a *re-rendered* copy — it
|
||||
reported 0 missing cells against 5263 cells of text the canvas had never
|
||||
drawn.
|
||||
2. **Screenshot vs. a forced repaint.** Playwright's screenshot drives a fresh
|
||||
compositor frame, which *heals* the stale paint before capture; and the
|
||||
"repair" ran the same code the reveal already ran, so a shared defect cancels
|
||||
out.
|
||||
|
||||
An earlier resize-referenced oracle was also unsound: resizing shifts alt-screen
|
||||
rows, so its 4.4% pixel diff measured legitimate reflow.
|
||||
|
||||
**Pixels are the wrong layer** — anything that reads them can trigger the repaint
|
||||
that hides the bug. `tests/e2e/terminal-reveal-draw-command-probe.spec.ts`
|
||||
instead wraps `GlyphRenderer.updateCell` and `gl.drawElementsInstanced` on the
|
||||
live pane and counts draw commands. A draw command cannot be healed after the
|
||||
fact, so "did the reveal repaint?" is directly observable — and falsifiable,
|
||||
which is how the second hypothesis got refuted.
|
||||
|
||||
Use that spec for **paint** questions;
|
||||
`terminal-opencode-altscreen-reveal-artifacts.spec.ts` for **buffer, geometry and
|
||||
PTY-size convergence** (worktree switch, cold park, idle agent, headful desktop
|
||||
hide). The idle-agent case matters: every earlier test kept the TUI streaming
|
||||
across the reveal, so live frames repainted whatever the reveal got wrong and the
|
||||
defect healed itself before any assertion ran.
|
||||
|
||||
## Leads closed by measurement
|
||||
|
||||
Both were live suspects in the previous handoff; both are now ruled out.
|
||||
|
||||
- **Dimension staleness.** Every dimension-rebuilding path in `WebglRenderer` is
|
||||
behind an unchanged/zero-sized early exit that a hidden pane trips, and
|
||||
`performSafeFit` early-returns at unchanged geometry — so nothing on the reveal
|
||||
path re-runs `handleResize()`. Measured across a real `window.hide()` /
|
||||
`show()`: canvas backing store `1272×1104` matched the renderer's computed
|
||||
dimensions exactly, char metrics stayed `8.65×16`, `_isAttached` stayed true,
|
||||
the screen element stayed connected. No staleness.
|
||||
- **Lagging atlas page bindings.** Right after a reveal the bound texture version
|
||||
*does* lag the atlas page (e.g. 1297 vs 1366) — but bindings are lazy and
|
||||
`GlyphRenderer.render` rebinds any page whose version moved, so one repaint
|
||||
brings them level (1366/1366). Normal, not a defect. Judge bindings only
|
||||
*after* a draw.
|
||||
|
||||
## Residual risk
|
||||
|
||||
The oracle proves the repaint is *issued*; it does not photograph the user's
|
||||
screen. If a report survives this fix, the remaining suspects are below the draw
|
||||
call — GPU-process/compositor-level content loss — where the in-app sentinel on
|
||||
real hardware is the tool:
|
||||
|
||||
```js
|
||||
localStorage.setItem('orca:render-desync-sentinel', '1') // then reload
|
||||
```
|
||||
|
||||
Reproduce, then **⌘-click** (Ctrl-click off Mac) in the garbled pane. That starts
|
||||
a 10s burst at 250ms intervals; a trip needs the same cells missing across 2
|
||||
consecutive samples, ≥200 text cells, ≥8% missing. It writes `corrupt.png` +
|
||||
`corrupt.json` to `<userData>/terminal-render-desync-evidence/<captureId>/`, then
|
||||
runs atlas recovery and captures the healed frame. Armed at import when the flag
|
||||
is set (`terminal-freeze-breadcrumbs.ts:45`).
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
npx vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/
|
||||
npx tsc --noEmit -p config/tsconfig.tc.web.json
|
||||
npx playwright test tests/e2e/terminal-reveal-draw-command-probe.spec.ts \
|
||||
tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \
|
||||
tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts \
|
||||
--config tests/playwright.config.ts --project=electron-headless --workers=1
|
||||
SKIP_BUILD=1 npx playwright test tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \
|
||||
--config tests/playwright.config.ts --project=electron-headful --workers=1
|
||||
```
|
||||
|
||||
To confirm the fix still has teeth, delete the
|
||||
`releaseAbandonedSynchronizedOutput(pane.terminal)` line from
|
||||
`schedulePaneRevealPresent` and re-run the oracle: the stranded-latch test fails
|
||||
with "the reveal repaint did not release the stranded latch, so the pane stays
|
||||
frozen".
|
||||
|
||||
## Files
|
||||
|
||||
Production:
|
||||
|
||||
- `src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts` (the fix)
|
||||
- `src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts` (`schedulePaneRevealPresent`)
|
||||
- `src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts` (`resetWebglTextureAtlas`)
|
||||
|
||||
Tests:
|
||||
|
||||
- `terminal-synchronized-output-release.test.ts`, `reveal-repaint-synchronized-output.test.ts`
|
||||
- `tests/e2e/terminal-reveal-draw-command-probe.spec.ts` (the oracle)
|
||||
- `tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts`,
|
||||
`tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs`
|
||||
@@ -0,0 +1,576 @@
|
||||
# Preserve active orchestration work across contract updates
|
||||
|
||||
Status: implementation validation
|
||||
|
||||
Date: 2026-07-28
|
||||
|
||||
## Decision
|
||||
|
||||
Do not block updates, restart active workers, or revive the retired scheduler.
|
||||
|
||||
On first launch after the contract update, atomically adopt the durable
|
||||
pre-Run graph into one ordinary lightweight Run. Preserve every Task,
|
||||
Dispatch, message, gate, terminal, PTY, process, worktree, and prompt
|
||||
identifier. Mark only the pre-update Dispatch attempts as legacy-contract
|
||||
attempts and accept the exact command shapes already pasted into those
|
||||
workers through a narrow adapter.
|
||||
|
||||
This is state migration plus a per-Dispatch protocol adapter. It is not a
|
||||
second scheduler:
|
||||
|
||||
- the migrated Run owns the preserved graph and coordinator inbox;
|
||||
- legacy attempts keep their original protocol until they settle;
|
||||
- every new attempt uses the current protocol, even on a preserved Task; and
|
||||
- no placement, polling, concurrency, or deadline loop is resurrected.
|
||||
|
||||
## User-visible outcome
|
||||
|
||||
- The update installs immediately.
|
||||
- Existing coordinator and worker terminals keep the same process, PTY,
|
||||
worktree, prompt, Task ID, Dispatch ID, and history.
|
||||
- Orca does not inject text, focus a tab, or start a replacement editor.
|
||||
- The legacy graph appears in an automatically adopted, normal Run instead of
|
||||
being stranded in `run_legacy_local`.
|
||||
- An original live worker with retained launch proof can keep using the
|
||||
`heartbeat`, `worker_done`, `escalation`, `ask`, and `check` commands in its
|
||||
existing prompt.
|
||||
- An original live coordinator can keep listing and settling preserved Tasks,
|
||||
dispatch remaining work with current commands, check direct legacy mail, and
|
||||
use the pre-cutover `reply` CLI guidance.
|
||||
- Unprovable, stale, ambiguous, or already-settled rows remain inspectable and
|
||||
unmistakably read-only. They never advertise Reply or Ack actions.
|
||||
- A pre-update WSL process whose older launcher did not propagate the hidden
|
||||
token remains lifecycle read-only. Its assignment, process, terminal, and
|
||||
filesystem work remain valid and inspectable.
|
||||
|
||||
The retired `orchestration run`, `coordinator-start`, and `coordinator-stop`
|
||||
scheduler commands remain retired. Their in-memory loop cannot be reconstructed
|
||||
truthfully. The coordinator agent and all durable work remain accessible; it
|
||||
explicitly dispatches any ready work that was never assigned before the update.
|
||||
|
||||
## Evidence and constraints
|
||||
|
||||
- Schema v7 preserved old `messages`, `tasks`, `dispatch_contexts`, and
|
||||
`decision_gates` by assigning them to `run_legacy_local`.
|
||||
- The exact prompt at `8b154d686` used old `send`, `ask`, and consuming
|
||||
`check` shapes. It omitted `worker_done --outcome`; a subject equal to
|
||||
`Failed` or beginning `Failed:` represented failure. Its escalation carried
|
||||
`taskId` but no `dispatchId`.
|
||||
- `reply` existed in the old CLI and message formatter guidance, not in that
|
||||
pinned worker prompt.
|
||||
- Restored `OrcaRuntime` PTY records currently set `launchToken: null`, and
|
||||
provider `listProcesses()` does not expose the launch token. Handles and
|
||||
pane IDs alone therefore cannot authorize compatibility.
|
||||
- Managed hooks source the owner-only endpoint file on every invocation, so an
|
||||
old process reaches the new hook server after restart. The hook cache retains
|
||||
the prior authenticated pane/launch-token association and distinguishes
|
||||
hydrated rows from events observed by the current runtime.
|
||||
- Older builds did not propagate `ORCA_AGENT_LAUNCH_TOKEN` through `WSLENV`, and
|
||||
a running process cannot acquire it retroactively.
|
||||
- The CLI transport mutation receipt is keyed by rotating runtime
|
||||
authentication. It cannot deduplicate a legacy mutation across restart.
|
||||
- Current invalid-capability lifecycle sends deliberately remain as auditable
|
||||
rejected messages. Compatibility must not silently change that behavior.
|
||||
- PR #11142 fixes background-tab materialization for newly dispatched workers.
|
||||
It does not recover a pre-fix worker whose PTY survived an update after its
|
||||
renderer tab binding was lost; workspace activation can otherwise mistake
|
||||
that worker for a sleeping provider session and launch `codex resume`.
|
||||
- The packaged Windows launcher keeps its Electron-as-Node child alive for the
|
||||
lifetime of a blocking CLI command. A seamless design cannot assume that a
|
||||
bounded RPC wait releases the installed executable.
|
||||
|
||||
## Non-negotiable invariants
|
||||
|
||||
1. Adoption never spawns, writes, signals, stops, focuses, or injects a
|
||||
terminal and never mutates the assigned filesystem. It may restore one
|
||||
persisted background tab/leaf binding for the exact already-live PTY.
|
||||
2. Every durable row and identifier survives adoption; only its owning Run and
|
||||
explicit protocol metadata change.
|
||||
3. Compatibility is write-once per Dispatch. Migration never classifies a
|
||||
current retry as legacy.
|
||||
4. A legacy mutation requires one uniquely resolved legacy attempt or
|
||||
coordinator principal plus current live proof of its original process tree.
|
||||
5. Caller-supplied handles, pane keys, Task IDs, Dispatch IDs, or remote
|
||||
attachments never create authority.
|
||||
6. Current Dispatch behavior remains unchanged, including auditable rejected
|
||||
lifecycle messages, capabilities, process incarnation, Run Delivery, and
|
||||
question semantics.
|
||||
7. Legacy direct mail and current Run Delivery are disjoint, even when their
|
||||
Dispatches share one migrated Task and Run.
|
||||
8. A B-era disconnect may replay accepted work; it may not lose mail or create
|
||||
a second question, reply, completion, or settlement. A cut after an A-era
|
||||
ask was already answered is explicitly ambiguous because A persisted no
|
||||
invocation identity; recovery replays its answer through legacy check before
|
||||
a same-text question is asked again.
|
||||
9. Different reply bodies for the same source and principal conflict instead
|
||||
of creating multiple replies.
|
||||
10. Folder workspaces, SSH, WSL, remote runtimes, macOS, Linux, and Windows
|
||||
follow the same proof rules. Missing proof degrades to inspection without
|
||||
invalidating the retained assignment or filesystem work.
|
||||
|
||||
## Storage and atomic adoption
|
||||
|
||||
Raise the schema version and add:
|
||||
|
||||
```text
|
||||
dispatch_contexts.contract_version INTEGER NOT NULL
|
||||
dispatch_contexts.launch_token_hash TEXT
|
||||
messages.delivery_contract TEXT NOT NULL
|
||||
CHECK(delivery_contract IN ('legacy_direct', 'current_delivery', 'audit_only'))
|
||||
legacy_adoptions(
|
||||
source_run_id PRIMARY KEY,
|
||||
adopted_run_id UNIQUE NOT NULL,
|
||||
scheduler_state_lost INTEGER NOT NULL,
|
||||
adopted_at
|
||||
)
|
||||
legacy_compatibility_principals(
|
||||
id PRIMARY KEY,
|
||||
run_id NOT NULL,
|
||||
dispatch_id,
|
||||
role CHECK(role IN ('worker', 'coordinator')),
|
||||
host_scope NOT NULL,
|
||||
terminal_handle NOT NULL,
|
||||
pane_key NOT NULL,
|
||||
launch_token_hash NOT NULL,
|
||||
process_incarnation,
|
||||
status CHECK(status IN ('committed', 'settled', 'revoked')),
|
||||
UNIQUE(role, run_id, dispatch_id)
|
||||
)
|
||||
legacy_operation_receipts(
|
||||
principal_id,
|
||||
operation_key,
|
||||
method,
|
||||
payload_hash,
|
||||
effect_id,
|
||||
response_json,
|
||||
completed_at,
|
||||
PRIMARY KEY(principal_id, operation_key)
|
||||
)
|
||||
legacy_mail_receipts(
|
||||
principal_id,
|
||||
message_id,
|
||||
acknowledged_at,
|
||||
PRIMARY KEY(principal_id, message_id)
|
||||
)
|
||||
```
|
||||
|
||||
The schema migration runs in one `BEGIN IMMEDIATE` transaction:
|
||||
|
||||
1. create a persisted random Run ID and `legacy_adoptions` marker if one does
|
||||
not already exist;
|
||||
2. create an ordinary `legacy = 0` Run with a recovery objective;
|
||||
3. move all `run_legacy_local` Tasks, Dispatches, decision gates, messages,
|
||||
and question threads to it without changing their IDs, sequence, status, or
|
||||
content;
|
||||
4. mark pre-existing direct mail `legacy_direct` and hard-cutover rejection
|
||||
rows `audit_only`;
|
||||
5. mark a Dispatch legacy only when it predates the contract column and lacks
|
||||
a current dispatch capability; and
|
||||
6. fence any impossible outstanding legacy Delivery, move retained Delivery
|
||||
history to the adopted Run, assert every Task/Dispatch/gate/message/question/
|
||||
Delivery Run ID agrees, and leave `run_legacy_local` as an empty read-only
|
||||
audit tombstone.
|
||||
|
||||
Every new Dispatch and message writes the current contract explicitly.
|
||||
Repeated startup, a partially initialized fixture, and WAL recovery reuse the
|
||||
persisted adoption marker. Reset and fresh-database paths cannot synthesize
|
||||
legacy authority.
|
||||
|
||||
## Durable principals and live authority
|
||||
|
||||
After the hook server and PTY inventory are ready, a one-shot adoption service
|
||||
may commit compatibility principals. It does not mutate terminal processes or
|
||||
input.
|
||||
|
||||
Before renderer hydration, legacy Dispatch evidence also fences automatic
|
||||
provider resume for the exact sleeping pane. Once the relevant local daemon or
|
||||
SSH relay has authoritative inventory, Orca intersects the durable handle,
|
||||
pane, PTY ID, PTY incarnation, workspace, and host owner. A unique live match
|
||||
is adopted into its original tab/leaf as inactive background state while the
|
||||
coordinator tab remains active. Missing, stale, reused, cross-host, or
|
||||
ambiguous evidence stays fenced and inspectable. Ordinary provider resume is
|
||||
unblocked only when authoritative inventory proves that exact PTY exited.
|
||||
|
||||
At hook-server startup, copy the validated hydrated status rows into an
|
||||
immutable main-process-only startup snapshot before the server listens or
|
||||
binds the new listener. Later live events update a separate current-runtime
|
||||
observation map and cannot overwrite the pre-update commitment source. A
|
||||
current event is preferred but is not required before the worker's first
|
||||
post-restart lifecycle command when controller inventory proves the exact
|
||||
original PTY and process incarnation are live.
|
||||
|
||||
For each candidate it resolves the union of:
|
||||
|
||||
- durable Dispatch assignee and pane;
|
||||
- equivalent-pane aliases;
|
||||
- remote attachment and host ownership;
|
||||
- coordinator candidates from active legacy coordinator rows, Task creator
|
||||
handles, Dispatch coordinator/parent provenance, and direct-message
|
||||
sender/recipient history;
|
||||
- the restored terminal inventory and process incarnation; and
|
||||
- the hydrated, previously authenticated hook pane/launch-token association.
|
||||
|
||||
The candidate commits only when that union identifies exactly one role and
|
||||
row. The orchestration database stores a SHA-256 token commitment, never the
|
||||
raw token. The existing owner-only hook status cache is an input to the
|
||||
one-time commitment, but a hydrated status is not enough to mutate.
|
||||
|
||||
Every compatibility call then requires:
|
||||
|
||||
- a hidden launch token and pane inherited by the calling process;
|
||||
- the immutable pre-update hook commitment, plus either a matching
|
||||
current-runtime hook event or exact current controller proof of the original
|
||||
PTY and process incarnation;
|
||||
- a live PTY with the committed handle, host scope, and process incarnation;
|
||||
- the committed principal and exact active legacy Dispatch or adopted Run; and
|
||||
- an operation-specific unique match.
|
||||
|
||||
The current hook event or exact controller inventory proves that the process
|
||||
survived the restart; equality with the pre-update commitment proves it is the
|
||||
same launch. If the hook cache was absent, the provider cannot report the
|
||||
proof, the token changed, or any candidate is ambiguous, the row remains
|
||||
read-only.
|
||||
|
||||
For new Dispatches, persist the launch-token hash at dispatch time so later
|
||||
contract updates do not depend on hook-cache recovery. Never return or log raw
|
||||
tokens. Add explicit RPC-envelope, structured-log, error, and CLI JSON
|
||||
redaction tests.
|
||||
|
||||
Host scope is runtime-owned:
|
||||
|
||||
- local: stable local execution-host identity;
|
||||
- WSL: stable native execution-host identity plus distro;
|
||||
- SSH: saved-host authority plus remote pane/process identity; and
|
||||
- federated runtime: authenticated peer fingerprint plus remote attachment.
|
||||
|
||||
`pickRemoteCliEnv`, SSH relay forwarding, WSLENV, and the legacy in-process SSH
|
||||
fallback must carry the hidden evidence without exposing it in command text or
|
||||
logs. A local runtime never accepts a raw remote token as local authority.
|
||||
Pre-update WSL processes without the token cannot use the lifecycle adapter.
|
||||
|
||||
For SSH, the host passthrough and in-process fallback stamp the saved target,
|
||||
`SshRelaySession` connection incarnation, and attachment provenance from the
|
||||
authenticated runtime channel; caller environment cannot choose them. WSL
|
||||
stamps the runtime-owned native host and distro in both the full bridge and
|
||||
fallback bridge. The verifier intersects those stamps with the committed host
|
||||
scope before considering token or pane evidence.
|
||||
|
||||
## Mixed-contract Run and mail routing
|
||||
|
||||
The adopted Run is a normal current Run. Tasks belong to it, so a current retry
|
||||
of a preserved Task naturally routes to the same Run without a cross-Run Task
|
||||
reference.
|
||||
|
||||
Message delivery is selected by `messages.delivery_contract`, not by Run ID:
|
||||
|
||||
- old and adapter-created direct messages are `legacy_direct`;
|
||||
- current sends are `current_delivery`; and
|
||||
- migration rejections and non-actionable history are `audit_only`.
|
||||
|
||||
Current Run Delivery creation, filtering, waiting, and acknowledgment select
|
||||
only `current_delivery`. Legacy `check` selects only the exact principal's
|
||||
`legacy_direct` address. History and inbox may show all three with explicit
|
||||
labels but never attach actions to `audit_only`. Mixed legacy/current attempts
|
||||
under one Task are covered across send, ask, check, reply, and settlement.
|
||||
|
||||
The original coordinator is automatically associated with the adopted Run
|
||||
after its principal proves live. `run-use` may bind that same principal without
|
||||
converting legacy Dispatches. A different coordinator cannot silently take
|
||||
over while a legacy attempt is active; an explicit recovery takeover is a
|
||||
separate current-contract action and revokes legacy coordinator mutation
|
||||
authority before rebinding.
|
||||
|
||||
When that coordinator uses old direct `check`, the adapter also performs a
|
||||
non-consuming existence check for `current_delivery` mail. It never mixes the
|
||||
messages into legacy output; it prints the exact current Run check command and,
|
||||
after that command creates a Delivery, the exact acknowledgment form. This
|
||||
keeps an unchanged coordinator informed about current retries without
|
||||
weakening current Delivery semantics.
|
||||
|
||||
## Exact legacy adapter
|
||||
|
||||
The CLI keeps accepting the old flags. It sends a hidden compatibility
|
||||
envelope; the runtime, not the CLI, decides whether legacy behavior applies.
|
||||
|
||||
Version B generates a random hidden `compatibilityInvocationId` once per CLI
|
||||
process and reuses it for every transport reconnect by that invocation.
|
||||
`legacy_operation_receipts.operation_key` is this invocation ID for B calls.
|
||||
The method and canonical payload hash must also match.
|
||||
|
||||
Attested compatibility calls bypass the rotating-auth generic mutation ledger
|
||||
after the contract fence routes them to the adapter. Their only dedupe boundary
|
||||
is the compatibility-principal receipt transaction described here; current
|
||||
calls continue through the existing generic ledger unchanged.
|
||||
|
||||
Version A had no invocation ID. Cross-cutover reconstruction is therefore
|
||||
limited to effects with a durable semantic identity: final settlement per
|
||||
Dispatch, still-pending ask per normalized question/options/recipient, reply
|
||||
per source/principal/body, and read recovery per message. Heartbeat and
|
||||
escalation remain at-least-once across an A-to-B manual retry because
|
||||
collapsing identical commands could suppress a legitimate later event. Tests
|
||||
and guidance do not promise cross-cutover exactly-once behavior for those
|
||||
repeatable signals.
|
||||
|
||||
An A-era ask that was already answered before its output was lost is also
|
||||
ambiguous. B does not guess whether the same text means retry or a new
|
||||
question. Its answer is in the durable recovery cohort and the error tells the
|
||||
agent to run the exact legacy check command first. After that answer is printed
|
||||
and acknowledged, a new B invocation with identical text creates a new
|
||||
question. If multiple identical A threads exist, ask fails ambiguous and
|
||||
recovery check drains them by message sequence; no arbitrary thread is chosen.
|
||||
|
||||
### Lifecycle `send`
|
||||
|
||||
- `heartbeat` and `worker_done`: resolve Task + Dispatch from the payload, then
|
||||
intersect them with the verified worker principal.
|
||||
- `escalation`: resolve Task + verified worker principal to exactly one active
|
||||
legacy Dispatch because the old prompt supplied no Dispatch ID.
|
||||
- Preserve the old direct coordinator recipient after verifying it belongs to
|
||||
the adopted Run principal.
|
||||
- Infer missing `worker_done` outcome as `failed` only when the trimmed subject
|
||||
is `Failed` or begins `Failed:`; otherwise infer `succeeded`.
|
||||
- Normalize to the current internal settlement model.
|
||||
|
||||
Compatibility validation happens before message insertion. Current lifecycle
|
||||
handling is not refactored through this prevalidation: its existing
|
||||
invalid-capability audit message behavior remains locked by regression tests.
|
||||
|
||||
The DB commits the direct message, lifecycle effect, and compatibility receipt
|
||||
in one transaction. If a pre-update `worker_done` already settled the Dispatch,
|
||||
the adapter reconstructs the matching semantic receipt from the existing
|
||||
message and settlement. The persisted A settlement is authoritative: the
|
||||
pinned A reconciler recorded even a `Failed:` subject as completed, so B
|
||||
returns that original completed result rather than retroactively changing Task
|
||||
or dependency state. Only a not-yet-accepted B call applies the corrected
|
||||
failure inference. A conflicting retry fails.
|
||||
|
||||
### `check`
|
||||
|
||||
Legacy check is at-least-once:
|
||||
|
||||
1. a read RPC returns the exact unacknowledged `legacy_direct` message IDs
|
||||
without changing them;
|
||||
2. the CLI prints the complete response; and
|
||||
3. a hidden ack atomically records receipts and marks those exact rows read.
|
||||
|
||||
A cut after read and before ack replays the same IDs. `--peek` and `--all`
|
||||
remain read-only.
|
||||
|
||||
Version A could mark a message read before losing its response. On adoption,
|
||||
every eligible read direct message connected to a still-active legacy attempt
|
||||
and lacking a post-adoption receipt is inserted into a durable recovery cohort
|
||||
using `legacy_mail_receipts` with `acknowledged_at = NULL`. Legacy check drains
|
||||
that stable cohort in bounded sequence-ordered pages before ordinary unread
|
||||
mail. Each page is labeled
|
||||
`[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`, replays until its post-print
|
||||
ack, and then advances. The cohort never grows after adoption, so every
|
||||
eligible row is covered without an unbounded response. Settled-at-cutover and
|
||||
unrelated historical mail do not enter it.
|
||||
|
||||
### `ask`
|
||||
|
||||
Legacy ask is an idempotent mutation followed by a resumable read:
|
||||
|
||||
1. create or find the direct legacy gate/question by verified Dispatch,
|
||||
normalized question, options, and recipient;
|
||||
2. atomically commit its message/thread plus operation receipt and return the
|
||||
stable question ID;
|
||||
3. wait in bounded read-only slices using that ID; and
|
||||
4. after printing the answer, hidden-ack that exact answer.
|
||||
|
||||
On first adoption, existing pending decision-gate threads are indexed into the
|
||||
same semantic identity. A retry resumes them instead of creating another gate.
|
||||
|
||||
### `reply`
|
||||
|
||||
Legacy reply requires the source thread, the verified coordinator principal,
|
||||
and a unique question owned by the adopted Run. One transaction inserts the
|
||||
reply, resolves the exact gate, marks the exact source, and completes the
|
||||
receipt.
|
||||
|
||||
An existing pre-update reply with the same source, principal, and body is
|
||||
reconstructed as success. A second body for the same source/principal is a
|
||||
conflict, not another reply.
|
||||
|
||||
### Coordinator and Task commands
|
||||
|
||||
After automatic binding, unchanged Task listing, Task settlement, dispatch,
|
||||
and gate inspection use the adopted Run. A new attempt receives a current
|
||||
preamble, capability, launch-token commitment, and current message contract.
|
||||
No unrelated Task can be created until the migrated coordinator binding is
|
||||
proven or explicitly taken over.
|
||||
|
||||
## Windows update behavior
|
||||
|
||||
No compatibility mutation holds a DB transaction while waiting. Windows
|
||||
legacy `ask` commits the question, prints its stable ID and exact resume
|
||||
command, then exits before a long wait whenever the installed launcher cannot
|
||||
prove that the executable is replaceable.
|
||||
|
||||
The native packaged launcher sets an internal
|
||||
`ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER=1` marker for its Electron-as-Node child.
|
||||
After the DB commit, legacy `ask` writes and flushes the stable question ID and
|
||||
the exact resume command, then exits with a documented `75` resume-required
|
||||
status. The command name comes only from the validated inherited
|
||||
`ORCA_CLI_COMMAND` packaged enum: `orca` for native packaged terminals or
|
||||
`orca-ide` for packaged WSL. Arbitrary environment text is rejected rather
|
||||
than rendered. The launcher propagates status 75. No long wait starts on this
|
||||
path.
|
||||
|
||||
This compatibility path makes the accepted question recoverable without
|
||||
claiming Windows updater or uninstaller process ownership. Generic executable
|
||||
drain, replacement, and uninstall behavior remains separate updater
|
||||
reliability work and requires its own Windows artifact proof.
|
||||
|
||||
## Rendering and authoritative guidance
|
||||
|
||||
Formatting is authority-aware:
|
||||
|
||||
- `[LEGACY COMPATIBILITY]`: live and attested; show only supported commands;
|
||||
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`: one bounded cutover replay;
|
||||
- `[LEGACY READ-ONLY]`: retained but unactionable; no Reply or Ack hint; and
|
||||
- current messages: unchanged.
|
||||
|
||||
Update `skill-guides/orchestration.md` and regenerate its bundled source. The
|
||||
guide explains automatic adoption, exact continuity, the retired scheduler
|
||||
boundary, proof failures, current retries, and the Windows ask resume fallback.
|
||||
It must not tell agents to restart a worker whose Dispatch was adopted.
|
||||
|
||||
Formatting cannot use database provenance alone; current liveness and
|
||||
attestation are runtime facts. Recovery may reveal the exact already-live PTY
|
||||
once as an inactive background tab. It never creates a PTY or process, writes
|
||||
input, switches workspaces, focuses, splits, or injects a terminal.
|
||||
|
||||
## Deterministic verification
|
||||
|
||||
### Schema and authority
|
||||
|
||||
- Migrate exact pre-v7, hard-cutover, current, partial, WAL, reset, and fresh
|
||||
fixtures; preserve IDs and row counts and reuse one adopted Run.
|
||||
- Assert Task, Dispatch, decision-gate, message, question-thread, and retained
|
||||
Delivery Run ownership agrees; outstanding legacy Deliveries are fenced.
|
||||
- Prove current retries in the adopted Run remain current across repeated
|
||||
migrations.
|
||||
- Test unique worker/coordinator import from prior hook commitment plus current
|
||||
live hook, and every forged, stale, wrong-host, wrong-pane, wrong-process,
|
||||
duplicate-token, duplicate-role, equivalent-pane, and remote-attachment
|
||||
ambiguity.
|
||||
- Race a current-runtime hook before adoption and prove the immutable startup
|
||||
commitment is neither overwritten nor confused with live proof.
|
||||
- Cover manual coordinators with no `coordinator_runs` row and reject
|
||||
conflicting candidates from Task, Dispatch, and message provenance.
|
||||
- Assert all legacy authority failures occur before message/receipt/gate/state
|
||||
effects while current invalid-capability rejection remains unchanged.
|
||||
- Assert tokens are absent from RPC logs, errors, CLI output, and orchestration
|
||||
storage.
|
||||
|
||||
### Exact old behavior and replay
|
||||
|
||||
- Replay the pinned prompt's heartbeat, successful and `Failed:`
|
||||
`worker_done`, task-only escalation, ask, and consuming check.
|
||||
- Replay the old formatter's reply command separately.
|
||||
- Cover every operation once after B and once accepted by A immediately before
|
||||
the cutover where a durable semantic identity exists, including the bounded
|
||||
recovery replay for an A check and preservation of A's persisted settlement.
|
||||
- Prove one B invocation reuses its invocation ID across reconnect, while
|
||||
separate identical heartbeat/escalation invocations remain distinct.
|
||||
- Inject real transport cuts after check read, ask commit, reply commit, and
|
||||
completion commit; prove stable IDs and one receipt/effect.
|
||||
- Page a recovery cohort larger than one response limit across repeated
|
||||
disconnects and prove every eligible row appears until ack, then never again.
|
||||
- Test mixed legacy/current attempts for every send/check/ask/reply route and
|
||||
prove current Delivery cannot consume legacy direct mail and old coordinator
|
||||
check only advertises the exact non-consuming current Delivery command.
|
||||
|
||||
### Live two-launch E2E
|
||||
|
||||
The focused renderer/daemon restart regression reconstructs the rc.3-era
|
||||
orphan artifact in a shared candidate profile: a durable legacy Dispatch,
|
||||
sleeping provider record, and live daemon PTY with its visual binding removed.
|
||||
It proves startup restores the exact PID, PTY/incarnation, handle, tab/leaf,
|
||||
Task, and Dispatch; emits no provider-resume spawn or interruption; preserves
|
||||
coordinator focus; and remains inert after switching away and back. This
|
||||
candidate/candidate fixture covers the renderer regression deterministically
|
||||
but does not replace the distinct installed-A/candidate-B packaged cutover
|
||||
journey below. It does not claim packaged Windows updater behavior.
|
||||
|
||||
Use distinct installed A and B artifacts plus an external append-only fake
|
||||
agent spawn ledger:
|
||||
|
||||
1. launch A with coordinator and worker terminals and record the replaceable A
|
||||
desktop/main PID, runtime ID, version, and artifact hash separately from the
|
||||
durable daemon, coordinator, and worker identities;
|
||||
2. accept one command with durable semantic identity on A—pending ask, reply,
|
||||
completion, or check—cut its response, close A without stopping the daemon
|
||||
or fake agent, install B, and prove B owns the relaunched runtime;
|
||||
3. assert A's desktop/main process and runtime are gone, B has a different
|
||||
desktop/main PID, runtime ID, version, and artifact hash, while the daemon
|
||||
plus coordinator/worker PID and start time, PTY/session, terminal handle,
|
||||
tab/leaf/pane, worktree, Task, and Dispatch remain byte-identical;
|
||||
4. prove that exact pending ask, reply, completion, or check resumes or
|
||||
deduplicates on B; do not use heartbeat, escalation, or an already-answered
|
||||
A ask as an exactly-once oracle;
|
||||
5. run the remaining exact old lifecycle commands and settle the same
|
||||
Dispatch;
|
||||
6. switch away and back and assert no second spawn/tab and no
|
||||
`Conversation interrupted`; and
|
||||
7. create a current attempt on preserved state and prove current grammar.
|
||||
|
||||
The append-only spawn ledger and an append-only interruption ledger, daemon
|
||||
inventory, runtime terminal inventory, and every viewer DOM independently
|
||||
prove one launch, process, handle, tab, and no `Conversation interrupted`.
|
||||
Transport fault injection cuts the real CLI connection after check output but
|
||||
before ack, and after ask, reply, and completion commit. Re-run the byte-exact A
|
||||
argv under B and assert stable IDs plus exactly one message, gate, reply,
|
||||
settlement, and receipt.
|
||||
|
||||
Retain #11142's local visible-inactive test and extend its oracle beyond DOM
|
||||
counting with the ledgers and host inventory.
|
||||
|
||||
Run separate remote journeys:
|
||||
|
||||
- headed paired server A-to-B with a surviving external client;
|
||||
- headless `orca serve` A-to-B with the same surviving-client oracle;
|
||||
- both prove the remote PID/PTy continuity, both viewer states, zero
|
||||
client-local handles/tabs, and wrong-host evidence with zero effects;
|
||||
- Docker SSH restart/reconnect with a remote proof file binding the process,
|
||||
pane, and token commitment;
|
||||
- physical Windows WSL coverage proving the correct distro and rejecting a
|
||||
wrong-distro token; and
|
||||
- a git-independent folder workspace parameter through the same adoption
|
||||
journey.
|
||||
|
||||
Packaged Windows updater continuity is outside this migration gate. A separate
|
||||
updater change must prove candidate-built A/B install, executable release,
|
||||
resume, and normal uninstall behavior on Windows before making that guarantee.
|
||||
|
||||
## Implementation slices
|
||||
|
||||
1. Schema, atomic Run adoption, per-row contract provenance, and mixed mailbox.
|
||||
2. Hook-commitment import, live principal verifier, host scopes, and redaction.
|
||||
3. Atomic compatibility receipts plus lifecycle send/escalation.
|
||||
4. Check read/ack and cutover recovery replay.
|
||||
5. Ask create/resume, reply conflict semantics, and coordinator binding.
|
||||
6. CLI hidden envelope, SSH/WSL forwarding, Windows resume fallback.
|
||||
7. Authority-aware formatting and the version-matched orchestration guide.
|
||||
8. Two-launch, remote, platform, and #11142 regression coverage.
|
||||
9. Focused unit/type/lint/E2E checks and final adversarial review.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Updates are never blocked.
|
||||
- Every durable legacy Task is owned by an ordinary visible Run.
|
||||
- Every provably original live legacy Dispatch can finish through its existing
|
||||
prompt without changing process or worktree.
|
||||
- New attempts use the current contract and mailbox in that same Run.
|
||||
- No authority comes from IDs or handles alone.
|
||||
- No adoption step starts, stops, writes, signals, focuses, splits, or injects
|
||||
a terminal. Exact live-PTY recovery may restore one inactive background
|
||||
renderer tab/leaf binding.
|
||||
- B-era disconnects replay or deduplicate instead of losing or duplicating
|
||||
work; A-era repeatable signals and already-answered asks follow the explicit
|
||||
ambiguity recovery rules.
|
||||
- Lost scheduler memory is reported precisely without stranding durable state.
|
||||
- Local/background, remote, SSH, WSL, folder, and Windows guarantees have
|
||||
deterministic proof.
|
||||
- The PR contains this plan, focused implementation, and no unrelated changes.
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -15,12 +15,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
|
||||
import {
|
||||
displayHostEndpoint,
|
||||
endpointPort,
|
||||
endpointScheme,
|
||||
normalizeHostEndpoint
|
||||
} from '../../../src/transport/host-endpoint'
|
||||
import { displayHostEndpoint } from '../../../src/transport/host-endpoint'
|
||||
import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit'
|
||||
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
|
||||
import type { HostProfile } from '../../../src/transport/types'
|
||||
|
||||
@@ -68,27 +64,24 @@ export default function EditHostScreen() {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
|
||||
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
|
||||
|
||||
const normalizedEndpoint = useMemo(
|
||||
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
|
||||
[address, fallbackPort, fallbackScheme]
|
||||
const endpointEdit = useMemo(
|
||||
() => (host ? resolveHostEndpointEdit(host.endpoint, address) : null),
|
||||
[address, host]
|
||||
)
|
||||
|
||||
const nameTrimmed = name.trim()
|
||||
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
|
||||
const endpointChanged =
|
||||
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
|
||||
const endpointChanged = endpointEdit?.kind === 'changed'
|
||||
const canSave =
|
||||
host != null &&
|
||||
endpointEdit != null &&
|
||||
nameTrimmed.length > 0 &&
|
||||
normalizedEndpoint.ok &&
|
||||
endpointEdit.kind !== 'invalid' &&
|
||||
(nameChanged || endpointChanged) &&
|
||||
!saving
|
||||
|
||||
async function handleSave() {
|
||||
if (!host || !hostId || savingRef.current) {
|
||||
if (!host || !hostId || !endpointEdit || savingRef.current) {
|
||||
return
|
||||
}
|
||||
const nextName = name.trim()
|
||||
@@ -96,14 +89,14 @@ export default function EditHostScreen() {
|
||||
setSaveError('Enter a name.')
|
||||
return
|
||||
}
|
||||
if (!normalizedEndpoint.ok) {
|
||||
setSaveError(normalizedEndpoint.error)
|
||||
if (endpointEdit.kind === 'invalid') {
|
||||
setSaveError(endpointEdit.error)
|
||||
return
|
||||
}
|
||||
|
||||
const willRename = nextName !== host.name
|
||||
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
|
||||
if (!willRename && !willUpdateEndpoint) {
|
||||
const nextEndpoint = endpointEdit.kind === 'changed' ? endpointEdit.endpoint : undefined
|
||||
if (!willRename && nextEndpoint === undefined) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
@@ -117,7 +110,7 @@ export default function EditHostScreen() {
|
||||
// other, and a host removed mid-edit throws instead of no-oping.
|
||||
await updateHostNameAndEndpoint(host.id, {
|
||||
...(willRename ? { name: nextName } : {}),
|
||||
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
|
||||
...(nextEndpoint !== undefined ? { endpoint: nextEndpoint } : {})
|
||||
})
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
|
||||
@@ -140,7 +133,7 @@ export default function EditHostScreen() {
|
||||
setSaving(false)
|
||||
router.back()
|
||||
|
||||
if (willUpdateEndpoint) {
|
||||
if (nextEndpoint !== undefined) {
|
||||
// Why: reconnect is a follow-on side effect of a save that already
|
||||
// committed — its failure or a hang must not be reported as a save
|
||||
// failure or block navigating back.
|
||||
@@ -247,12 +240,12 @@ export default function EditHostScreen() {
|
||||
(or 6768).
|
||||
</Text>
|
||||
|
||||
{normalizedEndpoint.ok ? (
|
||||
{endpointEdit == null ? null : endpointEdit.kind !== 'invalid' ? (
|
||||
<Text style={styles.preview} numberOfLines={2}>
|
||||
Connects to {normalizedEndpoint.endpoint}
|
||||
Connects to {endpointEdit.endpoint}
|
||||
</Text>
|
||||
) : address.trim().length > 0 ? (
|
||||
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
|
||||
<Text style={styles.previewError}>{endpointEdit.error}</Text>
|
||||
) : null}
|
||||
|
||||
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -149,8 +149,13 @@ describe('edit host handleSave', () => {
|
||||
})
|
||||
|
||||
it('rename-only save updates only the name and does not reconnect', async () => {
|
||||
const storedEndpoint = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
|
||||
dependencies.loadHosts.mockResolvedValueOnce([{ ...HOST_FIXTURE, endpoint: storedEndpoint }])
|
||||
const renderer = await renderEditHostRoute()
|
||||
setFieldValue(renderer, 'Address', ' wss://%64esk.example.com:443 ')
|
||||
setFieldValue(renderer, 'Name', 'Home Desk')
|
||||
|
||||
expect(findText(renderer, `Connects to ${storedEndpoint}`)).toBe(true)
|
||||
await pressSave(renderer)
|
||||
|
||||
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WebSocket } from 'ws'
|
||||
import {
|
||||
handleRequest,
|
||||
type RpcRequest,
|
||||
type RpcResponse
|
||||
} from '../scripts/mock-server-rpc-handlers'
|
||||
|
||||
function callRpc(method: string, params?: Record<string, unknown>): RpcResponse {
|
||||
let response: RpcResponse | undefined
|
||||
const request: RpcRequest = { id: 'request-1', method, ...(params ? { params } : {}) }
|
||||
handleRequest(
|
||||
request,
|
||||
(nextResponse) => {
|
||||
response = nextResponse
|
||||
},
|
||||
{} as WebSocket
|
||||
)
|
||||
expect(response).toBeDefined()
|
||||
return response!
|
||||
}
|
||||
|
||||
function listSessionTabs(worktree: string): RpcResponse {
|
||||
return callRpc('session.tabs.list', { worktree })
|
||||
}
|
||||
|
||||
describe('mock server session tabs fixture', () => {
|
||||
it('returns a contract-complete terminal surface for the requested worktree', () => {
|
||||
const response = listSessionTabs('id:repo-1::worktree-1')
|
||||
|
||||
expect(response.result).toEqual({
|
||||
worktree: 'repo-1::worktree-1',
|
||||
publicationEpoch: expect.stringMatching(/^mock-server:/),
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabOrder: ['tab-1'],
|
||||
recentTabIds: ['tab-1']
|
||||
}
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
title: 'zsh',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
status: 'ready',
|
||||
terminal: 'term-1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('passes a bare worktree selector through unprefixed', () => {
|
||||
const response = listSessionTabs('repo-1::worktree-1')
|
||||
|
||||
expect((response.result as { worktree: string }).worktree).toBe('repo-1::worktree-1')
|
||||
})
|
||||
|
||||
it('falls back to the same worktree terminal.list uses when no selector is sent', () => {
|
||||
const terminals = callRpc('terminal.list').result as {
|
||||
terminals: { worktreeId: string }[]
|
||||
}
|
||||
const expected = terminals.terminals[0]?.worktreeId
|
||||
expect(expected).toBeTruthy()
|
||||
|
||||
const tabs = callRpc('session.tabs.list').result as { worktree: string }
|
||||
expect(tabs.worktree).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
|
||||
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
|
||||
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
|
||||
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
|
||||
import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected'
|
||||
|
||||
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
|
||||
background: colors.terminalBg,
|
||||
@@ -688,6 +689,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
initRows = rows || 24;
|
||||
firstDataPending = true;
|
||||
smoothScrollOffsetY = 0;
|
||||
wheelAccumDeltaY = 0;
|
||||
mouseModeScanTail = '';
|
||||
trackedMouseTrackingMode = 'none';
|
||||
sgrMouseMode = false;
|
||||
@@ -1634,6 +1636,10 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
// terminal-webview-tap-dispatch-injected.ts (extracted for max-lines).
|
||||
${TERMINAL_TAP_DISPATCH_JS}
|
||||
|
||||
// External mouse / trackpad scroll: see
|
||||
// terminal-webview-wheel-scroll-injected.ts (extracted for max-lines).
|
||||
${TERMINAL_WHEEL_SCROLL_JS}
|
||||
|
||||
btnCopy.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1690,6 +1696,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
|
||||
targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
|
||||
|
||||
attachSurfaceWheelHandler(targetSurface);
|
||||
|
||||
targetSurface.addEventListener('touchstart', function(e) {
|
||||
if (dispatcherShouldBlockSurface()) return;
|
||||
if (ts.momentumId) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Indirect-pointer (external mouse / trackpad) scroll for the terminal surface,
|
||||
// injected into XTERM_HTML. Extracted from terminal-webview-html.ts to keep that
|
||||
// file within its max-lines budget. Closes over host-IIFE state/functions:
|
||||
// term, getCellHeight, getTotalScale, shouldRouteScrollToTerminalInput,
|
||||
// routeScrollLines, enqueueNormalBufferScrollDelta, resetSmoothScrollOffset,
|
||||
// and dispatcherShouldBlockSurface.
|
||||
export const TERMINAL_WHEEL_SCROLL_JS = `
|
||||
var wheelAccumDeltaY = 0;
|
||||
|
||||
function wheelEventPixelDeltaY(e) {
|
||||
var delta = e.deltaY;
|
||||
if (typeof delta !== 'number' || !isFinite(delta) || delta === 0) return 0;
|
||||
// DOM_DELTA_LINE / DOM_DELTA_PAGE: Android WebView reports line-mode deltas
|
||||
// for external mouse wheels, iOS trackpads report pixels.
|
||||
if (e.deltaMode === 1) return delta * getCellHeight() * getTotalScale();
|
||||
if (e.deltaMode === 2) return delta * window.innerHeight;
|
||||
return delta;
|
||||
}
|
||||
|
||||
function attachSurfaceWheelHandler(targetSurface) {
|
||||
targetSurface.addEventListener('wheel', function(e) {
|
||||
if (dispatcherShouldBlockSurface()) return;
|
||||
if (!term) return;
|
||||
// Why: xterm's own wheel handler scrolls its hidden viewport or emits
|
||||
// cursor keys through onData, which the mobile query-reply gate drops.
|
||||
// Claim the event so indirect pointers share the touch scroll router.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Why: a trackpad pinch arrives as ctrl+wheel. Swallow it rather than
|
||||
// firing cursor keys at the TUI; two-finger pinch still drives text size.
|
||||
if (e.ctrlKey) return;
|
||||
|
||||
var deltaY = wheelEventPixelDeltaY(e);
|
||||
if (deltaY === 0) return;
|
||||
|
||||
if (shouldRouteScrollToTerminalInput()) {
|
||||
resetSmoothScrollOffset();
|
||||
var effectiveCellH = getCellHeight() * getTotalScale();
|
||||
if (!(effectiveCellH > 0)) return;
|
||||
wheelAccumDeltaY += deltaY;
|
||||
var lines = Math.trunc(wheelAccumDeltaY / effectiveCellH);
|
||||
if (lines !== 0) {
|
||||
wheelAccumDeltaY -= lines * effectiveCellH;
|
||||
routeScrollLines(lines, e.clientX, e.clientY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
wheelAccumDeltaY = 0;
|
||||
enqueueNormalBufferScrollDelta(deltaY);
|
||||
}, { capture: true, passive: false });
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,292 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
function iifeSource(): string {
|
||||
const start = XTERM_HTML.indexOf('(function() {')
|
||||
const end = XTERM_HTML.lastIndexOf('})();')
|
||||
return XTERM_HTML.slice(start, end + '})();'.length)
|
||||
}
|
||||
|
||||
function bodyMarkup(): string {
|
||||
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
|
||||
const end = XTERM_HTML.indexOf('<script>', start)
|
||||
return XTERM_HTML.slice(start, end)
|
||||
}
|
||||
|
||||
type BufferState = {
|
||||
baseY: number
|
||||
type: 'alternate' | 'normal'
|
||||
viewportY: number
|
||||
}
|
||||
|
||||
type TerminalStub = ReturnType<typeof makeTerminal>
|
||||
type RegisteredWindowListener = {
|
||||
listener: EventListenerOrEventListenerObject
|
||||
options?: boolean | AddEventListenerOptions
|
||||
type: string
|
||||
}
|
||||
|
||||
const CELL_HEIGHT = 15
|
||||
const ESC = '\u001b'
|
||||
const ESC_ARROW_DOWN = `${ESC}[B`
|
||||
const ESC_APP_ARROW_UP = `${ESC}OA`
|
||||
|
||||
function makeTerminal(buffer: BufferState, scrollLines: (lines: number) => void) {
|
||||
const terminal = {
|
||||
cols: 40,
|
||||
rows: 24,
|
||||
options: { fontSize: 13 },
|
||||
modes: { mouseTrackingMode: 'none' as string },
|
||||
element: null as HTMLElement | null,
|
||||
_core: {
|
||||
_renderService: { dimensions: { css: { cell: { width: 8, height: CELL_HEIGHT } } } }
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
get baseY() {
|
||||
return buffer.baseY
|
||||
},
|
||||
get type() {
|
||||
return buffer.type
|
||||
},
|
||||
get viewportY() {
|
||||
return buffer.viewportY
|
||||
},
|
||||
cursorY: 0,
|
||||
length: 1,
|
||||
getLine: () => null
|
||||
}
|
||||
},
|
||||
write(_data: string, callback?: () => void) {
|
||||
callback?.()
|
||||
},
|
||||
open(surface: HTMLElement) {
|
||||
terminal.element = surface
|
||||
},
|
||||
loadAddon() {},
|
||||
resize(cols: number, rows: number) {
|
||||
terminal.cols = cols
|
||||
terminal.rows = rows
|
||||
},
|
||||
clear() {},
|
||||
reset() {},
|
||||
refresh() {},
|
||||
selectAll() {},
|
||||
clearSelection() {},
|
||||
select() {},
|
||||
scrollLines,
|
||||
scrollToBottom() {},
|
||||
scrollToLine() {},
|
||||
attachCustomKeyEventHandler() {},
|
||||
getSelection: () => '',
|
||||
onData: () => ({ dispose() {} }),
|
||||
onLineFeed: () => ({ dispose() {} }),
|
||||
onScroll: () => ({ dispose() {} }),
|
||||
onWriteParsed: () => ({ dispose() {} }),
|
||||
dispose() {}
|
||||
}
|
||||
return terminal
|
||||
}
|
||||
|
||||
function dispatchWheel(deltaY: number, init: WheelEventInit = {}): WheelEvent {
|
||||
const surface = document.getElementById('terminal-surface')
|
||||
if (!surface) {
|
||||
throw new Error('terminal surface missing')
|
||||
}
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: 40,
|
||||
clientY: 60,
|
||||
deltaMode: 0,
|
||||
deltaY,
|
||||
...init
|
||||
})
|
||||
if (init.ctrlKey) {
|
||||
// Why: happy-dom drops modifier flags from the WheelEvent init dict.
|
||||
Object.defineProperty(event, 'ctrlKey', { value: true })
|
||||
}
|
||||
surface.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
function terminalInputBytes(postMessage: ReturnType<typeof vi.fn>): string {
|
||||
return postMessage.mock.calls
|
||||
.map(([raw]) => JSON.parse(String(raw)) as { bytes?: string; type: string })
|
||||
.filter((msg) => msg.type === 'terminal-input')
|
||||
.map((msg) => msg.bytes ?? '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('terminal WebView external pointer wheel scrolling', () => {
|
||||
let animationFrames: Array<() => void>
|
||||
let buffer: BufferState
|
||||
let postMessage: ReturnType<typeof vi.fn>
|
||||
let registeredWindowListeners: RegisteredWindowListener[]
|
||||
let scrollLines: ReturnType<typeof vi.fn>
|
||||
let terminals: TerminalStub[]
|
||||
|
||||
function boot(): void {
|
||||
document.body.innerHTML = bodyMarkup()
|
||||
new Function(iifeSource())()
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' })
|
||||
})
|
||||
)
|
||||
// Why: init commits the replacement surface on the next animation frame.
|
||||
while (animationFrames.length > 0) {
|
||||
animationFrames.shift()?.()
|
||||
}
|
||||
}
|
||||
|
||||
function flushFrames(): void {
|
||||
for (let i = 0; i < 4 && animationFrames.length > 0; i++) {
|
||||
animationFrames.shift()?.()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
animationFrames = []
|
||||
buffer = { baseY: 0, type: 'normal', viewportY: 0 }
|
||||
registeredWindowListeners = []
|
||||
scrollLines = vi.fn()
|
||||
terminals = []
|
||||
const addWindowEventListener = window.addEventListener.bind(window)
|
||||
vi.spyOn(window, 'addEventListener').mockImplementation(((
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject,
|
||||
options?: boolean | AddEventListenerOptions
|
||||
) => {
|
||||
registeredWindowListeners.push({ type, listener, options })
|
||||
addWindowEventListener(type, listener, options)
|
||||
}) as typeof window.addEventListener)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
|
||||
animationFrames.push(callback)
|
||||
return animationFrames.length
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
|
||||
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
|
||||
postMessage = vi.fn()
|
||||
const webWindow = window as unknown as {
|
||||
Terminal: new () => TerminalStub
|
||||
ReactNativeWebView: { postMessage: (data: string) => void }
|
||||
}
|
||||
webWindow.Terminal = function () {
|
||||
const terminal = makeTerminal(buffer, scrollLines)
|
||||
terminals.push(terminal)
|
||||
return terminal
|
||||
} as unknown as new () => TerminalStub
|
||||
webWindow.ReactNativeWebView = { postMessage }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { type, listener, options } of registeredWindowListeners) {
|
||||
window.removeEventListener(type, listener as EventListener, options)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('turns an alternate-screen wheel scroll into cursor keys (#6863, #8818)', () => {
|
||||
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
|
||||
boot()
|
||||
expect(terminals).toHaveLength(1)
|
||||
|
||||
dispatchWheel(3 * CELL_HEIGHT)
|
||||
|
||||
// Why: alt-screen TUIs (Claude Code, vim) have no scrollback, so an external
|
||||
// mouse/trackpad wheel must reach the PTY as cursor keys the way touch does.
|
||||
expect(terminalInputBytes(postMessage)).toBe(ESC_ARROW_DOWN.repeat(3))
|
||||
})
|
||||
|
||||
it('sends application cursor keys when the TUI enabled DECCKM', () => {
|
||||
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
|
||||
boot()
|
||||
const terminal = terminals[0]
|
||||
if (!terminal) {
|
||||
throw new Error('terminal missing')
|
||||
}
|
||||
;(terminal.modes as Record<string, unknown>).applicationCursorKeysMode = true
|
||||
|
||||
dispatchWheel(-2 * CELL_HEIGHT)
|
||||
|
||||
expect(terminalInputBytes(postMessage)).toBe(ESC_APP_ARROW_UP.repeat(2))
|
||||
})
|
||||
|
||||
it('reports the wheel as mouse tracking bytes when the TUI asked for them', () => {
|
||||
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
|
||||
boot()
|
||||
const terminal = terminals[0]
|
||||
if (!terminal) {
|
||||
throw new Error('terminal missing')
|
||||
}
|
||||
terminal.modes.mouseTrackingMode = 'any'
|
||||
|
||||
dispatchWheel(CELL_HEIGHT)
|
||||
|
||||
// Default (non-SGR) encoding: wheel-down button 65 + 32 = 97 ('a'), then two
|
||||
// printable cell bytes (their value depends on the fit scale, not on routing).
|
||||
const bytes = terminalInputBytes(postMessage)
|
||||
expect(bytes.slice(0, 4)).toBe(`${ESC}[Ma`)
|
||||
expect(bytes).toHaveLength(6)
|
||||
expect(bytes.charCodeAt(4)).toBeGreaterThanOrEqual(33)
|
||||
expect(bytes.charCodeAt(5)).toBeGreaterThanOrEqual(33)
|
||||
})
|
||||
|
||||
it('scrolls normal-buffer scrollback from a wheel instead of leaving it frozen', () => {
|
||||
buffer = { baseY: 20, type: 'normal', viewportY: 20 }
|
||||
boot()
|
||||
|
||||
dispatchWheel(-3 * CELL_HEIGHT)
|
||||
flushFrames()
|
||||
|
||||
expect(scrollLines).toHaveBeenCalledWith(-3)
|
||||
})
|
||||
|
||||
it('converts line-mode deltas that Android reports for external mouse wheels', () => {
|
||||
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
|
||||
boot()
|
||||
|
||||
dispatchWheel(2, { deltaMode: 1 })
|
||||
|
||||
expect(terminalInputBytes(postMessage)).toBe(ESC_ARROW_DOWN.repeat(2))
|
||||
})
|
||||
|
||||
it('swallows a trackpad pinch instead of firing cursor keys at the TUI', () => {
|
||||
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
|
||||
boot()
|
||||
|
||||
const event = dispatchWheel(3 * CELL_HEIGHT, { ctrlKey: true })
|
||||
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('claims the wheel so xterm does not double-apply it', () => {
|
||||
buffer = { baseY: 20, type: 'normal', viewportY: 20 }
|
||||
boot()
|
||||
|
||||
const event = dispatchWheel(-CELL_HEIGHT)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('clears fractional wheel state when the terminal is recreated', () => {
|
||||
buffer = { baseY: 0, type: 'alternate', viewportY: 0 }
|
||||
boot()
|
||||
dispatchWheel(CELL_HEIGHT - 1)
|
||||
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' })
|
||||
})
|
||||
)
|
||||
flushFrames()
|
||||
dispatchWheel(1)
|
||||
|
||||
expect(terminalInputBytes(postMessage)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { displayHostEndpoint } from './host-endpoint'
|
||||
import { resolveHostEndpointEdit } from './host-endpoint-edit'
|
||||
|
||||
function persistedAfterEdit(stored: string, input = displayHostEndpoint(stored)) {
|
||||
const edit = resolveHostEndpointEdit(stored, input)
|
||||
return edit.kind === 'changed' ? edit.endpoint : stored
|
||||
}
|
||||
|
||||
describe('resolveHostEndpointEdit', () => {
|
||||
it.each([
|
||||
['implicit secure DNS proxy', 'wss://desk.example.com'],
|
||||
['explicit secure default', 'wss://desk.example.com:443'],
|
||||
['explicit secure custom', 'wss://desk.example.com:8443'],
|
||||
['implicit secure IPv6 proxy', 'wss://[2001:db8::1]'],
|
||||
['explicit secure IPv6 default', 'wss://[2001:db8::1]:443'],
|
||||
['implicit LAN websocket', 'ws://desk.local'],
|
||||
['explicit websocket default', 'ws://desk.local:80'],
|
||||
['explicit websocket custom', 'ws://desk.local:6768'],
|
||||
['secure path-routed proxy', 'wss://desk.example.com/orca'],
|
||||
['secure query-routed proxy', 'wss://desk.example.com/orca?route=runtime'],
|
||||
['legacy bare hostname', 'desk.local'],
|
||||
['legacy bare host and port', 'desk.local:7777'],
|
||||
['legacy unparsable endpoint', 'not-a-url']
|
||||
])('preserves an untouched %s endpoint', (_label, stored) => {
|
||||
const edit = resolveHostEndpointEdit(stored, displayHostEndpoint(stored))
|
||||
|
||||
expect(edit).toEqual({ kind: 'unchanged', endpoint: stored })
|
||||
expect(persistedAfterEdit(stored)).toBe(stored)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['wss://old.example.com', 'new.example.com', 'wss://new.example.com:443'],
|
||||
['wss://old.example.com:8443', 'new.example.com', 'wss://new.example.com:8443'],
|
||||
['ws://old.local', 'new.local', 'ws://new.local:6768'],
|
||||
['ws://old.local:80', 'new.local', 'ws://new.local:80'],
|
||||
['wss://[2001:db8::1]', '[2001:db8::2]', 'wss://[2001:db8::2]:443']
|
||||
])('uses the current endpoint semantics for an address edit', (stored, input, expected) => {
|
||||
const edit = resolveHostEndpointEdit(stored, input)
|
||||
|
||||
expect(edit).toEqual({ kind: 'changed', endpoint: expected })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['surrounding whitespace', ' desk.example.com '],
|
||||
['hostname case', 'DESK.EXAMPLE.COM'],
|
||||
['explicit default port', 'desk.example.com:443'],
|
||||
['percent-encoded hostname', 'wss://%64esk.example.com:443']
|
||||
])('preserves routed endpoint bytes for equivalent %s', (_label, input) => {
|
||||
const stored = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
|
||||
|
||||
expect(resolveHostEndpointEdit(stored, input)).toEqual({
|
||||
kind: 'unchanged',
|
||||
endpoint: stored
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the hidden route when the authority changes', () => {
|
||||
expect(
|
||||
resolveHostEndpointEdit('wss://old.example.com/%6Fruntime?route=%72ed', 'new.example.com')
|
||||
).toEqual({
|
||||
kind: 'changed',
|
||||
endpoint: 'wss://new.example.com:443/%6Fruntime?route=%72ed'
|
||||
})
|
||||
})
|
||||
|
||||
it('discriminates invalid input without a candidate endpoint', () => {
|
||||
expect(
|
||||
resolveHostEndpointEdit('wss://desk.example.com/orca', 'https://desk.example.com')
|
||||
).toEqual({
|
||||
kind: 'invalid',
|
||||
error: 'Use ws:// or wss:// (or host:port).'
|
||||
})
|
||||
})
|
||||
|
||||
it('stays stable through repeated name-only edits', () => {
|
||||
const stored = 'wss://desk.example.com/orca?route=runtime'
|
||||
const once = persistedAfterEdit(stored)
|
||||
|
||||
expect(once).toBe(stored)
|
||||
expect(persistedAfterEdit(once)).toBe(stored)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
displayHostEndpoint,
|
||||
endpointPort,
|
||||
endpointScheme,
|
||||
normalizeHostEndpoint
|
||||
} from './host-endpoint'
|
||||
|
||||
export type HostEndpointEditResolution =
|
||||
| { kind: 'unchanged'; endpoint: string }
|
||||
| { kind: 'changed'; endpoint: string }
|
||||
| { kind: 'invalid'; error: string }
|
||||
|
||||
export function resolveHostEndpointEdit(
|
||||
storedEndpoint: string,
|
||||
input: string
|
||||
): HostEndpointEditResolution {
|
||||
const displayedEndpoint = displayHostEndpoint(storedEndpoint)
|
||||
if (input.trim() === displayedEndpoint) {
|
||||
return { kind: 'unchanged', endpoint: storedEndpoint }
|
||||
}
|
||||
|
||||
const fallbackScheme = endpointScheme(storedEndpoint)
|
||||
const fallbackPort =
|
||||
endpointPort(storedEndpoint) ?? (fallbackScheme === 'wss' ? '443' : undefined)
|
||||
const options = { fallbackPort, fallbackScheme }
|
||||
const normalizedInput = normalizeHostEndpoint(input, options)
|
||||
if (!normalizedInput.ok) {
|
||||
return { kind: 'invalid', error: normalizedInput.error }
|
||||
}
|
||||
|
||||
const normalizedDisplay = normalizeHostEndpoint(displayedEndpoint, options)
|
||||
if (
|
||||
sameEndpointAuthority(normalizedInput.endpoint, storedEndpoint) ||
|
||||
(normalizedDisplay.ok &&
|
||||
sameEndpointAuthority(normalizedInput.endpoint, normalizedDisplay.endpoint))
|
||||
) {
|
||||
return { kind: 'unchanged', endpoint: storedEndpoint }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'changed',
|
||||
endpoint: normalizedInput.endpoint + endpointRouteSuffix(storedEndpoint)
|
||||
}
|
||||
}
|
||||
|
||||
function sameEndpointAuthority(left: string, right: string): boolean {
|
||||
try {
|
||||
return new URL(left).origin === new URL(right).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function endpointRouteSuffix(endpoint: string): string {
|
||||
const match = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]*(.*)$/.exec(endpoint)
|
||||
return match?.[1] ?? ''
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+28
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -50,6 +50,11 @@ internal static class OrcaCliLauncher
|
||||
"ORCA_NODE_REPL_EXTERNAL_MODULE"
|
||||
);
|
||||
startInfo.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1";
|
||||
startInfo.EnvironmentVariables["ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER"] = "1";
|
||||
startInfo.EnvironmentVariables["ORCA_CLI_COMMAND"] =
|
||||
Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND") == "orca-ide"
|
||||
? "orca-ide"
|
||||
: "orca";
|
||||
|
||||
using (Process child = Process.Start(startInfo))
|
||||
{
|
||||
|
||||
+6
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.160-rc.5",
|
||||
"version": "1.4.162-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -11,7 +11,7 @@
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"format": "oxfmt --write .",
|
||||
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
|
||||
"lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
|
||||
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
|
||||
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
|
||||
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
|
||||
@@ -60,12 +60,7 @@
|
||||
"verify:cli-bin": "node config/scripts/verify-cli-bin.mjs",
|
||||
"verify:localization-catalog": "node config/scripts/verify-localization-catalog.mjs",
|
||||
"sync:localization-catalog": "node config/scripts/verify-localization-catalog.mjs --fix",
|
||||
"bootstrap:locale-catalog": "node config/scripts/bootstrap-locale-catalog.mjs",
|
||||
"bootstrap:zh-catalog": "node config/scripts/bootstrap-zh-catalog.mjs",
|
||||
"bootstrap:ko-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale ko",
|
||||
"bootstrap:ja-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale ja",
|
||||
"bootstrap:es-catalog": "node config/scripts/bootstrap-locale-catalog.mjs --locale es",
|
||||
"repair:locale-catalog": "node config/scripts/repair-locale-catalog.mjs",
|
||||
"verify:localization-extraction": "node config/scripts/verify-localization-extraction.mjs",
|
||||
"verify:localization-coverage": "node config/scripts/audit-localization-coverage.mjs --check",
|
||||
"audit:localization": "node config/scripts/audit-localization-coverage.mjs",
|
||||
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs",
|
||||
@@ -105,11 +100,13 @@
|
||||
"win-crash-survival-e2e": "node tools/win-crash-survival-e2e/run.mjs",
|
||||
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
|
||||
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
|
||||
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
|
||||
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
|
||||
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
|
||||
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
|
||||
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:worktree-deletion": "node tools/benchmarks/worktree-deletion-dev-bench.mjs",
|
||||
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
|
||||
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
|
||||
"bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-reveal-bench.mjs",
|
||||
@@ -197,6 +194,7 @@
|
||||
"happy-dom": "^20.9.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"i18next-cli": "1.65.0",
|
||||
"katex": "^0.16.45",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lowlight": "^3.3.0",
|
||||
|
||||
Generated
+507
-41
@@ -19,7 +19,7 @@ patchedDependencies:
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3
|
||||
hash: 082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6
|
||||
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
|
||||
@@ -46,7 +46,7 @@ importers:
|
||||
version: 2.5.6
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.287
|
||||
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -206,25 +206,25 @@ importers:
|
||||
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.12.0-beta.287
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/addon-ligatures':
|
||||
specifier: 0.11.0-beta.287
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/addon-web-links':
|
||||
specifier: 0.13.0-beta.287
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
version: 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -248,7 +248,7 @@ importers:
|
||||
version: 26.15.3(dmg-builder@26.15.3)
|
||||
electron-vite:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
version: 5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
emoji-picker-react:
|
||||
specifier: ^4.19.1
|
||||
version: 4.19.1(react@19.2.7)
|
||||
@@ -264,6 +264,9 @@ importers:
|
||||
husky:
|
||||
specifier: ^9.1.7
|
||||
version: 9.1.7
|
||||
i18next-cli:
|
||||
specifier: 1.65.0
|
||||
version: 1.65.0(@types/node@25.9.5)(react-dom@19.2.7(react@19.2.7))(typescript@7.0.2)
|
||||
katex:
|
||||
specifier: ^0.16.45
|
||||
version: 0.16.45
|
||||
@@ -575,6 +578,12 @@ packages:
|
||||
'@chevrotain/types@11.1.2':
|
||||
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
|
||||
|
||||
'@croct/json5-parser@0.2.2':
|
||||
resolution: {integrity: sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw==}
|
||||
|
||||
'@croct/json@2.1.0':
|
||||
resolution: {integrity: sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
@@ -867,6 +876,15 @@ packages:
|
||||
resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
|
||||
'@inquirer/checkbox@5.2.1':
|
||||
resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/confirm@6.1.1':
|
||||
resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
@@ -885,10 +903,100 @@ packages:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/editor@5.2.2':
|
||||
resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/expand@5.1.1':
|
||||
resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/external-editor@3.0.3':
|
||||
resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/figures@2.0.7':
|
||||
resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
|
||||
'@inquirer/input@5.1.2':
|
||||
resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/number@4.1.1':
|
||||
resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/password@5.1.1':
|
||||
resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/prompts@8.5.2':
|
||||
resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/rawlist@5.3.1':
|
||||
resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/search@4.2.1':
|
||||
resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/select@5.2.1':
|
||||
resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/type@4.0.7':
|
||||
resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
@@ -2429,6 +2537,99 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.46':
|
||||
resolution: {integrity: sha512-IsISIT22EfktVJrlvIpnAxG2u/A9aob9l99HMlx80x72WlFmFPk1V3UhkEzx86eJP8hw049KTFv/RISho2cq2Q==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@swc/core-darwin-x64@1.15.46':
|
||||
resolution: {integrity: sha512-4Tj4ppVIPCmUMpmGFiGtyEriwLyJ+yi/US4WfBrP/ok8COGddDZXLEzQETnKyK46mjvr1v0jevrS23zjoff7vA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@swc/core-linux-arm-gnueabihf@1.15.46':
|
||||
resolution: {integrity: sha512-i8tUGnNjyOgMmfmgFSg4aeJLQoFyfpIHK5FjpQAwpRyQIqEUB2w1e8zIDQzY1WhOxx8NoS1S5iUL813Un4Sf5A==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-arm64-gnu@1.15.46':
|
||||
resolution: {integrity: sha512-c0OnhqzdhfOvv6qhNCcByepB+sNYOGZyhtr2Qa6ZCHvAWTYhSRw4j/u92Stue9PbZ/6q74b9nHzi76+kVzqQHQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.46':
|
||||
resolution: {integrity: sha512-imyRpNEcUzFQFV2LE4jL68ErvmKEuZCbvZru77iQREunJ+bR4i658cupTgtG1mLYM3F1Tzy3Sb9xYb02KghWTg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.46':
|
||||
resolution: {integrity: sha512-ctEfcl/HcUeomK33cbySiHZm98GEDIxTm1EkpBsYCiHxElYBzvTXVeuQT2YwbUXn9XCrjiw4ipyUNk33k26qRg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.46':
|
||||
resolution: {integrity: sha512-DxlMdnt84TtRVTv7WL/thWyz9+QU8QZNNoAP9rrk0P68LziuhfePp8MjQ44zIprpTHTsEwyziIuGUUN5iSC1bQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.46':
|
||||
resolution: {integrity: sha512-SKxI7J6t90XPl8hRUqtJi9NfGdunN/E/vZMc7Bc0figeRdOPDBT+Tm8g7cx9xM0T0mewh2l+8dewa3Am27/P+A==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.46':
|
||||
resolution: {integrity: sha512-qj9T6B7bosI0VEsrWOVXZN1OXxS8Tp63ywyrLxNdOycnUtLdkgYcoBsN5y8ImnDDsnwrEWZOy1e+J4xSe7mA3Q==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.46':
|
||||
resolution: {integrity: sha512-8p7l4c3LU+eA5g9Et1JPhNeMC1oQwXTGU+uah8DPIBX7YXzqswvaBtyKVmXefVGi/DJU1x3YJsc3mbAp9aWzSQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core-win32-ia32-msvc@1.15.46':
|
||||
resolution: {integrity: sha512-tUEnfr3Bn9u6FOjUb3PN9p+09qZC2j+wNDLKHzXXZn22rqGcUqR/ohCRSS+nG9B9+X+U+3FewNEHJkTmdIvMjQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core-win32-x64-msvc@1.15.46':
|
||||
resolution: {integrity: sha512-Vux7UDzBJYQggSuPfcl2w9iu+IJpgpRCxHzgCaVkELnAXAE4XZMOTX9HNcaNiwfeIDqdu2rkr69RuDm6wY8neA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core@1.15.46':
|
||||
resolution: {integrity: sha512-Ri3em2mBpq3h2zSPliCYl63otDGqek8PPEfv2nWgRQEbZ/VBCNyypVTVQ6cEbTCXBhy+WE2T3fQb08moIyuYaw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@swc/helpers': '>=0.5.17'
|
||||
peerDependenciesMeta:
|
||||
'@swc/helpers':
|
||||
optional: true
|
||||
|
||||
'@swc/counter@0.1.3':
|
||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||
|
||||
'@swc/types@0.1.27':
|
||||
resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
|
||||
|
||||
'@szmarczak/http-timer@4.0.6':
|
||||
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3461,6 +3662,13 @@ packages:
|
||||
character-reference-invalid@2.0.1:
|
||||
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
|
||||
|
||||
chardet@2.2.0:
|
||||
resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==}
|
||||
|
||||
chokidar@5.0.0:
|
||||
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
chownr@3.0.0:
|
||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4316,6 +4524,10 @@ packages:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
glob@13.0.6:
|
||||
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
@@ -4473,6 +4685,15 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
i18next-cli@1.65.0:
|
||||
resolution: {integrity: sha512-sak+2Ry4P7wtl7xMAZg2sWG2vup1lRHFBKA7h5IeEqFUog51QEgeYUhHxd2x85+MvS4BhVOZZs833clnd1WgYA==}
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
|
||||
i18next-resources-for-ts@2.1.0:
|
||||
resolution: {integrity: sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow==}
|
||||
hasBin: true
|
||||
|
||||
i18next@26.3.1:
|
||||
resolution: {integrity: sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==}
|
||||
peerDependencies:
|
||||
@@ -4515,6 +4736,15 @@ packages:
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
inquirer@14.0.2:
|
||||
resolution: {integrity: sha512-VsSx1JneSNp3ld1veMTLe+UDcUD8Tw2/jjOthhkX3/IX2q+xHhVELifeb/hsb1fBw31pabEPNUf/xUOyb+KZjA==}
|
||||
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
inspect-webkit@0.0.5:
|
||||
resolution: {integrity: sha512-584wP/2nJO1LX74nqHP2j0tQzlK9ZTi+D0Z9qeLQjtUR/LCMXQHGX8M0vrsqBwTeGakF7q5GMFpg81nxUYlCvw==}
|
||||
hasBin: true
|
||||
@@ -5390,6 +5620,10 @@ packages:
|
||||
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
path-scurry@2.0.2:
|
||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
path-to-regexp@6.3.0:
|
||||
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||
|
||||
@@ -5693,6 +5927,10 @@ packages:
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
readdirp@5.0.0:
|
||||
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
recast@0.23.11:
|
||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -5856,6 +6094,10 @@ packages:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
run-async@4.0.6:
|
||||
resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
@@ -6543,6 +6785,11 @@ packages:
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yaml@2.9.0:
|
||||
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6819,6 +7066,12 @@ snapshots:
|
||||
|
||||
'@chevrotain/types@11.1.2': {}
|
||||
|
||||
'@croct/json5-parser@0.2.2':
|
||||
dependencies:
|
||||
'@croct/json': 2.1.0
|
||||
|
||||
'@croct/json@2.1.0': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
@@ -7094,8 +7347,16 @@ snapshots:
|
||||
'@iconify/types': 2.0.0
|
||||
mlly: 1.8.2
|
||||
|
||||
'@inquirer/ansi@2.0.7':
|
||||
optional: true
|
||||
'@inquirer/ansi@2.0.7': {}
|
||||
|
||||
'@inquirer/checkbox@5.2.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/confirm@6.1.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
@@ -7103,7 +7364,6 @@ snapshots:
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@11.2.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
@@ -7116,15 +7376,95 @@ snapshots:
|
||||
signal-exit: 4.1.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
optional: true
|
||||
|
||||
'@inquirer/figures@2.0.7':
|
||||
optional: true
|
||||
'@inquirer/editor@5.2.2(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/external-editor': 3.0.3(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/expand@5.1.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/external-editor@3.0.3(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
chardet: 2.2.0
|
||||
iconv-lite: 0.7.2
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/figures@2.0.7': {}
|
||||
|
||||
'@inquirer/input@5.1.2(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/number@4.1.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/password@5.1.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/prompts@8.5.2(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/checkbox': 5.2.1(@types/node@25.9.5)
|
||||
'@inquirer/confirm': 6.1.1(@types/node@25.9.5)
|
||||
'@inquirer/editor': 5.2.2(@types/node@25.9.5)
|
||||
'@inquirer/expand': 5.1.1(@types/node@25.9.5)
|
||||
'@inquirer/input': 5.1.2(@types/node@25.9.5)
|
||||
'@inquirer/number': 4.1.1(@types/node@25.9.5)
|
||||
'@inquirer/password': 5.1.1(@types/node@25.9.5)
|
||||
'@inquirer/rawlist': 5.3.1(@types/node@25.9.5)
|
||||
'@inquirer/search': 4.2.1(@types/node@25.9.5)
|
||||
'@inquirer/select': 5.2.1(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/rawlist@5.3.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/search@4.2.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/select@5.2.1(@types/node@25.9.5)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/figures': 2.0.7
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
'@inquirer/type@4.0.7(@types/node@25.9.5)':
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
optional: true
|
||||
|
||||
'@isaacs/fs-minipass@4.0.1':
|
||||
dependencies:
|
||||
@@ -8446,6 +8786,66 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-darwin-x64@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm-gnueabihf@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm64-gnu@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-ia32-msvc@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-x64-msvc@1.15.46':
|
||||
optional: true
|
||||
|
||||
'@swc/core@1.15.46':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/types': 0.1.27
|
||||
optionalDependencies:
|
||||
'@swc/core-darwin-arm64': 1.15.46
|
||||
'@swc/core-darwin-x64': 1.15.46
|
||||
'@swc/core-linux-arm-gnueabihf': 1.15.46
|
||||
'@swc/core-linux-arm64-gnu': 1.15.46
|
||||
'@swc/core-linux-arm64-musl': 1.15.46
|
||||
'@swc/core-linux-ppc64-gnu': 1.15.46
|
||||
'@swc/core-linux-s390x-gnu': 1.15.46
|
||||
'@swc/core-linux-x64-gnu': 1.15.46
|
||||
'@swc/core-linux-x64-musl': 1.15.46
|
||||
'@swc/core-win32-arm64-msvc': 1.15.46
|
||||
'@swc/core-win32-ia32-msvc': 1.15.46
|
||||
'@swc/core-win32-x64-msvc': 1.15.46
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
||||
'@swc/types@0.1.27':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
|
||||
'@szmarczak/http-timer@4.0.6':
|
||||
dependencies:
|
||||
defer-to-connect: 2.0.1
|
||||
@@ -9179,39 +9579,39 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
lru-cache: 11.5.1
|
||||
opentype.js: 2.0.0
|
||||
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
@@ -9487,6 +9887,12 @@ snapshots:
|
||||
|
||||
character-reference-invalid@2.0.1: {}
|
||||
|
||||
chardet@2.2.0: {}
|
||||
|
||||
chokidar@5.0.0:
|
||||
dependencies:
|
||||
readdirp: 5.0.0
|
||||
|
||||
chownr@3.0.0: {}
|
||||
|
||||
chromium-pickle-js@0.2.0: {}
|
||||
@@ -9512,8 +9918,7 @@ snapshots:
|
||||
slice-ansi: 8.0.0
|
||||
string-width: 8.2.1
|
||||
|
||||
cli-width@4.1.0:
|
||||
optional: true
|
||||
cli-width@4.1.0: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
@@ -10002,7 +10407,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron-vite@5.0.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)):
|
||||
electron-vite@5.0.0(@swc/core@1.15.46)(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7)
|
||||
@@ -10011,6 +10416,8 @@ snapshots:
|
||||
magic-string: 0.30.21
|
||||
picocolors: 1.1.1
|
||||
vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)
|
||||
optionalDependencies:
|
||||
'@swc/core': 1.15.46
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10252,20 +10659,17 @@ snapshots:
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
fast-string-truncated-width@3.0.3:
|
||||
optional: true
|
||||
fast-string-truncated-width@3.0.3: {}
|
||||
|
||||
fast-string-width@3.0.2:
|
||||
dependencies:
|
||||
fast-string-truncated-width: 3.0.3
|
||||
optional: true
|
||||
|
||||
fast-uri@3.1.4: {}
|
||||
|
||||
fast-wrap-ansi@0.2.2:
|
||||
dependencies:
|
||||
fast-string-width: 3.0.2
|
||||
optional: true
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
@@ -10417,6 +10821,12 @@ snapshots:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
glob@13.0.6:
|
||||
dependencies:
|
||||
minimatch: 10.2.5
|
||||
minipass: 7.1.3
|
||||
path-scurry: 2.0.2
|
||||
|
||||
glob@7.2.3:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
@@ -10672,6 +11082,41 @@ snapshots:
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
i18next-cli@1.65.0(@types/node@25.9.5)(react-dom@19.2.7(react@19.2.7))(typescript@7.0.2):
|
||||
dependencies:
|
||||
'@croct/json5-parser': 0.2.2
|
||||
'@swc/core': 1.15.46
|
||||
chokidar: 5.0.0
|
||||
commander: 14.0.3
|
||||
execa: 9.6.1
|
||||
glob: 13.0.6
|
||||
i18next: 26.3.1(typescript@7.0.2)
|
||||
i18next-resources-for-ts: 2.1.0
|
||||
inquirer: 14.0.2(@types/node@25.9.5)
|
||||
jiti: 2.7.0
|
||||
jsonc-parser: 3.3.1
|
||||
magic-string: 0.30.21
|
||||
minimatch: 10.2.5
|
||||
ora: 9.4.0
|
||||
react: 19.2.7
|
||||
react-i18next: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)
|
||||
yaml: 2.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
- '@types/node'
|
||||
- react-dom
|
||||
- react-native
|
||||
- typescript
|
||||
|
||||
i18next-resources-for-ts@2.1.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@swc/core': 1.15.46
|
||||
chokidar: 5.0.0
|
||||
yaml: 2.9.0
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
|
||||
i18next@26.3.1(typescript@7.0.2):
|
||||
optionalDependencies:
|
||||
typescript: 7.0.2
|
||||
@@ -10704,6 +11149,17 @@ snapshots:
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
inquirer@14.0.2(@types/node@25.9.5):
|
||||
dependencies:
|
||||
'@inquirer/ansi': 2.0.7
|
||||
'@inquirer/core': 11.2.1(@types/node@25.9.5)
|
||||
'@inquirer/prompts': 8.5.2(@types/node@25.9.5)
|
||||
'@inquirer/type': 4.0.7(@types/node@25.9.5)
|
||||
mute-stream: 3.0.0
|
||||
run-async: 4.0.6
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.5
|
||||
|
||||
inspect-webkit@0.0.5(typescript@7.0.2):
|
||||
dependencies:
|
||||
typescript: 7.0.2
|
||||
@@ -11517,8 +11973,7 @@ snapshots:
|
||||
- '@types/node'
|
||||
optional: true
|
||||
|
||||
mute-stream@3.0.0:
|
||||
optional: true
|
||||
mute-stream@3.0.0: {}
|
||||
|
||||
nan@2.26.2:
|
||||
optional: true
|
||||
@@ -11794,6 +12249,11 @@ snapshots:
|
||||
|
||||
path-key@4.0.0: {}
|
||||
|
||||
path-scurry@2.0.2:
|
||||
dependencies:
|
||||
lru-cache: 11.5.1
|
||||
minipass: 7.1.3
|
||||
|
||||
path-to-regexp@6.3.0:
|
||||
optional: true
|
||||
|
||||
@@ -12177,6 +12637,8 @@ snapshots:
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
|
||||
recast@0.23.11:
|
||||
dependencies:
|
||||
ast-types: 0.16.1
|
||||
@@ -12396,6 +12858,8 @@ snapshots:
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
run-async@4.0.6: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
@@ -13082,6 +13546,8 @@ snapshots:
|
||||
|
||||
yaml@2.8.4: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -53,36 +53,49 @@ Do not use orchestration merely because the user says "hand off", "handoff", "ha
|
||||
|
||||
## Contract Migration
|
||||
|
||||
Orca uses a hard cutover for orchestration mutations. It does not run a legacy scheduler, translate old writes, or drain pre-upgrade orchestration work.
|
||||
Orca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.
|
||||
|
||||
If a command returns `orchestration_migration_required`, `run_required`, or a lifecycle validation error with `nextCommandArgs`:
|
||||
Treat the authority label on injected or formatted messages as definitive:
|
||||
|
||||
1. Confirm `effectsApplied` is `false`.
|
||||
2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`.
|
||||
3. Read the guide completely. Do not retry the rejected command unchanged.
|
||||
4. Inspect the pre-upgrade Run, terminal, and assigned worktree before deciding whether any work needs replacement.
|
||||
5. If the legacy worker is still making valid progress, leave it as the only editor in that worktree and observe it manually, read-only, until it reaches a stable handoff point.
|
||||
6. Only then, if remaining work needs new lifecycle supervision, create or bind a lightweight Run, create a Task for the remaining work, and use `worker-start` in a conflict-free placement.
|
||||
- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.
|
||||
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.
|
||||
- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.
|
||||
- An unlabeled current message uses the current guide and current grammar.
|
||||
|
||||
The arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command.
|
||||
Database provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.
|
||||
|
||||
The cutover removes lifecycle authority; it does not cancel the prior assignment, invalidate its worktree, discard filesystem changes, or stop the worker process. Pre-upgrade terminals and agents can continue their valid assigned work, but they are no longer supervised by Orca: old heartbeat, question, completion, scheduler, reply, acknowledgment, and mutation calls are rejected before effects.
|
||||
Compatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.
|
||||
|
||||
Legacy database rows and terminal output remain available for explicit read-only inspection:
|
||||
When a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.
|
||||
|
||||
On packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume <message_id>` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.
|
||||
|
||||
Legacy inspection remains available without consuming mail:
|
||||
|
||||
```bash
|
||||
orca orchestration run-list --json
|
||||
# run_legacy_local is an empty audit tombstone after adoption.
|
||||
orca orchestration run-show --id run_legacy_local --json
|
||||
orca orchestration task-list --run run_legacy_local --json
|
||||
# In run-list, find the ordinary Run whose objective is:
|
||||
# "Recovered orchestration work from a contract update"
|
||||
orca orchestration run-show --id <adopted_run_id> --json
|
||||
orca orchestration task-list --run <adopted_run_id> --json
|
||||
orca orchestration inbox --full --json
|
||||
orca orchestration check --terminal <legacy_handle> --peek --format --json
|
||||
orca terminal read --terminal <legacy_handle> --json
|
||||
orca terminal wait --terminal <legacy_handle> --for tui-idle --timeout-ms 60000 --json
|
||||
```
|
||||
|
||||
Read-only inspection never consumes legacy mail. A stable handoff point means the worker has become idle, stopped, or completed a coherent edit/test/commit checkpoint; visible activity is a reason to keep observing, not to replace it. Do not prompt the worker to use old lifecycle commands.
|
||||
If the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:
|
||||
|
||||
Never launch a replacement editor in the same worktree while the legacy worker may still write there. Wait for a stable handoff and preserve its filesystem work; if overlap is truly required, use a separate conflict-free worktree with an explicit plan for transferring existing dirty changes. Do not use actionable `check`, acknowledgment, reply, send, retry, or task updates against the legacy Run.
|
||||
```bash
|
||||
orca orchestration run-use --id <adopted_run_id> --takeover-legacy --json
|
||||
orca orchestration check --run <adopted_run_id> --json
|
||||
```
|
||||
|
||||
Takeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.
|
||||
|
||||
Do not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.
|
||||
|
||||
## Ownership
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@ import {
|
||||
} from '../runtime-client'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import { getDefaultPersistedState } from '../../shared/constants'
|
||||
import type { PersistedState } from '../../shared/types'
|
||||
import type { GlobalSettings, PersistedState } from '../../shared/types'
|
||||
import {
|
||||
applyAgentStatusHooksEnabled,
|
||||
getManagedAgentHookStatuses
|
||||
@@ -75,7 +75,10 @@ function readEnabledFromDisk(): boolean {
|
||||
return state.settings?.agentStatusHooksEnabled !== false
|
||||
}
|
||||
|
||||
function updateEnabledOnDisk(enabled: boolean): string {
|
||||
function updateEnabledOnDisk(enabled: boolean): {
|
||||
settingsPath: string
|
||||
settings: Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
|
||||
} {
|
||||
const dataPath = getDataPath()
|
||||
const state = readPersistedState(dataPath)
|
||||
state.settings = {
|
||||
@@ -84,7 +87,13 @@ function updateEnabledOnDisk(enabled: boolean): string {
|
||||
agentStatusHooksEnabled: enabled
|
||||
}
|
||||
writePersistedState(dataPath, state)
|
||||
return dataPath
|
||||
return {
|
||||
settingsPath: dataPath,
|
||||
settings: {
|
||||
agentCmdOverrides: state.settings.agentCmdOverrides ?? {},
|
||||
disabledTuiAgents: state.settings.disabledTuiAgents ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise<boolean> {
|
||||
@@ -134,10 +143,11 @@ async function setAgentHooksEnabled(
|
||||
enabled: boolean
|
||||
): Promise<AgentHookCommandResult> {
|
||||
const updatedRuntime = await updateRunningRuntime(client, enabled)
|
||||
const settingsPath = updatedRuntime ? getDataPath() : updateEnabledOnDisk(enabled)
|
||||
const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled)
|
||||
const settingsPath = offlineUpdate?.settingsPath ?? getDataPath()
|
||||
const statuses = updatedRuntime
|
||||
? getManagedAgentHookStatuses()
|
||||
: applyAgentStatusHooksEnabled(enabled)
|
||||
: await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings)
|
||||
return {
|
||||
enabled,
|
||||
settingsPath,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: [] }))
|
||||
|
||||
|
||||
@@ -71,4 +71,24 @@ describe('orchestration check identity', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the pinned legacy --inject check signature', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_legacy_worker'
|
||||
|
||||
await invokeCheck(
|
||||
new Map<string, string | boolean>([
|
||||
['unread', true],
|
||||
['inject', true]
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.check',
|
||||
expect.objectContaining({
|
||||
terminal: 'term_legacy_worker',
|
||||
unread: true,
|
||||
inject: true
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -179,6 +179,7 @@ describe('legacy orchestration CLI inspection', () => {
|
||||
id: 'msg_current',
|
||||
run_id: 'run_current',
|
||||
from_handle: 'term_current',
|
||||
to_handle: 'run:run_current',
|
||||
subject: 'current question',
|
||||
type: 'question',
|
||||
body: 'May I continue?',
|
||||
@@ -217,7 +218,8 @@ describe('legacy orchestration CLI inspection', () => {
|
||||
expect(response.result.formatted).not.toContain('RUNTIME_SENTINEL')
|
||||
expect(
|
||||
response.result.formatted.split('\n').filter((line) => line.startsWith('[Reply:'))
|
||||
).toEqual(['[Reply: orca orchestration reply --id msg_current --from term_coord --body "..."]'])
|
||||
).toEqual(['[Reply: orca orchestration reply --id msg_current --body "..."]'])
|
||||
expect(response.result.formatted).not.toContain('--from run:run_current')
|
||||
})
|
||||
|
||||
it('preserves runtime formatting when every message belongs to a current Run', async () => {
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('orchestration CLI migration recovery', () => {
|
||||
it('redirects worker_done without an outcome before resolving or calling the runtime', async () => {
|
||||
const call = vi.fn()
|
||||
const originalPaneKey = process.env.ORCA_PANE_KEY
|
||||
|
||||
await expect(
|
||||
ORCHESTRATION_HANDLERS['orchestration send']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['from', 'term_worker'],
|
||||
['subject', 'Done'],
|
||||
['type', 'worker_done']
|
||||
]),
|
||||
client: { call },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
data: {
|
||||
effectsApplied: false,
|
||||
nextCommandArgs: ['skills', 'get', 'orchestration', '--full']
|
||||
}
|
||||
afterEach(() => {
|
||||
if (originalPaneKey === undefined) {
|
||||
delete process.env.ORCA_PANE_KEY
|
||||
} else {
|
||||
process.env.ORCA_PANE_KEY = originalPaneKey
|
||||
}
|
||||
})
|
||||
|
||||
describe('orchestration CLI migration recovery', () => {
|
||||
it('forwards legacy worker_done without inventing a completion outcome', async () => {
|
||||
process.env.ORCA_PANE_KEY = 'tab-worker:leaf-worker'
|
||||
const call = vi.fn().mockResolvedValue({ result: { message: { id: 'msg_done' } } })
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration send']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['from', 'term_worker'],
|
||||
['subject', 'Done'],
|
||||
['type', 'worker_done']
|
||||
]),
|
||||
client: { call },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(call).toHaveBeenCalledWith('orchestration.send', {
|
||||
from: 'term_worker',
|
||||
to: undefined,
|
||||
run: undefined,
|
||||
subject: 'Done',
|
||||
body: undefined,
|
||||
type: 'worker_done',
|
||||
priority: undefined,
|
||||
threadId: undefined,
|
||||
payload: undefined,
|
||||
senderPaneKey: 'tab-worker:leaf-worker',
|
||||
devMode: false
|
||||
})
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,63 @@ describe('lightweight Run CLI handlers', () => {
|
||||
from: 'term_coord'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes Run pagination flags to the runtime', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { runs: [], nextCursor: null }
|
||||
})
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-list']({
|
||||
flags: new Map([
|
||||
['limit', '25'],
|
||||
['cursor', 'next-page']
|
||||
]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.runList', {
|
||||
limit: 25,
|
||||
cursor: 'next-page'
|
||||
})
|
||||
})
|
||||
|
||||
it('opts into bounded Run pagination by default', async () => {
|
||||
callMock.mockResolvedValue({ result: { runs: [], nextCursor: null } })
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-list']({
|
||||
flags: new Map(),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.runList', {
|
||||
limit: 100,
|
||||
cursor: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('passes explicit legacy takeover only when requested', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { run: { id: 'run_adopted', objective: 'Recovered work' } }
|
||||
})
|
||||
await ORCHESTRATION_HANDLERS['orchestration run-use']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['id', 'run_adopted'],
|
||||
['from', 'term_current'],
|
||||
['takeover-legacy', true]
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.runUse', {
|
||||
id: 'run_adopted',
|
||||
from: 'term_current',
|
||||
takeoverLegacy: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration reset CLI handler', () => {
|
||||
|
||||
@@ -64,11 +64,13 @@ describe('orchestration timeout flag validation', () => {
|
||||
// Why: unread:false makes pre-peek runtimes fall back to non-consuming all mode.
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
|
||||
terminal: 'term_worker',
|
||||
terminalPaneKey: undefined,
|
||||
unread: false,
|
||||
peek: true,
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
format: undefined,
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
@@ -182,7 +184,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
@@ -209,7 +213,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: 'msg_question',
|
||||
options: undefined,
|
||||
timeoutMs: undefined,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 605_000, orchestrationCapability: undefined }
|
||||
)
|
||||
|
||||
@@ -86,11 +86,15 @@ describe('orchestration timeout flag validation', () => {
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
|
||||
terminal: 'term_worker',
|
||||
terminalPaneKey: undefined,
|
||||
unread: false,
|
||||
peek: true,
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
inject: undefined,
|
||||
format: undefined,
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
timeoutMs: 250
|
||||
})
|
||||
@@ -273,12 +277,16 @@ describe('orchestration timeout flag validation', () => {
|
||||
'orchestration.ask',
|
||||
{
|
||||
to: 'term_coord',
|
||||
run: undefined,
|
||||
question: 'Proceed?',
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 5_123 }
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const originalCliCommand = process.env.ORCA_CLI_COMMAND
|
||||
const originalPackagedLauncher = process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
const originalExitCode = process.exitCode
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
describe('packaged Windows legacy ask protocol', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER = '1'
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = originalExitCode
|
||||
restoreEnv('ORCA_CLI_COMMAND', originalCliCommand)
|
||||
restoreEnv('ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER', originalPackagedLauncher)
|
||||
restoreEnv('ORCA_TERMINAL_HANDLE', originalTerminalHandle)
|
||||
})
|
||||
|
||||
it.each(['orca', 'orca-ide'] as const)(
|
||||
'commits with the %s launcher and exits 75 before resume',
|
||||
async (command) => {
|
||||
process.env.ORCA_CLI_COMMAND = command
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: null,
|
||||
messageId: 'msg_question',
|
||||
threadId: 'msg_question',
|
||||
timedOut: false,
|
||||
legacyCompatibility: {
|
||||
resumeRequired: true,
|
||||
resumeCommand: `${command} orchestration ask --resume msg_question`
|
||||
}
|
||||
}
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await invokeAsk(new Map([['question', 'Proceed?']]))
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.ask',
|
||||
expect.objectContaining({
|
||||
question: 'Proceed?',
|
||||
resume: undefined,
|
||||
compatibilityWindowsCommand: command
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(log.mock.calls.map(([line]) => line)).toEqual([
|
||||
'Question msg_question committed.',
|
||||
`Resume with: ${command} orchestration ask --resume msg_question`
|
||||
])
|
||||
expect(process.exitCode).toBe(75)
|
||||
}
|
||||
)
|
||||
|
||||
it('resumes the committed question without another exit-75 handoff', async () => {
|
||||
process.env.ORCA_CLI_COMMAND = 'orca'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: 'yes',
|
||||
messageId: 'msg_question',
|
||||
threadId: 'msg_question',
|
||||
timedOut: false
|
||||
}
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await invokeAsk(new Map([['resume', 'msg_question']]))
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.ask',
|
||||
expect.objectContaining({
|
||||
question: undefined,
|
||||
resume: 'msg_question',
|
||||
compatibilityWindowsCommand: 'orca'
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(log).toHaveBeenCalledWith('yes')
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
function invokeAsk(flags: Map<string, string | boolean>): Promise<void> {
|
||||
return ORCHESTRATION_HANDLERS['orchestration ask']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name]
|
||||
} else {
|
||||
process.env[name] = value
|
||||
}
|
||||
}
|
||||
@@ -692,6 +692,7 @@ describe('orchestration timeout flag validation', () => {
|
||||
all: undefined,
|
||||
types: undefined,
|
||||
format: undefined,
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
run: undefined,
|
||||
ack: undefined,
|
||||
wait: true,
|
||||
@@ -819,7 +820,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: undefined,
|
||||
options: undefined,
|
||||
timeoutMs: 123,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 5_123, orchestrationCapability: undefined }
|
||||
)
|
||||
@@ -848,7 +851,9 @@ describe('orchestration timeout flag validation', () => {
|
||||
resume: 'msg_question',
|
||||
options: undefined,
|
||||
timeoutMs: undefined,
|
||||
from: 'term_worker'
|
||||
from: 'term_worker',
|
||||
compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/),
|
||||
compatibilityWindowsCommand: undefined
|
||||
},
|
||||
{ timeoutMs: 605_000, orchestrationCapability: undefined }
|
||||
)
|
||||
|
||||
+103
-131
@@ -21,11 +21,15 @@ import type {
|
||||
} from '../../shared/orchestration-worker-output'
|
||||
import type { NativeChatMessage } from '../../shared/native-chat-types'
|
||||
import type { RuntimeTerminalRead } from '../../shared/runtime-types'
|
||||
import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract'
|
||||
import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../shared/orchestration-run-pagination'
|
||||
import {
|
||||
ORCHESTRATION_LEGACY_RUN_ID,
|
||||
orchestrationMigrationData,
|
||||
orchestrationSkillRecoveryData
|
||||
} from '../../shared/orchestration-rpc-contract'
|
||||
formatMessageReadOnlyTag,
|
||||
formatOrchestrationCheckText,
|
||||
prepareOrchestrationCheckOutput,
|
||||
type LegacyCompatibilityResult,
|
||||
type OrchestrationMessageSummary as MessageSummary
|
||||
} from '../../shared/orchestration-check-output'
|
||||
|
||||
// Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4.
|
||||
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
|
||||
@@ -75,81 +79,6 @@ const TASK_STATUS_VALUES = [
|
||||
'blocked'
|
||||
] as const
|
||||
|
||||
type MessageSummary = {
|
||||
id: string
|
||||
run_id?: string
|
||||
from_handle: string
|
||||
to_handle?: string
|
||||
subject?: string
|
||||
type?: string
|
||||
body?: string
|
||||
payload?: string | null
|
||||
priority?: string
|
||||
read?: number
|
||||
}
|
||||
|
||||
function formatMessageReadOnlyTag(message: MessageSummary): string {
|
||||
return message.run_id === ORCHESTRATION_LEGACY_RUN_ID ? ' [legacy, read-only]' : ''
|
||||
}
|
||||
|
||||
function isLegacyReadOnlyMessage(message: MessageSummary): boolean {
|
||||
return message.run_id === ORCHESTRATION_LEGACY_RUN_ID
|
||||
}
|
||||
|
||||
function formatMessagePriorityTag(message: MessageSummary): string {
|
||||
return message.priority === 'urgent' ? ' [URGENT]' : message.priority === 'high' ? ' [HIGH]' : ''
|
||||
}
|
||||
|
||||
function escapeTerminalControlCharacters(value: string): string {
|
||||
return [...value]
|
||||
.map((character) => {
|
||||
const code = character.charCodeAt(0)
|
||||
if (character === '\n' || (code >= 0x20 && code < 0x7f) || code > 0x9f) {
|
||||
return character
|
||||
}
|
||||
return `\\x${code.toString(16).padStart(2, '0')}`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function formatQuotedMessageField(label: string, value?: string): string {
|
||||
return `[${label}]\n${escapeTerminalControlCharacters(value ?? '')
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')}`
|
||||
}
|
||||
|
||||
function formatLegacyAwareCheckMessages(
|
||||
messages: MessageSummary[],
|
||||
checkedTerminal: string
|
||||
): string {
|
||||
return messages
|
||||
.map((message) => {
|
||||
const legacyReadOnly = isLegacyReadOnlyMessage(message)
|
||||
const lines = [
|
||||
`${message.id}${formatMessageReadOnlyTag(message)}${formatMessagePriorityTag(message)} [${message.type ?? 'status'}] from=${message.from_handle}`,
|
||||
formatQuotedMessageField('subject', message.subject)
|
||||
]
|
||||
if (legacyReadOnly) {
|
||||
lines.push('[Inspection only: reply and acknowledgment are unavailable.]')
|
||||
}
|
||||
if (message.body) {
|
||||
lines.push(formatQuotedMessageField('body', message.body))
|
||||
}
|
||||
if (message.payload) {
|
||||
lines.push(formatQuotedMessageField('payload', message.payload))
|
||||
}
|
||||
if (!legacyReadOnly) {
|
||||
const replyFrom = message.to_handle ?? checkedTerminal
|
||||
lines.push(
|
||||
`[Reply: orca orchestration reply --id ${message.id} --from ${replyFrom} --body "..."]`
|
||||
)
|
||||
}
|
||||
return lines.join('\n')
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
type LifecycleSendRejection = {
|
||||
action: 'rejected'
|
||||
code: string
|
||||
@@ -170,6 +99,40 @@ type OrchestrationSendResult =
|
||||
lifecycle?: { action: 'completed' | 'failed' }
|
||||
}
|
||||
|
||||
function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' {
|
||||
const configured = process.env.ORCA_CLI_COMMAND
|
||||
if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') {
|
||||
return configured
|
||||
}
|
||||
return process.platform === 'linux' ? 'orca-ide' : 'orca'
|
||||
}
|
||||
|
||||
function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined {
|
||||
if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') {
|
||||
return undefined
|
||||
}
|
||||
const command = process.env.ORCA_CLI_COMMAND
|
||||
if (command === 'orca' || command === 'orca-ide') {
|
||||
return command
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'The packaged Orca launcher did not provide a valid resume command. No question was created.'
|
||||
)
|
||||
}
|
||||
|
||||
async function flushStdout(): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write('', (error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getOptionalStructuredMessagePayload(
|
||||
flags: Map<string, string | boolean>
|
||||
): string | undefined {
|
||||
@@ -485,7 +448,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
run: { id: string; objective: string; consumer_generation: number }
|
||||
}>(client, flags, 'orchestration.runUse', {
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
from
|
||||
from,
|
||||
...(flags.has('takeover-legacy') ? { takeoverLegacy: true } : {})
|
||||
})
|
||||
printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`)
|
||||
},
|
||||
@@ -500,19 +464,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
)
|
||||
},
|
||||
|
||||
'orchestration run-list': async ({ client, json }) => {
|
||||
'orchestration run-list': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{
|
||||
runs: { id: string; objective: string; legacy: number }[]
|
||||
}>('orchestration.runList', {})
|
||||
printResult(result, json, (r) =>
|
||||
r.runs.length === 0
|
||||
? 'No Runs found.'
|
||||
: r.runs
|
||||
.map(
|
||||
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
|
||||
)
|
||||
.join('\n')
|
||||
)
|
||||
nextCursor: string | null
|
||||
}>('orchestration.runList', {
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit') ?? ORCHESTRATION_RUN_PAGE_LIMIT,
|
||||
cursor: getOptionalStringFlag(flags, 'cursor')
|
||||
})
|
||||
printResult(result, json, (r) => {
|
||||
const rows =
|
||||
r.runs.length === 0
|
||||
? 'No Runs found.'
|
||||
: r.runs
|
||||
.map(
|
||||
(run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}`
|
||||
)
|
||||
.join('\n')
|
||||
return r.nextCursor ? `${rows}\nMore Runs: --cursor ${r.nextCursor}` : rows
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration run-show': async ({ flags, client, json }) => {
|
||||
@@ -541,13 +511,6 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
rejectLifecycleGroupRecipient(type, to)
|
||||
}
|
||||
const outcome = getOptionalStringFlag(flags, 'outcome')
|
||||
if (type === 'worker_done' && outcome === undefined && !flags.has('payload')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'worker_done requires --outcome succeeded or --outcome failed. No effects were applied.',
|
||||
orchestrationSkillRecoveryData()
|
||||
)
|
||||
}
|
||||
if (type !== 'worker_done' && outcome !== undefined) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
@@ -634,6 +597,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
timedOut?: boolean
|
||||
cancelled?: boolean
|
||||
connectionLost?: boolean
|
||||
legacyCompatibility?: LegacyCompatibilityResult
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof client.call<CheckResult>>>
|
||||
try {
|
||||
@@ -646,6 +610,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
all: flags.has('all') ? true : undefined,
|
||||
types: getOptionalStringFlag(flags, 'types'),
|
||||
format: flags.has('format') ? true : undefined,
|
||||
inject: flags.has('inject') ? true : undefined,
|
||||
compatibilityCliCommand: resolveCompatibilityCliCommand(),
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
ack: getOptionalStringFlag(flags, 'ack'),
|
||||
wait: wait ? true : undefined,
|
||||
@@ -682,39 +648,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (flags.has('format') && result.result.messages.some(isLegacyReadOnlyMessage)) {
|
||||
// Why: formatted is one opaque batch with untrusted bodies, so selective banner parsing cannot safely remove legacy actions.
|
||||
result = {
|
||||
...result,
|
||||
result: {
|
||||
...result.result,
|
||||
formatted: formatLegacyAwareCheckMessages(result.result.messages, terminal)
|
||||
}
|
||||
}
|
||||
result = {
|
||||
...result,
|
||||
result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format'))
|
||||
}
|
||||
printResult(result, json, (r) => formatOrchestrationCheckText(r, terminal))
|
||||
const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds
|
||||
if (compatibilityAck && compatibilityAck.length > 0) {
|
||||
await flushStdout()
|
||||
await client.call('orchestration.check', {
|
||||
terminal,
|
||||
compatibilityAck: JSON.stringify({
|
||||
messageIds: compatibilityAck,
|
||||
types: getOptionalStringFlag(flags, 'types')
|
||||
?.split(',')
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
})
|
||||
}
|
||||
printResult(result, json, (r) => {
|
||||
if (r.formatted) {
|
||||
return r.formatted
|
||||
}
|
||||
if (r.count === 0) {
|
||||
if (r.timedOut) {
|
||||
return 'Wait timed out; no messages were consumed.'
|
||||
}
|
||||
if (r.cancelled) {
|
||||
return r.connectionLost
|
||||
? 'Wait cancelled because the connection closed; no messages were consumed.'
|
||||
: 'Wait cancelled; no messages were consumed.'
|
||||
}
|
||||
return 'No messages.'
|
||||
}
|
||||
const rendered = r.messages
|
||||
.map(
|
||||
(m) =>
|
||||
`${m.id}${formatMessageReadOnlyTag(m)} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`
|
||||
)
|
||||
.join('\n')
|
||||
return r.deliveryId ? `Delivery ${r.deliveryId}\n${rendered}` : rendered
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration reply': async ({ flags, client, cwd, json }) => {
|
||||
@@ -1034,6 +986,8 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
timeoutMs?: number
|
||||
cancelled?: boolean
|
||||
connectionLost?: boolean
|
||||
answerMessageId?: string | null
|
||||
legacyCompatibility?: LegacyCompatibilityResult
|
||||
}>(
|
||||
client,
|
||||
flags,
|
||||
@@ -1045,7 +999,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
resume,
|
||||
options: getOptionalStringFlag(flags, 'options'),
|
||||
timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs,
|
||||
from
|
||||
from,
|
||||
compatibilityCliCommand: resolveCompatibilityCliCommand(),
|
||||
compatibilityWindowsCommand: resolvePackagedWindowsCompatibilityCommand()
|
||||
},
|
||||
// Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves.
|
||||
{
|
||||
@@ -1056,9 +1012,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
// Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs.
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result.result))
|
||||
} else if (result.result.legacyCompatibility?.resumeRequired) {
|
||||
console.log(`Question ${result.result.messageId} committed.`)
|
||||
console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`)
|
||||
} else if (result.result.answer !== null) {
|
||||
console.log(result.result.answer)
|
||||
}
|
||||
if (result.result.legacyCompatibility?.resumeRequired) {
|
||||
await flushStdout()
|
||||
process.exitCode = 75
|
||||
return
|
||||
}
|
||||
const answerAck = result.result.legacyCompatibility?.answerAcknowledgement
|
||||
if (answerAck && result.result.answer !== null) {
|
||||
await flushStdout()
|
||||
await client.call('orchestration.check', {
|
||||
terminal: from,
|
||||
compatibilityQuestionAck: JSON.stringify(answerAck)
|
||||
})
|
||||
}
|
||||
if (result.result.timedOut) {
|
||||
if (!json) {
|
||||
// Why: report the server's effective budget — it clamps large values, so the requested one would overstate the wait.
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -103,20 +103,40 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => {
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeMetadata(userDataPath, endpoint)
|
||||
|
||||
const priorLaunchToken = process.env.ORCA_AGENT_LAUNCH_TOKEN
|
||||
process.env.ORCA_AGENT_LAUNCH_TOKEN = 'launch-secret'
|
||||
const client = new RuntimeClient(userDataPath, 500)
|
||||
await client.call(
|
||||
'orchestration.send',
|
||||
{ subject: 'hello' },
|
||||
{
|
||||
orchestrationRequestId: 'mutation_explicit'
|
||||
try {
|
||||
await client.call(
|
||||
'orchestration.send',
|
||||
{ subject: 'hello' },
|
||||
{
|
||||
orchestrationRequestId: 'mutation_explicit'
|
||||
}
|
||||
)
|
||||
await client.call('orchestration.taskList', {})
|
||||
const secondClient = new RuntimeClient(userDataPath, 500)
|
||||
await secondClient.call('orchestration.taskList', {})
|
||||
} finally {
|
||||
if (priorLaunchToken === undefined) {
|
||||
delete process.env.ORCA_AGENT_LAUNCH_TOKEN
|
||||
} else {
|
||||
process.env.ORCA_AGENT_LAUNCH_TOKEN = priorLaunchToken
|
||||
}
|
||||
)
|
||||
await client.call('orchestration.taskList', {})
|
||||
}
|
||||
|
||||
expect(requests[0]?.method).toBe('status.get')
|
||||
expect(requests[0]?.compatibilityInvocationId).toBeUndefined()
|
||||
expect(requests[1]?.orchestrationRequestId).toBe('mutation_explicit')
|
||||
expect(requests[1]?.orchestrationContractVersion).toBe(1)
|
||||
expect(requests[1]?.compatibilityInvocationId).toBe('mutation_explicit')
|
||||
expect(requests[1]?.orchestrationCompatibilityEvidence).toMatchObject({
|
||||
launchToken: 'launch-secret'
|
||||
})
|
||||
expect(requests[2]?.orchestrationRequestId).toBeUndefined()
|
||||
expect(requests[2]?.compatibilityInvocationId).not.toBe(requests[1]?.compatibilityInvocationId)
|
||||
expect(requests[3]?.method).toBe('orchestration.taskList')
|
||||
expect(requests[3]?.compatibilityInvocationId).not.toBe(requests[1]?.compatibilityInvocationId)
|
||||
})
|
||||
|
||||
it('rejects an old local runtime before sending an orchestration mutation', async () => {
|
||||
|
||||
+14
-17
@@ -20,6 +20,8 @@ import {
|
||||
ORCHESTRATION_CONTRACT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
|
||||
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
|
||||
|
||||
// Why: for long-poll methods the caller's method-level
|
||||
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
|
||||
@@ -43,6 +45,9 @@ export class RuntimeClient {
|
||||
private readonly environmentSelector: string | null
|
||||
private remoteCompatChecked = false
|
||||
private orchestrationContractCheck: Promise<void> | null = null
|
||||
private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(
|
||||
process.env
|
||||
)
|
||||
|
||||
// Why: browser commands trigger first-time session init (agent-browser connect +
|
||||
// CDP proxy setup) which can take 15-30s. 60s accommodates cold start without
|
||||
@@ -76,12 +81,20 @@ export class RuntimeClient {
|
||||
const orchestrationRequestId = orchestrationMutation
|
||||
? (options?.orchestrationRequestId ?? randomUUID())
|
||||
: undefined
|
||||
const compatibilityEnvelope = method.startsWith('orchestration.')
|
||||
? {
|
||||
...this.orchestrationCompatibility,
|
||||
compatibilityInvocationId:
|
||||
orchestrationRequestId ?? this.orchestrationCompatibility.compatibilityInvocationId
|
||||
}
|
||||
: {}
|
||||
const envelope = {
|
||||
orchestrationCapability: options?.orchestrationCapability,
|
||||
orchestrationContractVersion: method.startsWith('orchestration.')
|
||||
? ORCHESTRATION_CONTRACT_VERSION
|
||||
: undefined,
|
||||
orchestrationRequestId
|
||||
orchestrationRequestId,
|
||||
...compatibilityEnvelope
|
||||
}
|
||||
if (this.remotePairing) {
|
||||
if (method !== 'status.get') {
|
||||
@@ -327,19 +340,3 @@ function resolveRemotePairing(
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isWaitingCheck(params: unknown): boolean {
|
||||
return (
|
||||
typeof params === 'object' &&
|
||||
params !== null &&
|
||||
'wait' in params &&
|
||||
(params as { wait: unknown }).wait === true
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeoutMsParam(params: unknown): unknown {
|
||||
if (typeof params !== 'object' || params === null || !('timeoutMs' in params)) {
|
||||
return undefined
|
||||
}
|
||||
return (params as { timeoutMs?: unknown }).timeoutMs
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
|
||||
export function createOrchestrationCompatibilityEnvelope(
|
||||
env: NodeJS.ProcessEnv
|
||||
): RuntimeOrchestrationEnvelope {
|
||||
return {
|
||||
compatibilityInvocationId: randomUUID(),
|
||||
orchestrationCompatibilityEvidence: readOrchestrationCompatibilityEvidence(env)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function isWaitingCheck(params: unknown): boolean {
|
||||
return (
|
||||
typeof params === 'object' &&
|
||||
params !== null &&
|
||||
'wait' in params &&
|
||||
(params as { wait: unknown }).wait === true
|
||||
)
|
||||
}
|
||||
|
||||
export function getTimeoutMsParam(params: unknown): unknown {
|
||||
if (typeof params !== 'object' || params === null || !('timeoutMs' in params)) {
|
||||
return undefined
|
||||
}
|
||||
return (params as { timeoutMs?: unknown }).timeoutMs
|
||||
}
|
||||
@@ -188,7 +188,9 @@ export async function sendRequest<TResult>(
|
||||
params,
|
||||
orchestrationCapability: envelope?.orchestrationCapability,
|
||||
orchestrationContractVersion: envelope?.orchestrationContractVersion,
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId
|
||||
orchestrationRequestId: envelope?.orchestrationRequestId,
|
||||
compatibilityInvocationId: envelope?.compatibilityInvocationId,
|
||||
orchestrationCompatibilityEvidence: envelope?.orchestrationCompatibilityEvidence
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RuntimeRpcFailureError } from './types'
|
||||
|
||||
describe('RuntimeRpcFailureError compatibility redaction', () => {
|
||||
it('removes compatibility evidence from CLI JSON errors', () => {
|
||||
const error = new RuntimeRpcFailureError({
|
||||
id: 'request-1',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'compatibility_rejected',
|
||||
message: 'Rejected',
|
||||
data: {
|
||||
orchestrationCompatibilityEvidence: {
|
||||
launchToken: 'launch-secret'
|
||||
}
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
expect(error.data).toEqual({
|
||||
orchestrationCompatibilityEvidence: '[redacted]'
|
||||
})
|
||||
expect(JSON.stringify(error.response)).not.toContain('launch-secret')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeRpcFailure } from '../../shared/runtime-rpc-envelope'
|
||||
import { redactOrchestrationCompatibilitySecrets } from '../../shared/orchestration-compatibility-evidence'
|
||||
|
||||
export type {
|
||||
RuntimeRpcFailure,
|
||||
@@ -15,7 +16,7 @@ export class RuntimeClientError extends Error {
|
||||
constructor(code: string, message: string, data?: unknown) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.data = data
|
||||
this.data = redactOrchestrationCompatibilitySecrets(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +26,12 @@ export class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
constructor(response: RuntimeRpcFailure) {
|
||||
// Why: all client errors expose recovery through the same inherited channel.
|
||||
super(response.error.code, response.error.message, response.error.data)
|
||||
this.response = response
|
||||
this.response = {
|
||||
...response,
|
||||
error: {
|
||||
...response.error,
|
||||
...(response.error.data === undefined ? {} : { data: this.data })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +16,12 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['orchestration', 'run-use'],
|
||||
summary: 'Bind this coordinator terminal to an existing Run',
|
||||
usage: 'orca orchestration run-use --id <run_id> [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'retry-request']
|
||||
usage:
|
||||
'orca orchestration run-use --id <run_id> [--from <handle>] [--takeover-legacy] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'takeover-legacy', 'retry-request'],
|
||||
notes: [
|
||||
'--takeover-legacy must run in the live coordinator agent terminal it binds; it preserves existing worker assignments.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-current'],
|
||||
@@ -28,8 +32,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['orchestration', 'run-list'],
|
||||
summary: 'List lightweight orchestration Runs',
|
||||
usage: 'orca orchestration run-list [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS]
|
||||
usage: 'orca orchestration run-list [--limit <n>] [--cursor <cursor>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'cursor']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'run-show'],
|
||||
|
||||
@@ -1,155 +1,39 @@
|
||||
// Pins the contract between `runManagedHookInstallers` and the
|
||||
// `agent_hook_install_failed` telemetry event: each catch must fire `track`
|
||||
// with the correct agent label and a truncated error_message, and one
|
||||
// installer's failure must not stop the others (fail-open semantics).
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() }))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: trackMock }))
|
||||
|
||||
import { runManagedHookInstallers } from './install-telemetry'
|
||||
import { recordManagedHookInstallFailure } from './install-telemetry'
|
||||
|
||||
describe('runManagedHookInstallers', () => {
|
||||
describe('recordManagedHookInstallFailure', () => {
|
||||
beforeEach(() => {
|
||||
trackMock.mockReset()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('runs every installer when none throw and never calls track', () => {
|
||||
const claude = vi.fn()
|
||||
const codex = vi.fn()
|
||||
runManagedHookInstallers([
|
||||
['claude', claude],
|
||||
['codex', codex]
|
||||
])
|
||||
expect(claude).toHaveBeenCalledTimes(1)
|
||||
expect(codex).toHaveBeenCalledTimes(1)
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fires agent_hook_install_failed with the correct agent label when an installer throws', () => {
|
||||
runManagedHookInstallers([
|
||||
[
|
||||
'codex',
|
||||
() => {
|
||||
throw new Error('codex config malformed')
|
||||
}
|
||||
]
|
||||
])
|
||||
it('records the agent and truncated error message', () => {
|
||||
recordManagedHookInstallFailure('codex', new Error('x'.repeat(500)))
|
||||
|
||||
expect(trackMock).toHaveBeenCalledTimes(1)
|
||||
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
|
||||
agent: 'codex',
|
||||
error_message: 'codex config malformed'
|
||||
})
|
||||
})
|
||||
|
||||
it('continues running later installers after an earlier one throws (fail-open)', () => {
|
||||
const codex = vi.fn()
|
||||
const gemini = vi.fn()
|
||||
runManagedHookInstallers([
|
||||
[
|
||||
'claude',
|
||||
() => {
|
||||
throw new Error('claude failed')
|
||||
}
|
||||
],
|
||||
['codex', codex],
|
||||
['gemini', gemini]
|
||||
])
|
||||
expect(codex).toHaveBeenCalledTimes(1)
|
||||
expect(gemini).toHaveBeenCalledTimes(1)
|
||||
expect(trackMock).toHaveBeenCalledTimes(1)
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'agent_hook_install_failed',
|
||||
expect.objectContaining({ agent: 'claude' })
|
||||
)
|
||||
})
|
||||
|
||||
it('truncates error_message to 200 chars', () => {
|
||||
const longMessage = 'x'.repeat(500)
|
||||
runManagedHookInstallers([
|
||||
[
|
||||
'gemini',
|
||||
() => {
|
||||
throw new Error(longMessage)
|
||||
}
|
||||
]
|
||||
])
|
||||
expect(trackMock).toHaveBeenCalledTimes(1)
|
||||
const [, props] = trackMock.mock.calls[0] as [string, { error_message: string }]
|
||||
expect(props.error_message.length).toBe(200)
|
||||
})
|
||||
|
||||
it('handles non-Error throws', () => {
|
||||
runManagedHookInstallers([
|
||||
[
|
||||
'cursor',
|
||||
() => {
|
||||
throw 'cursor string failure'
|
||||
}
|
||||
]
|
||||
])
|
||||
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
|
||||
agent: 'cursor',
|
||||
error_message: 'cursor string failure'
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes thrown objects through JSON.stringify', () => {
|
||||
runManagedHookInstallers([
|
||||
[
|
||||
'cursor',
|
||||
() => {
|
||||
throw { code: 'EACCES', path: '/tmp' }
|
||||
}
|
||||
]
|
||||
])
|
||||
expect(trackMock).toHaveBeenCalledTimes(1)
|
||||
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
|
||||
agent: 'cursor',
|
||||
error_message: '{"code":"EACCES","path":"/tmp"}'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not throw when an installer throws undefined (regression for JSON.stringify undefined return)', () => {
|
||||
expect(() =>
|
||||
runManagedHookInstallers([
|
||||
[
|
||||
'cursor',
|
||||
() => {
|
||||
throw undefined
|
||||
}
|
||||
]
|
||||
])
|
||||
).not.toThrow()
|
||||
expect(trackMock).toHaveBeenCalledTimes(1)
|
||||
const [eventName, props] = trackMock.mock.calls[0] as [string, { error_message: string }]
|
||||
const [eventName, props] = trackMock.mock.calls[0] as [
|
||||
string,
|
||||
{ agent: string; error_message: string }
|
||||
]
|
||||
expect(eventName).toBe('agent_hook_install_failed')
|
||||
expect(typeof props.error_message).toBe('string')
|
||||
expect(props.agent).toBe('codex')
|
||||
expect(props.error_message).toHaveLength(200)
|
||||
})
|
||||
|
||||
it('continues running later installers when track itself throws (telemetry must not break fail-open)', () => {
|
||||
const codex = vi.fn()
|
||||
it('handles non-Error values and telemetry failures', () => {
|
||||
trackMock.mockImplementationOnce(() => {
|
||||
throw new Error('telemetry blew up')
|
||||
throw new Error('telemetry failed')
|
||||
})
|
||||
expect(() =>
|
||||
runManagedHookInstallers([
|
||||
[
|
||||
'claude',
|
||||
() => {
|
||||
throw new Error('claude failed')
|
||||
}
|
||||
],
|
||||
['codex', codex]
|
||||
])
|
||||
).not.toThrow()
|
||||
expect(codex).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(() => recordManagedHookInstallFailure('cursor', { code: 'EACCES' })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
// Per-agent managed-hook installer with fail-open semantics and PostHog
|
||||
// attribution. Lifted out of `src/main/index.ts` so the loop is unit-testable
|
||||
// without standing up the full Electron startup graph — the catch site needs
|
||||
// the agent label to fire `agent_hook_install_failed`, and the previous
|
||||
// closure-style loop lost it.
|
||||
|
||||
import type { HookInstallAgent } from '../../shared/telemetry-events'
|
||||
import { track } from '../telemetry/client'
|
||||
|
||||
// Why: install errors are about config-file shape (malformed JSON, ACL
|
||||
// denial), not user content — but messages can include paths or stack
|
||||
// fragments. The 200-char cap matches `agentHookInstallFailedSchema.error_message`
|
||||
// in `src/shared/telemetry-events.ts`; the validator drops overlength values,
|
||||
// so truncation must happen here at the call site.
|
||||
const ERROR_MESSAGE_MAX_LEN = 200
|
||||
|
||||
export type ManagedHookInstaller = readonly [HookInstallAgent, () => void]
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
@@ -31,23 +18,13 @@ function describeError(error: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function runManagedHookInstallers(installers: readonly ManagedHookInstaller[]): void {
|
||||
for (const [agent, install] of installers) {
|
||||
try {
|
||||
install()
|
||||
} catch (error) {
|
||||
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
|
||||
// Why: telemetry must not break fail-open. A throw inside `track` (e.g.
|
||||
// a corrupted settings store the resolveConsent path reads from) would
|
||||
// otherwise abort the for-loop and skip later agents' installers.
|
||||
try {
|
||||
track('agent_hook_install_failed', {
|
||||
agent,
|
||||
error_message: describeError(error).slice(0, ERROR_MESSAGE_MAX_LEN)
|
||||
})
|
||||
} catch (telemetryError) {
|
||||
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
|
||||
}
|
||||
}
|
||||
export function recordManagedHookInstallFailure(agent: HookInstallAgent, error: unknown): void {
|
||||
try {
|
||||
track('agent_hook_install_failed', {
|
||||
agent,
|
||||
error_message: describeError(error).slice(0, ERROR_MESSAGE_MAX_LEN)
|
||||
})
|
||||
} catch (telemetryError) {
|
||||
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets'
|
||||
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
|
||||
|
||||
const codexTarget: ManagedAgentHookTarget = {
|
||||
agent: 'codex',
|
||||
tuiAgent: 'codex',
|
||||
executableCandidates: ['codex']
|
||||
}
|
||||
|
||||
const claudeTarget: ManagedAgentHookTarget = {
|
||||
agent: 'claude',
|
||||
tuiAgent: 'claude',
|
||||
executableCandidates: ['claude']
|
||||
}
|
||||
|
||||
describe('detectLocalManagedAgentCliPresence', () => {
|
||||
let tmpDir: string | null = null
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
tmpDir = null
|
||||
}
|
||||
})
|
||||
|
||||
it('scans a deduped PATH once for all agent candidates', async () => {
|
||||
const probe = vi.fn(async (filePath: string) => filePath.endsWith('/bin/codex'))
|
||||
const result = await detectLocalManagedAgentCliPresence(
|
||||
[codexTarget, claudeTarget],
|
||||
{ agentCmdOverrides: {} },
|
||||
{
|
||||
pathEnv: ['/bin', '/bin', '/usr/bin'].join(':'),
|
||||
pathDelimiter: ':',
|
||||
fileProbe: { isExecutableFile: probe },
|
||||
platform: 'linux'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.codex?.state).toBe('found')
|
||||
expect(result.claude?.state).toBe('missing')
|
||||
expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([
|
||||
'/bin/codex',
|
||||
'/bin/claude',
|
||||
'/usr/bin/claude'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses executable override paths as positive evidence', async () => {
|
||||
const probe = vi.fn(async (filePath: string) => filePath === '/custom/bin/codex')
|
||||
const result = await detectLocalManagedAgentCliPresence(
|
||||
[codexTarget],
|
||||
{ agentCmdOverrides: { codex: '/custom/bin/codex --profile work' } },
|
||||
{
|
||||
pathEnv: '',
|
||||
pathDelimiter: ':',
|
||||
fileProbe: { isExecutableFile: probe },
|
||||
platform: 'linux'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.codex?.state).toBe('found')
|
||||
expect(probe).toHaveBeenCalledWith('/custom/bin/codex')
|
||||
})
|
||||
|
||||
it('preserves Windows override separators', async () => {
|
||||
const overridePath = 'C:\\My Tools\\claude.cmd'
|
||||
const probe = vi.fn(async (filePath: string) => filePath === overridePath)
|
||||
const result = await detectLocalManagedAgentCliPresence(
|
||||
[claudeTarget],
|
||||
{ agentCmdOverrides: { claude: `"${overridePath}" --flag` } },
|
||||
{
|
||||
pathEnv: '',
|
||||
pathDelimiter: ';',
|
||||
fileProbe: { isExecutableFile: probe },
|
||||
platform: 'win32'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.claude?.state).toBe('found')
|
||||
expect(probe).toHaveBeenCalledWith(overridePath)
|
||||
})
|
||||
|
||||
it('expands Windows home-relative override paths with Windows separators', async () => {
|
||||
const overridePath = 'C:\\Users\\orca\\bin\\claude.cmd'
|
||||
const probe = vi.fn(async (filePath: string) => filePath === overridePath)
|
||||
const result = await detectLocalManagedAgentCliPresence(
|
||||
[claudeTarget],
|
||||
{ agentCmdOverrides: { claude: '~\\bin\\claude.cmd --flag' } },
|
||||
{
|
||||
pathEnv: '',
|
||||
fileProbe: { isExecutableFile: probe },
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\orca'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.claude?.state).toBe('found')
|
||||
expect(probe).toHaveBeenCalledWith(overridePath)
|
||||
})
|
||||
|
||||
it('expands home-relative override paths', async () => {
|
||||
const probe = vi.fn(async (filePath: string) => filePath === '/home/orca/bin/codex')
|
||||
const result = await detectLocalManagedAgentCliPresence(
|
||||
[codexTarget],
|
||||
{ agentCmdOverrides: { codex: '~/bin/codex --profile work' } },
|
||||
{
|
||||
pathEnv: '',
|
||||
pathDelimiter: ':',
|
||||
fileProbe: { isExecutableFile: probe },
|
||||
platform: 'linux',
|
||||
homeDir: '/home/orca'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.codex?.state).toBe('found')
|
||||
expect(probe).toHaveBeenCalledWith('/home/orca/bin/codex')
|
||||
})
|
||||
|
||||
it('reports relative override paths as unknown', async () => {
|
||||
const probe = vi.fn(async () => true)
|
||||
const result = await detectLocalManagedAgentCliPresence(
|
||||
[codexTarget],
|
||||
{ agentCmdOverrides: { codex: 'bin/codex --profile work' } },
|
||||
{
|
||||
pathEnv: '',
|
||||
pathDelimiter: ':',
|
||||
fileProbe: { isExecutableFile: probe },
|
||||
platform: 'linux'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.codex).toEqual({ state: 'unknown' })
|
||||
expect(probe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors PATHEXT for Windows PATH candidates', async () => {
|
||||
const probe = vi.fn(async (filePath: string) => filePath === 'C:\\Tools\\codex.CMD')
|
||||
const result = await detectLocalManagedAgentCliPresence(
|
||||
[codexTarget],
|
||||
{ agentCmdOverrides: {} },
|
||||
{
|
||||
pathEnv: 'C:\\Other;C:\\Tools',
|
||||
pathExt: '.EXE;.CMD',
|
||||
fileProbe: { isExecutableFile: probe },
|
||||
platform: 'win32'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.codex?.state).toBe('found')
|
||||
expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([
|
||||
'C:\\Other\\codex.EXE',
|
||||
'C:\\Other\\codex.CMD',
|
||||
'C:\\Tools\\codex.EXE',
|
||||
'C:\\Tools\\codex.CMD'
|
||||
])
|
||||
})
|
||||
|
||||
it('warns and uses the inherited PATH when shell hydration throws', async () => {
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const result = await detectLocalManagedAgentCliPresence(
|
||||
[codexTarget],
|
||||
{ agentCmdOverrides: {} },
|
||||
{
|
||||
pathEnv: '',
|
||||
pathDelimiter: ':',
|
||||
fileProbe: { isExecutableFile: vi.fn(async () => false) },
|
||||
platform: 'linux',
|
||||
shouldHydrateShellPath: true,
|
||||
hydratePath: vi.fn(async () => {
|
||||
throw new Error('shell unavailable')
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.codex?.state).toBe('missing')
|
||||
expect(warning).toHaveBeenCalledWith(
|
||||
'[agent-hooks] Shell PATH hydration failed; using inherited PATH:',
|
||||
expect.objectContaining({ message: 'shell unavailable' })
|
||||
)
|
||||
} finally {
|
||||
warning.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'accepts executable symlinks and rejects broken symlinks',
|
||||
async () => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cli-presence-'))
|
||||
const binDir = join(tmpDir, 'bin')
|
||||
mkdirSync(binDir)
|
||||
const targetPath = join(tmpDir, 'codex-real')
|
||||
writeFileSync(targetPath, '#!/bin/sh\n')
|
||||
chmodSync(targetPath, 0o755)
|
||||
symlinkSync(targetPath, join(binDir, 'codex'))
|
||||
symlinkSync(join(tmpDir, 'missing'), join(binDir, 'claude'))
|
||||
|
||||
const result = await detectLocalManagedAgentCliPresence(
|
||||
[codexTarget, claudeTarget],
|
||||
{ agentCmdOverrides: {} },
|
||||
{ pathEnv: binDir, pathDelimiter: ':', platform: process.platform }
|
||||
)
|
||||
|
||||
expect(result.codex?.state).toBe('found')
|
||||
expect(result.claude?.state).toBe('missing')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { access, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { AgentHookTarget } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
extractExecutableToken,
|
||||
hasPathSeparatorToken,
|
||||
isSafeExecutableBasename
|
||||
} from '../../shared/managed-agent-command-token'
|
||||
import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
|
||||
|
||||
export type LocalCliPresenceState = 'found' | 'missing' | 'unknown'
|
||||
export type LocalCliPresenceByAgent = Partial<
|
||||
Record<AgentHookTarget, { state: LocalCliPresenceState }>
|
||||
>
|
||||
|
||||
type FileProbe = {
|
||||
isExecutableFile: (filePath: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
type HydrationResult =
|
||||
| { ok: true; segments: string[] }
|
||||
| { ok: false; segments: []; failureReason?: string }
|
||||
|
||||
type DetectOptions = {
|
||||
pathEnv?: string
|
||||
platform?: NodeJS.Platform
|
||||
pathDelimiter?: string
|
||||
pathExt?: string
|
||||
fileProbe?: FileProbe
|
||||
hydratePath?: () => Promise<HydrationResult>
|
||||
shouldHydrateShellPath?: boolean
|
||||
homeDir?: string
|
||||
}
|
||||
|
||||
type CommandOverrideSettings = Partial<Pick<GlobalSettings, 'agentCmdOverrides'>> | null | undefined
|
||||
|
||||
const DEFAULT_WINDOWS_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD']
|
||||
|
||||
function pathApiForPlatform(platform: NodeJS.Platform) {
|
||||
return platform === 'win32' ? path.win32 : path.posix
|
||||
}
|
||||
|
||||
async function isExecutableFile(filePath: string, platform: NodeJS.Platform): Promise<boolean> {
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
if (!fileStat.isFile()) {
|
||||
return false
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
return true
|
||||
}
|
||||
await access(filePath, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function pathEntries(pathEnv: string, delimiter: string): string[] {
|
||||
return [...new Set(pathEnv.split(delimiter).filter(Boolean))]
|
||||
}
|
||||
|
||||
function windowsPathExts(value: string | undefined): string[] {
|
||||
const source = value?.length ? value : DEFAULT_WINDOWS_EXTENSIONS.join(';')
|
||||
return [
|
||||
...new Set(
|
||||
source
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => (part.startsWith('.') ? part : `.${part}`))
|
||||
.map((part) => part.toUpperCase())
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
function candidateFileNames(
|
||||
candidate: string,
|
||||
platform: NodeJS.Platform,
|
||||
pathExt?: string
|
||||
): string[] {
|
||||
if (platform !== 'win32' || pathApiForPlatform(platform).extname(candidate)) {
|
||||
return [candidate]
|
||||
}
|
||||
return windowsPathExts(pathExt).map((suffix) => `${candidate}${suffix}`)
|
||||
}
|
||||
|
||||
function overrideTokenForAgent(
|
||||
settings: CommandOverrideSettings,
|
||||
target: ManagedAgentHookTarget,
|
||||
platform: NodeJS.Platform
|
||||
): string | null {
|
||||
return extractExecutableToken(settings?.agentCmdOverrides?.[target.tuiAgent], { platform })
|
||||
}
|
||||
|
||||
function expandHomePathToken(token: string, platform: NodeJS.Platform, homeDir: string): string {
|
||||
if (token === '~') {
|
||||
return homeDir
|
||||
}
|
||||
if (token.startsWith('~/') || (platform === 'win32' && token.startsWith('~\\'))) {
|
||||
return pathApiForPlatform(platform).join(homeDir, token.slice(2))
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
async function probePathCandidate(
|
||||
candidate: string,
|
||||
dirs: readonly string[],
|
||||
platform: NodeJS.Platform,
|
||||
fileProbe: FileProbe,
|
||||
pathExt?: string
|
||||
): Promise<boolean> {
|
||||
if (!isSafeExecutableBasename(candidate)) {
|
||||
return false
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
for (const fileName of candidateFileNames(candidate, platform, pathExt)) {
|
||||
if (await fileProbe.isExecutableFile(pathApiForPlatform(platform).join(dir, fileName))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isPlatformAbsolutePath(candidate: string, platform: NodeJS.Platform): boolean {
|
||||
return platform === 'win32' ? path.win32.isAbsolute(candidate) : path.posix.isAbsolute(candidate)
|
||||
}
|
||||
|
||||
async function maybeHydrateShellPath(options: DetectOptions): Promise<void> {
|
||||
if (!options.shouldHydrateShellPath) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await (options.hydratePath ?? hydrateShellPath)()
|
||||
if (result.ok) {
|
||||
mergePathSegments(result.segments)
|
||||
}
|
||||
} catch (error) {
|
||||
// Detection failure must never permit config mutation.
|
||||
console.warn('[agent-hooks] Shell PATH hydration failed; using inherited PATH:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectLocalManagedAgentCliPresence(
|
||||
targets: readonly ManagedAgentHookTarget[],
|
||||
settings: CommandOverrideSettings,
|
||||
options: DetectOptions = {}
|
||||
): Promise<LocalCliPresenceByAgent> {
|
||||
await maybeHydrateShellPath(options)
|
||||
const platform = options.platform ?? process.platform
|
||||
const delimiter = options.pathDelimiter ?? pathApiForPlatform(platform).delimiter
|
||||
const dirs = pathEntries(options.pathEnv ?? process.env.PATH ?? '', delimiter)
|
||||
const homeDir = options.homeDir ?? homedir()
|
||||
const fileProbe = options.fileProbe ?? {
|
||||
isExecutableFile: (filePath: string) => isExecutableFile(filePath, platform)
|
||||
}
|
||||
const candidates = new Set<string>()
|
||||
for (const target of targets) {
|
||||
for (const candidate of target.executableCandidates) {
|
||||
if (!hasPathSeparatorToken(candidate)) {
|
||||
candidates.add(candidate)
|
||||
}
|
||||
}
|
||||
const override = overrideTokenForAgent(settings, target, platform)
|
||||
if (override && !hasPathSeparatorToken(override)) {
|
||||
candidates.add(override)
|
||||
}
|
||||
}
|
||||
const found = new Set<string>()
|
||||
for (const candidate of candidates) {
|
||||
if (await probePathCandidate(candidate, dirs, platform, fileProbe, options.pathExt)) {
|
||||
found.add(candidate)
|
||||
}
|
||||
}
|
||||
const result: LocalCliPresenceByAgent = {}
|
||||
for (const target of targets) {
|
||||
const override = overrideTokenForAgent(settings, target, platform)
|
||||
if (override && hasPathSeparatorToken(override)) {
|
||||
const expanded = expandHomePathToken(override, platform, homeDir)
|
||||
if (!isPlatformAbsolutePath(expanded, platform)) {
|
||||
result[target.agent] = { state: 'unknown' }
|
||||
continue
|
||||
}
|
||||
result[target.agent] = (await fileProbe.isExecutableFile(expanded))
|
||||
? { state: 'found' }
|
||||
: { state: 'missing' }
|
||||
continue
|
||||
}
|
||||
const targetCandidates = [...target.executableCandidates, ...(override ? [override] : [])]
|
||||
result[target.agent] = targetCandidates.some((candidate) => found.has(candidate))
|
||||
? { state: 'found' }
|
||||
: { state: 'missing' }
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
detect: vi.fn(),
|
||||
installClaude: vi.fn(),
|
||||
installCodex: vi.fn(),
|
||||
removeClaude: vi.fn(),
|
||||
removeCodex: vi.fn(),
|
||||
statusClaude: vi.fn(),
|
||||
statusCodex: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./local-agent-cli-presence', () => ({
|
||||
detectLocalManagedAgentCliPresence: mocks.detect
|
||||
}))
|
||||
|
||||
vi.mock('./managed-agent-hook-registry', () => ({
|
||||
MANAGED_AGENT_HOOK_INSTALLERS: [
|
||||
['claude', mocks.installClaude],
|
||||
['codex', mocks.installCodex]
|
||||
],
|
||||
MANAGED_AGENT_HOOK_REMOVERS: [
|
||||
['claude', mocks.removeClaude],
|
||||
['codex', mocks.removeCodex]
|
||||
],
|
||||
MANAGED_AGENT_HOOK_STATUS_READERS: [
|
||||
['claude', mocks.statusClaude],
|
||||
['codex', mocks.statusCodex]
|
||||
]
|
||||
}))
|
||||
|
||||
import {
|
||||
applyAgentStatusHooksEnabled,
|
||||
installManagedAgentHooks
|
||||
} from './managed-agent-hook-controls'
|
||||
|
||||
function status(agent: 'claude' | 'codex', state: 'installed' | 'not_installed') {
|
||||
return {
|
||||
agent,
|
||||
state,
|
||||
configPath: `/${agent}`,
|
||||
managedHooksPresent: state === 'installed',
|
||||
detail: null
|
||||
} as const
|
||||
}
|
||||
|
||||
describe('managed agent hook controls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.installClaude.mockReturnValue(status('claude', 'installed'))
|
||||
mocks.installCodex.mockReturnValue(status('codex', 'installed'))
|
||||
mocks.removeClaude.mockReturnValue(status('claude', 'not_installed'))
|
||||
mocks.removeCodex.mockReturnValue(status('codex', 'not_installed'))
|
||||
})
|
||||
|
||||
it('installs only agents with positively detected CLIs', async () => {
|
||||
mocks.detect.mockResolvedValue({
|
||||
claude: { state: 'missing' },
|
||||
codex: { state: 'found' }
|
||||
})
|
||||
|
||||
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
|
||||
|
||||
expect(mocks.installClaude).not.toHaveBeenCalled()
|
||||
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
agent: 'claude',
|
||||
state: 'skipped',
|
||||
skipReason: 'cli_not_found'
|
||||
}),
|
||||
expect.objectContaining({ agent: 'codex', state: 'installed' })
|
||||
])
|
||||
})
|
||||
|
||||
it('fails closed when CLI detection rejects', async () => {
|
||||
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
|
||||
|
||||
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
|
||||
|
||||
expect(mocks.installClaude).not.toHaveBeenCalled()
|
||||
expect(mocks.installCodex).not.toHaveBeenCalled()
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
agent: 'claude',
|
||||
state: 'skipped',
|
||||
skipReason: 'cli_presence_unknown'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
agent: 'codex',
|
||||
state: 'skipped',
|
||||
skipReason: 'cli_presence_unknown'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('removes disabled agents without probing or reinstalling them', async () => {
|
||||
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
|
||||
|
||||
const results = await applyAgentStatusHooksEnabled(true, {
|
||||
agentCmdOverrides: {},
|
||||
disabledTuiAgents: ['claude']
|
||||
})
|
||||
|
||||
expect(mocks.removeClaude).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.installClaude).not.toHaveBeenCalled()
|
||||
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ agent: 'claude', state: 'not_installed' }),
|
||||
expect.objectContaining({ agent: 'codex', state: 'installed' })
|
||||
])
|
||||
})
|
||||
|
||||
it('does not install an agent disabled while detection was running', async () => {
|
||||
mocks.detect.mockResolvedValue({
|
||||
claude: { state: 'found' },
|
||||
codex: { state: 'found' }
|
||||
})
|
||||
|
||||
await installManagedAgentHooks(
|
||||
{ agentCmdOverrides: {} },
|
||||
{ shouldContinue: (agent) => agent !== 'claude' }
|
||||
)
|
||||
|
||||
expect(mocks.installClaude).not.toHaveBeenCalled()
|
||||
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not remove an agent enabled by a newer settings update', async () => {
|
||||
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
|
||||
|
||||
await applyAgentStatusHooksEnabled(
|
||||
true,
|
||||
{
|
||||
agentCmdOverrides: {},
|
||||
disabledTuiAgents: ['claude']
|
||||
},
|
||||
{ shouldContinue: () => true }
|
||||
)
|
||||
|
||||
expect(mocks.removeClaude).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes every managed hook when the global setting is off', async () => {
|
||||
await applyAgentStatusHooksEnabled(false)
|
||||
|
||||
expect(mocks.detect).not.toHaveBeenCalled()
|
||||
expect(mocks.removeClaude).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.removeCodex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,75 +1,34 @@
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import type { HookInstallAgent } from '../../shared/telemetry-events'
|
||||
import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
getManagedAgentHookTarget,
|
||||
isManagedAgentHookTarget
|
||||
} from '../../shared/managed-agent-hook-targets'
|
||||
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { ampHookService } from '../amp/hook-service'
|
||||
import { antigravityHookService } from '../antigravity/hook-service'
|
||||
import { claudeHookService } from '../claude/hook-service'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
import { copilotHookService } from '../copilot/hook-service'
|
||||
import { cursorHookService } from '../cursor/hook-service'
|
||||
import { droidHookService } from '../droid/hook-service'
|
||||
import { commandCodeHookService } from '../command-code/hook-service'
|
||||
import { geminiHookService } from '../gemini/hook-service'
|
||||
import { devinHookService } from '../devin/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
|
||||
import {
|
||||
MANAGED_AGENT_HOOK_INSTALLERS,
|
||||
MANAGED_AGENT_HOOK_REMOVERS,
|
||||
MANAGED_AGENT_HOOK_STATUS_READERS,
|
||||
type ManagedAgentHookInstaller
|
||||
} from './managed-agent-hook-registry'
|
||||
|
||||
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => void]
|
||||
type ManagedHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
type ManagedHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry'
|
||||
|
||||
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
|
||||
['claude', () => claudeHookService.install()],
|
||||
['openclaude', () => openClaudeHookService.install()],
|
||||
['codex', () => codexHookService.install()],
|
||||
['gemini', () => geminiHookService.install()],
|
||||
['antigravity', () => antigravityHookService.install()],
|
||||
['amp', () => ampHookService.install()],
|
||||
['cursor', () => cursorHookService.install()],
|
||||
['droid', () => droidHookService.install()],
|
||||
['command-code', () => commandCodeHookService.install()],
|
||||
['grok', () => grokHookService.install()],
|
||||
['copilot', () => copilotHookService.install()],
|
||||
['hermes', () => hermesHookService.install()],
|
||||
['devin', () => devinHookService.install()],
|
||||
['kimi', () => kimiHookService.install()]
|
||||
]
|
||||
type ManagedHookSettings = Partial<
|
||||
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
|
||||
> | null
|
||||
|
||||
const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [
|
||||
['claude', () => claudeHookService.remove()],
|
||||
['openclaude', () => openClaudeHookService.remove()],
|
||||
['codex', () => codexHookService.remove()],
|
||||
['gemini', () => geminiHookService.remove()],
|
||||
['antigravity', () => antigravityHookService.remove()],
|
||||
['amp', () => ampHookService.remove()],
|
||||
['cursor', () => cursorHookService.remove()],
|
||||
['droid', () => droidHookService.remove()],
|
||||
['command-code', () => commandCodeHookService.remove()],
|
||||
['grok', () => grokHookService.remove()],
|
||||
['copilot', () => copilotHookService.remove()],
|
||||
['hermes', () => hermesHookService.remove()],
|
||||
['devin', () => devinHookService.remove()],
|
||||
['kimi', () => kimiHookService.remove()]
|
||||
]
|
||||
type InstallOptions = {
|
||||
shouldHydrateShellPath?: boolean
|
||||
onInstallError?: (agent: AgentHookTarget, error: unknown) => void
|
||||
shouldContinue?: (agent: AgentHookTarget) => boolean
|
||||
agents?: readonly AgentHookTarget[]
|
||||
}
|
||||
|
||||
const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [
|
||||
['claude', () => claudeHookService.getStatus()],
|
||||
['openclaude', () => openClaudeHookService.getStatus()],
|
||||
['codex', () => codexHookService.getStatus()],
|
||||
['gemini', () => geminiHookService.getStatus()],
|
||||
['antigravity', () => antigravityHookService.getStatus()],
|
||||
['amp', () => ampHookService.getStatus()],
|
||||
['cursor', () => cursorHookService.getStatus()],
|
||||
['droid', () => droidHookService.getStatus()],
|
||||
['grok', () => grokHookService.getStatus()],
|
||||
['command-code', () => commandCodeHookService.getStatus()],
|
||||
['copilot', () => copilotHookService.getStatus()],
|
||||
['hermes', () => hermesHookService.getStatus()],
|
||||
['devin', () => devinHookService.getStatus()],
|
||||
['kimi', () => kimiHookService.getStatus()]
|
||||
]
|
||||
type RemoveOptions = {
|
||||
agents?: readonly AgentHookTarget[]
|
||||
}
|
||||
|
||||
export function isAgentStatusHooksEnabled(
|
||||
settings: Pick<GlobalSettings, 'agentStatusHooksEnabled'> | null | undefined
|
||||
@@ -77,17 +36,7 @@ export function isAgentStatusHooksEnabled(
|
||||
return settings?.agentStatusHooksEnabled !== false
|
||||
}
|
||||
|
||||
export function installManagedAgentHooks(): void {
|
||||
for (const [agent, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
|
||||
try {
|
||||
install()
|
||||
} catch (error) {
|
||||
console.warn(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallStatus {
|
||||
function errorStatus(agent: AgentHookTarget, error: unknown): AgentHookInstallStatus {
|
||||
return {
|
||||
agent,
|
||||
state: 'error',
|
||||
@@ -97,8 +46,110 @@ function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallS
|
||||
}
|
||||
}
|
||||
|
||||
export function removeManagedAgentHooks(): AgentHookInstallStatus[] {
|
||||
return LOCAL_MANAGED_HOOK_REMOVERS.map(([agent, remove]) => {
|
||||
function skippedStatus(
|
||||
agent: AgentHookTarget,
|
||||
skipReason: NonNullable<AgentHookInstallStatus['skipReason']>,
|
||||
detail: string
|
||||
): AgentHookInstallStatus {
|
||||
return {
|
||||
agent,
|
||||
state: 'skipped',
|
||||
configPath: '',
|
||||
managedHooksPresent: false,
|
||||
detail,
|
||||
skipReason
|
||||
}
|
||||
}
|
||||
|
||||
function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookInstaller[] {
|
||||
if (!options.agents) {
|
||||
return MANAGED_AGENT_HOOK_INSTALLERS
|
||||
}
|
||||
const allowed = new Set(options.agents)
|
||||
return MANAGED_AGENT_HOOK_INSTALLERS.filter(([agent]) => allowed.has(agent))
|
||||
}
|
||||
|
||||
function runInstaller(
|
||||
entry: ManagedAgentHookInstaller,
|
||||
onInstallError: InstallOptions['onInstallError']
|
||||
): AgentHookInstallStatus {
|
||||
const [agent, install] = entry
|
||||
try {
|
||||
return install()
|
||||
} catch (error) {
|
||||
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
|
||||
try {
|
||||
onInstallError?.(agent, error)
|
||||
} catch (telemetryError) {
|
||||
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
|
||||
}
|
||||
return errorStatus(agent, error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function installManagedAgentHooks(
|
||||
settings: ManagedHookSettings = null,
|
||||
options: InstallOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
const installers = selectedInstallers(options)
|
||||
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
|
||||
const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent))
|
||||
const targets = enabledInstallers.flatMap(([agent]) => {
|
||||
const target = getManagedAgentHookTarget(agent)
|
||||
return target ? [target] : []
|
||||
})
|
||||
let presenceByAgent
|
||||
try {
|
||||
presenceByAgent = await detectLocalManagedAgentCliPresence(targets, settings, {
|
||||
shouldHydrateShellPath: options.shouldHydrateShellPath
|
||||
})
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
return installers.map(([agent]) =>
|
||||
disabled.has(agent)
|
||||
? skippedStatus(agent, 'agent_disabled', 'Agent is disabled in Settings.')
|
||||
: skippedStatus(agent, 'cli_presence_unknown', detail)
|
||||
)
|
||||
}
|
||||
|
||||
const results: AgentHookInstallStatus[] = []
|
||||
for (const entry of installers) {
|
||||
const [agent] = entry
|
||||
if (disabled.has(agent)) {
|
||||
results.push(skippedStatus(agent, 'agent_disabled', 'Agent is disabled in Settings.'))
|
||||
continue
|
||||
}
|
||||
if (options.shouldContinue && !options.shouldContinue(agent)) {
|
||||
results.push(
|
||||
skippedStatus(
|
||||
agent,
|
||||
'hooks_disabled',
|
||||
'Agent status hooks were disabled before install completed.'
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
const presence = presenceByAgent[agent]
|
||||
if (presence?.state !== 'found') {
|
||||
results.push(
|
||||
skippedStatus(
|
||||
agent,
|
||||
presence?.state === 'unknown' ? 'cli_presence_unknown' : 'cli_not_found',
|
||||
'CLI not found; managed hook install skipped.'
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
results.push(runInstaller(entry, options.onInstallError))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export function removeManagedAgentHooks(options: RemoveOptions = {}): AgentHookInstallStatus[] {
|
||||
const allowed = options.agents ? new Set(options.agents) : null
|
||||
return MANAGED_AGENT_HOOK_REMOVERS.filter(
|
||||
([agent]) => allowed === null || allowed.has(agent)
|
||||
).map(([agent, remove]) => {
|
||||
try {
|
||||
return remove()
|
||||
} catch (error) {
|
||||
@@ -108,7 +159,7 @@ export function removeManagedAgentHooks(): AgentHookInstallStatus[] {
|
||||
}
|
||||
|
||||
export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
|
||||
return LOCAL_MANAGED_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
|
||||
return MANAGED_AGENT_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
|
||||
try {
|
||||
return getStatus()
|
||||
} catch (error) {
|
||||
@@ -117,10 +168,26 @@ export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
|
||||
})
|
||||
}
|
||||
|
||||
export function applyAgentStatusHooksEnabled(enabled: boolean): AgentHookInstallStatus[] {
|
||||
if (enabled) {
|
||||
installManagedAgentHooks()
|
||||
return getManagedAgentHookStatuses()
|
||||
export async function applyAgentStatusHooksEnabled(
|
||||
enabled: boolean,
|
||||
settings: ManagedHookSettings = null,
|
||||
options: InstallOptions = {}
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
if (!enabled) {
|
||||
return removeManagedAgentHooks()
|
||||
}
|
||||
return removeManagedAgentHooks()
|
||||
const disabled = normalizeDisabledTuiAgents(settings?.disabledTuiAgents).filter(
|
||||
isManagedAgentHookTarget
|
||||
)
|
||||
const installed = await installManagedAgentHooks(settings, options)
|
||||
const disabledToRemove = options.shouldContinue
|
||||
? disabled.filter((agent) => !options.shouldContinue?.(agent))
|
||||
: disabled
|
||||
if (disabledToRemove.length === 0) {
|
||||
return installed
|
||||
}
|
||||
const removed = new Map(
|
||||
removeManagedAgentHooks({ agents: disabledToRemove }).map((status) => [status.agent, status])
|
||||
)
|
||||
return installed.map((status) => removed.get(status.agent) ?? status)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import type { HookInstallAgent } from '../../shared/telemetry-events'
|
||||
import { ampHookService } from '../amp/hook-service'
|
||||
import { antigravityHookService } from '../antigravity/hook-service'
|
||||
import { claudeHookService } from '../claude/hook-service'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
import { commandCodeHookService } from '../command-code/hook-service'
|
||||
import { copilotHookService } from '../copilot/hook-service'
|
||||
import { cursorHookService } from '../cursor/hook-service'
|
||||
import { devinHookService } from '../devin/hook-service'
|
||||
import { droidHookService } from '../droid/hook-service'
|
||||
import { geminiHookService } from '../gemini/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
|
||||
['claude', () => claudeHookService.install()],
|
||||
['openclaude', () => openClaudeHookService.install()],
|
||||
['codex', () => codexHookService.install()],
|
||||
['gemini', () => geminiHookService.install()],
|
||||
['antigravity', () => antigravityHookService.install()],
|
||||
['amp', () => ampHookService.install()],
|
||||
['cursor', () => cursorHookService.install()],
|
||||
['droid', () => droidHookService.install()],
|
||||
['command-code', () => commandCodeHookService.install()],
|
||||
['grok', () => grokHookService.install()],
|
||||
['copilot', () => copilotHookService.install()],
|
||||
['hermes', () => hermesHookService.install()],
|
||||
['devin', () => devinHookService.install()],
|
||||
['kimi', () => kimiHookService.install()]
|
||||
]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
|
||||
['claude', () => claudeHookService.remove()],
|
||||
['openclaude', () => openClaudeHookService.remove()],
|
||||
['codex', () => codexHookService.remove()],
|
||||
['gemini', () => geminiHookService.remove()],
|
||||
['antigravity', () => antigravityHookService.remove()],
|
||||
['amp', () => ampHookService.remove()],
|
||||
['cursor', () => cursorHookService.remove()],
|
||||
['droid', () => droidHookService.remove()],
|
||||
['command-code', () => commandCodeHookService.remove()],
|
||||
['grok', () => grokHookService.remove()],
|
||||
['copilot', () => copilotHookService.remove()],
|
||||
['hermes', () => hermesHookService.remove()],
|
||||
['devin', () => devinHookService.remove()],
|
||||
['kimi', () => kimiHookService.remove()]
|
||||
]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusReader[] = [
|
||||
['claude', () => claudeHookService.getStatus()],
|
||||
['openclaude', () => openClaudeHookService.getStatus()],
|
||||
['codex', () => codexHookService.getStatus()],
|
||||
['gemini', () => geminiHookService.getStatus()],
|
||||
['antigravity', () => antigravityHookService.getStatus()],
|
||||
['amp', () => ampHookService.getStatus()],
|
||||
['cursor', () => cursorHookService.getStatus()],
|
||||
['droid', () => droidHookService.getStatus()],
|
||||
['grok', () => grokHookService.getStatus()],
|
||||
['command-code', () => commandCodeHookService.getStatus()],
|
||||
['copilot', () => copilotHookService.getStatus()],
|
||||
['hermes', () => hermesHookService.getStatus()],
|
||||
['devin', () => devinHookService.getStatus()],
|
||||
['kimi', () => kimiHookService.getStatus()]
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildManagedHookDetectionCommands,
|
||||
detectedManagedHookAgents
|
||||
} from './managed-hook-detection-commands'
|
||||
|
||||
describe('managed hook detection commands', () => {
|
||||
it('omits disabled agents and includes safe command overrides', () => {
|
||||
const commands = buildManagedHookDetectionCommands(
|
||||
{
|
||||
disabledTuiAgents: ['claude'],
|
||||
agentCmdOverrides: { codex: '/opt/codex custom' }
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(commands.some((command) => command.id === 'claude')).toBe(false)
|
||||
expect(commands).toContainEqual({ id: 'codex', cmd: '/opt/codex' })
|
||||
})
|
||||
|
||||
it('maps detected TUI ids back to managed hook targets', () => {
|
||||
expect(detectedManagedHookAgents(['codex', 'opencode', 'droid'])).toEqual(['codex', 'droid'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { AgentHookTarget } from '../../shared/agent-hook-types'
|
||||
import {
|
||||
extractExecutableToken,
|
||||
isSafeOverrideExecutableToken
|
||||
} from '../../shared/managed-agent-command-token'
|
||||
import { MANAGED_AGENT_HOOK_TARGETS } from '../../shared/managed-agent-hook-targets'
|
||||
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands'
|
||||
|
||||
export type ManagedHookDetectionSettings = Partial<
|
||||
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
|
||||
> | null
|
||||
|
||||
export function buildManagedHookDetectionCommands(
|
||||
settings: ManagedHookDetectionSettings,
|
||||
platform: NodeJS.Platform
|
||||
): TuiAgentDetectionCommand[] {
|
||||
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
|
||||
return MANAGED_AGENT_HOOK_TARGETS.filter((target) => !disabled.has(target.tuiAgent)).flatMap(
|
||||
(target) => {
|
||||
const commands = new Set(target.executableCandidates)
|
||||
const override = extractExecutableToken(settings?.agentCmdOverrides?.[target.tuiAgent], {
|
||||
platform
|
||||
})
|
||||
if (override && isSafeOverrideExecutableToken(override)) {
|
||||
commands.add(override)
|
||||
}
|
||||
return [...commands].map((cmd) => ({ id: target.tuiAgent, cmd }))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function detectedManagedHookAgents(values: unknown): AgentHookTarget[] {
|
||||
if (!Array.isArray(values)) {
|
||||
return []
|
||||
}
|
||||
const detected = new Set(values.filter((value): value is string => typeof value === 'string'))
|
||||
return MANAGED_AGENT_HOOK_TARGETS.filter((target) => detected.has(target.tuiAgent)).map(
|
||||
(target) => target.agent
|
||||
)
|
||||
}
|
||||
@@ -6,9 +6,11 @@ import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
|
||||
|
||||
const tempHomes: string[] = []
|
||||
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
|
||||
|
||||
async function createTempHome(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-managed-hooks-'))
|
||||
// Why: mkdir-p probes ancestors; macOS's per-user temp directory can contain hundreds of thousands of entries.
|
||||
const home = await mkdtemp(join(tempRoot, 'orca-managed-hooks-'))
|
||||
tempHomes.push(home)
|
||||
return home
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { basename } from 'node:path'
|
||||
import { homedir, userInfo } from 'node:os'
|
||||
import { promisify } from 'node:util'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import type { AgentHookTarget } from '../../shared/agent-hook-types'
|
||||
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
|
||||
import { withManagedHookInstallLock } from './managed-hook-install-lock'
|
||||
import {
|
||||
@@ -75,6 +76,7 @@ export async function resolveRelayGrokHome(home: string, signal?: AbortSignal):
|
||||
export async function installManagedHooks(options?: {
|
||||
signal?: AbortSignal
|
||||
hostKeyFingerprint?: string
|
||||
agents?: readonly AgentHookTarget[]
|
||||
}): Promise<ManagedHookInstallSummary> {
|
||||
options?.signal?.throwIfAborted()
|
||||
const home = homedir()
|
||||
@@ -93,7 +95,8 @@ export async function installManagedHooks(options?: {
|
||||
home,
|
||||
{
|
||||
grokHomeDir,
|
||||
signal: options?.signal
|
||||
signal: options?.signal,
|
||||
agents: options?.agents
|
||||
}
|
||||
)
|
||||
return {
|
||||
|
||||
@@ -713,6 +713,20 @@ describe('remote hook service installers', () => {
|
||||
expect(byAgent.get('copilot')).toBe('installed')
|
||||
})
|
||||
|
||||
it('installs only positively detected remote agents', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
|
||||
const results = await installRemoteManagedAgentHooks(sftp, '/home/dev', {
|
||||
agents: ['codex']
|
||||
})
|
||||
|
||||
expect(results.map((result) => result.agent)).toEqual(['codex'])
|
||||
const paths = [...fs.files.keys(), ...fs.dirs]
|
||||
for (const unusedHome of ['.factory', '.gemini', '.grok', '.hermes', '.commandcode']) {
|
||||
expect(paths.some((path) => path.includes(`/home/dev/${unusedHome}`))).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('stops before the next installer when its relay request is cancelled', async () => {
|
||||
const controller = new AbortController()
|
||||
const claudeInstall = vi
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
||||
import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types'
|
||||
import { ampHookService } from '../amp/hook-service'
|
||||
import { claudeHookService } from '../claude/hook-service'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
@@ -26,6 +26,8 @@ export type RemoteManagedHookInstallOptions = {
|
||||
/** Stops before starting the next installer when the owning relay request
|
||||
* is cancelled. Individual filesystem mutations remain atomic. */
|
||||
signal?: AbortSignal
|
||||
/** Positively detected and enabled agents allowed to mutate config. */
|
||||
agents?: readonly AgentHookTarget[]
|
||||
}
|
||||
|
||||
type RemoteManagedHookInstaller = readonly [
|
||||
@@ -80,7 +82,11 @@ export async function installRemoteManagedAgentHooks(
|
||||
options?: RemoteManagedHookInstallOptions
|
||||
): Promise<AgentHookInstallStatus[]> {
|
||||
const results: AgentHookInstallStatus[] = []
|
||||
const allowedAgents = options?.agents ? new Set(options.agents) : null
|
||||
for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) {
|
||||
if (allowedAgents && !allowedAgents.has(agent)) {
|
||||
continue
|
||||
}
|
||||
// Why: relay requests can disappear during reconnect; do not start more
|
||||
// user-config mutations after their client has gone away.
|
||||
options?.signal?.throwIfAborted()
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSubagentSnapshot } from '../../shared/agent-status-types'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { toAppSshPtyId } from '../../shared/ssh-pty-id'
|
||||
import { AgentHookServer } from './server'
|
||||
import {
|
||||
indexPersistedPaneKeyPtyIds,
|
||||
isLocalExecutionHost,
|
||||
resolveAgentWorkspaceExecutionHostId,
|
||||
sweepRestoredSubagentsWithoutLiveAgent
|
||||
} from './restored-subagent-liveness-sweep'
|
||||
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE = makePaneKey('tab-1', LEAF)
|
||||
const PTY = 'wt-1__pty-1'
|
||||
const WORKING_CHILD: AgentSubagentSnapshot = {
|
||||
id: 'areview-loop-c237a4c577493352',
|
||||
state: 'working',
|
||||
startedAt: 1_000
|
||||
}
|
||||
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-restored-subagent-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Persist a pane whose lead has finished but whose roster still holds a working
|
||||
* child, then restart into a fresh server — the shape a machine sleep leaves
|
||||
* behind when the child's SubagentStop is lost while Orca is down. */
|
||||
async function restartWithInFlightSubagent(options?: {
|
||||
connectionId?: string
|
||||
state?: 'working' | 'waiting'
|
||||
subagents?: AgentSubagentSnapshot[]
|
||||
additionalPaneKey?: string
|
||||
}): Promise<AgentHookServer> {
|
||||
const first = new AgentHookServer()
|
||||
await first.start({ env: 'production', userDataPath: dir })
|
||||
first.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: options?.connectionId ?? null,
|
||||
payload: {
|
||||
state: options?.state ?? 'working',
|
||||
prompt: 'review the PR',
|
||||
agentType: 'claude',
|
||||
subagents: options?.subagents ?? [WORKING_CHILD]
|
||||
}
|
||||
})
|
||||
if (options?.additionalPaneKey) {
|
||||
first.ingestTerminalStatus({
|
||||
paneKey: options.additionalPaneKey,
|
||||
tabId: 'tab-2',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: options.connectionId ?? null,
|
||||
payload: {
|
||||
state: options.state ?? 'working',
|
||||
prompt: 'review the PR',
|
||||
agentType: 'claude',
|
||||
subagents: options.subagents ?? [WORKING_CHILD]
|
||||
}
|
||||
})
|
||||
}
|
||||
first.flushStatusPersistSync()
|
||||
first.stop()
|
||||
|
||||
const restarted = new AgentHookServer()
|
||||
await restarted.start({ env: 'production', userDataPath: dir })
|
||||
return restarted
|
||||
}
|
||||
|
||||
function sweepWith(
|
||||
server: AgentHookServer,
|
||||
overrides: {
|
||||
probeLiveLocalPty?: (ptyId: string) => boolean | null | Promise<boolean | null>
|
||||
executionHostId?: string | null
|
||||
boundPtyIdByPaneKey?: Record<string, string>
|
||||
persistedPtyIdByPaneKey?: Record<string, string>
|
||||
} = {}
|
||||
): Promise<number> {
|
||||
return sweepRestoredSubagentsWithoutLiveAgent({
|
||||
probeLiveLocalPty: async (ptyId) =>
|
||||
overrides.probeLiveLocalPty ? await overrides.probeLiveLocalPty(ptyId) : false,
|
||||
isLocalExecutionHost: () =>
|
||||
isLocalExecutionHost(
|
||||
overrides.executionHostId === undefined ? 'local' : overrides.executionHostId
|
||||
),
|
||||
getBoundPtyIdForPaneKey: (paneKey) => overrides.boundPtyIdByPaneKey?.[paneKey],
|
||||
getPersistedPtyIdForPaneKey: (paneKey) => overrides.persistedPtyIdByPaneKey?.[paneKey],
|
||||
reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) =>
|
||||
server.reapRestoredClaudeSubagentsWithoutLiveAgent(
|
||||
isLocalHost,
|
||||
isLocalPaneAgentLive,
|
||||
isLocalPaneLivenessEvidenceCurrent
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function paneStatus(
|
||||
server: AgentHookServer,
|
||||
paneKey = PANE
|
||||
): { state: string; subagents?: AgentSubagentSnapshot[] } {
|
||||
const entry = server.getStatusSnapshotForPane(paneKey)[0]
|
||||
return { state: entry?.state ?? 'missing', subagents: entry?.subagents }
|
||||
}
|
||||
|
||||
describe('restored subagent liveness sweep', () => {
|
||||
it('reaps the phantom seed so a slept-through pane reaches done', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
try {
|
||||
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
|
||||
const previous = server.getStatusSnapshotForPane(PANE)[0]
|
||||
const previousReceivedAt = previous?.receivedAt ?? 0
|
||||
const reconciledAt = previousReceivedAt + 1
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(previousReceivedAt)
|
||||
|
||||
expect(await sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).toBe(1)
|
||||
|
||||
expect(paneStatus(server)).toEqual({ state: 'done', subagents: undefined })
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
|
||||
receivedAt: reconciledAt,
|
||||
stateStartedAt: reconciledAt
|
||||
})
|
||||
now.mockRestore()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a seed whose pane still has a live local PTY', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
try {
|
||||
expect(
|
||||
await sweepWith(server, {
|
||||
probeLiveLocalPty: (ptyId) => ptyId === PTY,
|
||||
persistedPtyIdByPaneKey: { [PANE]: PTY }
|
||||
})
|
||||
).toBe(0)
|
||||
|
||||
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves timing when reaping rows does not change the lead state', async () => {
|
||||
const server = await restartWithInFlightSubagent({ state: 'waiting' })
|
||||
try {
|
||||
const previous = server.getStatusSnapshotForPane(PANE)[0]
|
||||
|
||||
expect(await sweepWith(server, { persistedPtyIdByPaneKey: { [PANE]: PTY } })).toBe(1)
|
||||
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({
|
||||
state: 'waiting',
|
||||
subagents: undefined,
|
||||
receivedAt: previous?.receivedAt,
|
||||
stateStartedAt: previous?.stateStartedAt
|
||||
})
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses targeted liveness for a PTY bound in this runtime', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
try {
|
||||
const probeLiveLocalPty = vi.fn((ptyId: string) => ptyId === PTY)
|
||||
expect(
|
||||
await sweepWith(server, {
|
||||
probeLiveLocalPty,
|
||||
boundPtyIdByPaneKey: { [PANE]: PTY }
|
||||
})
|
||||
).toBe(0)
|
||||
|
||||
expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY)
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('probes only restored rosters and deduplicates a shared exact PTY', async () => {
|
||||
const otherPane = makePaneKey('tab-2', LEAF)
|
||||
const server = await restartWithInFlightSubagent({ additionalPaneKey: otherPane })
|
||||
const probeLiveLocalPty = vi.fn(() => false)
|
||||
try {
|
||||
expect(
|
||||
await sweepWith(server, {
|
||||
probeLiveLocalPty,
|
||||
persistedPtyIdByPaneKey: { [PANE]: PTY, [otherPane]: PTY }
|
||||
})
|
||||
).toBe(2)
|
||||
|
||||
expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not probe a hydrated Claude pane without restored rows', async () => {
|
||||
const server = await restartWithInFlightSubagent({ subagents: [] })
|
||||
const probeLiveLocalPty = vi.fn(() => false)
|
||||
try {
|
||||
expect(
|
||||
await sweepWith(server, {
|
||||
probeLiveLocalPty,
|
||||
persistedPtyIdByPaneKey: { [PANE]: PTY }
|
||||
})
|
||||
).toBe(0)
|
||||
|
||||
expect(probeLiveLocalPty).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('never reaps an SSH-launched pane, whose agent cannot appear in a local scan', async () => {
|
||||
const sshPtyId = toAppSshPtyId('conn-1', PTY)
|
||||
const server = await restartWithInFlightSubagent()
|
||||
try {
|
||||
expect(
|
||||
await sweepWith(server, {
|
||||
executionHostId: 'ssh:conn-1',
|
||||
persistedPtyIdByPaneKey: { [PANE]: sshPtyId }
|
||||
})
|
||||
).toBe(0)
|
||||
|
||||
expect(paneStatus(server)).toEqual({ state: 'working', subagents: [WORKING_CHILD] })
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('never reaps a relay-owned pane even with no local PTY at all', async () => {
|
||||
const server = await restartWithInFlightSubagent({ connectionId: 'conn-1' })
|
||||
try {
|
||||
expect(await sweepWith(server)).toBe(0)
|
||||
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('never reaps a runtime-hosted pane from the desktop local provider', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
const probeLiveLocalPty = vi.fn(() => false)
|
||||
try {
|
||||
expect(
|
||||
await sweepWith(server, {
|
||||
executionHostId: 'runtime:ephemeral-vm-1',
|
||||
probeLiveLocalPty,
|
||||
persistedPtyIdByPaneKey: { [PANE]: 'remote:ephemeral-vm-1@@pty-1' }
|
||||
})
|
||||
).toBe(0)
|
||||
|
||||
expect(probeLiveLocalPty).not.toHaveBeenCalled()
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does nothing when targeted PTY liveness is unknown', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
try {
|
||||
expect(
|
||||
await sweepWith(server, {
|
||||
probeLiveLocalPty: () => null,
|
||||
persistedPtyIdByPaneKey: { [PANE]: PTY }
|
||||
})
|
||||
).toBe(0)
|
||||
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does nothing without an exact pane PTY binding', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
const probeLiveLocalPty = vi.fn(() => false)
|
||||
try {
|
||||
expect(await sweepWith(server, { probeLiveLocalPty })).toBe(0)
|
||||
|
||||
expect(probeLiveLocalPty).not.toHaveBeenCalled()
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips panes that have reported to this runtime', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
try {
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'now reviewing round two',
|
||||
agentType: 'claude',
|
||||
subagents: [WORKING_CHILD]
|
||||
}
|
||||
})
|
||||
|
||||
expect(await sweepWith(server)).toBe(0)
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a pane that reports while its liveness probe is pending', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
let resolveProbe!: (live: boolean | null) => void
|
||||
const probeLiveLocalPty = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean | null>((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
try {
|
||||
const sweep = sweepWith(server, {
|
||||
probeLiveLocalPty,
|
||||
persistedPtyIdByPaneKey: { [PANE]: PTY }
|
||||
})
|
||||
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledOnce())
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'confirmed during probe',
|
||||
agentType: 'claude',
|
||||
subagents: [WORKING_CHILD]
|
||||
}
|
||||
})
|
||||
resolveProbe(false)
|
||||
|
||||
expect(await sweep).toBe(0)
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a pane that binds a newer PTY while the old probe is pending', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
const boundPtyIdByPaneKey = { [PANE]: PTY }
|
||||
let resolveProbe!: (live: boolean | null) => void
|
||||
const probeLiveLocalPty = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean | null>((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
try {
|
||||
const sweep = sweepWith(server, { probeLiveLocalPty, boundPtyIdByPaneKey })
|
||||
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY))
|
||||
boundPtyIdByPaneKey[PANE] = 'wt-1__pty-new'
|
||||
resolveProbe(false)
|
||||
|
||||
expect(await sweep).toBe(0)
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a pane that rebinds its persisted PTY while the probe is pending', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
const boundPtyIdByPaneKey: Record<string, string> = {}
|
||||
let resolveProbe!: (live: boolean | null) => void
|
||||
const probeLiveLocalPty = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean | null>((resolve) => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
try {
|
||||
const sweep = sweepWith(server, {
|
||||
probeLiveLocalPty,
|
||||
boundPtyIdByPaneKey,
|
||||
persistedPtyIdByPaneKey: { [PANE]: PTY }
|
||||
})
|
||||
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledExactlyOnceWith(PTY))
|
||||
boundPtyIdByPaneKey[PANE] = PTY
|
||||
resolveProbe(false)
|
||||
|
||||
expect(await sweep).toBe(0)
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a same-id rebind that lands after its probe but before the batch settles', async () => {
|
||||
const otherPane = makePaneKey('tab-2', LEAF)
|
||||
const otherPty = 'wt-1__pty-2'
|
||||
const server = await restartWithInFlightSubagent({ additionalPaneKey: otherPane })
|
||||
const boundPtyIdByPaneKey: Record<string, string> = {}
|
||||
let resolveOtherProbe!: (live: boolean | null) => void
|
||||
const probeLiveLocalPty = vi.fn((ptyId: string) =>
|
||||
ptyId === PTY
|
||||
? Promise.resolve(false)
|
||||
: new Promise<boolean | null>((resolve) => {
|
||||
resolveOtherProbe = resolve
|
||||
})
|
||||
)
|
||||
try {
|
||||
const sweep = sweepWith(server, {
|
||||
probeLiveLocalPty,
|
||||
boundPtyIdByPaneKey,
|
||||
persistedPtyIdByPaneKey: { [PANE]: PTY, [otherPane]: otherPty }
|
||||
})
|
||||
await vi.waitFor(() => expect(probeLiveLocalPty).toHaveBeenCalledTimes(2))
|
||||
boundPtyIdByPaneKey[PANE] = PTY
|
||||
resolveOtherProbe(false)
|
||||
|
||||
expect(await sweep).toBe(1)
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
expect(paneStatus(server, otherPane).state).toBe('done')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves a pane whose lead is genuinely mid-turn at working', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
try {
|
||||
// Why: the lead's own tool event proves the process is alive; only the
|
||||
// child-gated 'working' may be re-derived, so the state must survive.
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: makePaneKey('tab-2', LEAF),
|
||||
tabId: 'tab-2',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'still going', agentType: 'claude' }
|
||||
})
|
||||
|
||||
await sweepWith(server)
|
||||
|
||||
expect(paneStatus(server, makePaneKey('tab-2', LEAF)).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports zero and leaves state alone when targeted liveness throws', async () => {
|
||||
const server = await restartWithInFlightSubagent()
|
||||
const probeLiveLocalPty = vi.fn(() => {
|
||||
throw new Error('daemon unreachable')
|
||||
})
|
||||
try {
|
||||
expect(
|
||||
await sweepWith(server, {
|
||||
probeLiveLocalPty,
|
||||
persistedPtyIdByPaneKey: { [PANE]: PTY }
|
||||
})
|
||||
).toBe(0)
|
||||
|
||||
expect(paneStatus(server).state).toBe('working')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('indexPersistedPaneKeyPtyIds', () => {
|
||||
it('maps layout leaves to pane keys and ignores empty bindings', () => {
|
||||
expect(
|
||||
indexPersistedPaneKeyPtyIds({
|
||||
'tab-1': { ptyIdsByLeafId: { [LEAF]: PTY, 'leaf-empty': '' } },
|
||||
'tab-2': undefined,
|
||||
'tab-3': {}
|
||||
})
|
||||
).toEqual(new Map([[PANE, PTY]]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveAgentWorkspaceExecutionHostId', () => {
|
||||
const localRepo = {
|
||||
id: 'local-repo',
|
||||
connectionId: null,
|
||||
executionHostId: 'local' as const
|
||||
}
|
||||
const runtimeRepo = {
|
||||
id: 'runtime-repo',
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:ephemeral-vm-1' as const
|
||||
}
|
||||
const futureHostRepo = {
|
||||
id: 'future-repo',
|
||||
connectionId: null,
|
||||
executionHostId: 'container:future-host'
|
||||
}
|
||||
const deps = {
|
||||
getRepo: (repoId: string) =>
|
||||
[localRepo, runtimeRepo, futureHostRepo].find((candidate) => candidate.id === repoId),
|
||||
getWorktreeMeta: (worktreeId: string) => {
|
||||
if (worktreeId === 'local-repo::/runtime-worktree') {
|
||||
return { hostId: 'runtime:worktree-owner' }
|
||||
}
|
||||
if (worktreeId === 'runtime-repo::/local-worktree') {
|
||||
return { hostId: 'local' }
|
||||
}
|
||||
if (worktreeId === 'local-repo::/future-worktree') {
|
||||
return { hostId: 'container:future-host' }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
getFolderWorkspace: (id: string) =>
|
||||
id === 'folder-runtime' ? { projectGroupId: 'group-runtime', connectionId: null } : undefined,
|
||||
getProjectGroups: () => [
|
||||
{
|
||||
id: 'group-runtime',
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:ephemeral-vm-1'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
it('positively identifies local ownership and rejects runtime hosts', () => {
|
||||
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/repo', deps)).toBe('local')
|
||||
expect(resolveAgentWorkspaceExecutionHostId('runtime-repo::/repo', deps)).toBe(
|
||||
'runtime:ephemeral-vm-1'
|
||||
)
|
||||
expect(resolveAgentWorkspaceExecutionHostId('folder:folder-runtime', deps)).toBe(
|
||||
'runtime:ephemeral-vm-1'
|
||||
)
|
||||
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/runtime-worktree', deps)).toBe(
|
||||
'runtime:worktree-owner'
|
||||
)
|
||||
expect(resolveAgentWorkspaceExecutionHostId('runtime-repo::/local-worktree', deps)).toBe(
|
||||
'local'
|
||||
)
|
||||
expect(resolveAgentWorkspaceExecutionHostId('future-repo::/repo', deps)).toBeNull()
|
||||
expect(resolveAgentWorkspaceExecutionHostId('local-repo::/future-worktree', deps)).toBeNull()
|
||||
expect(isLocalExecutionHost('local')).toBe(true)
|
||||
expect(isLocalExecutionHost('runtime:ephemeral-vm-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats missing workspace provenance as unknown', () => {
|
||||
expect(resolveAgentWorkspaceExecutionHostId('missing::/repo', deps)).toBeNull()
|
||||
expect(resolveAgentWorkspaceExecutionHostId(undefined, deps)).toBeNull()
|
||||
expect(isLocalExecutionHost(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import type { FolderWorkspace, ProjectGroup } from '../../shared/types'
|
||||
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
|
||||
export type RestoredSubagentLivenessSweepDeps = {
|
||||
/** Targeted provider liveness, or null when the provider cannot prove either state. */
|
||||
probeLiveLocalPty: (ptyId: string) => Promise<boolean | null>
|
||||
isLocalExecutionHost: (worktreeId: string | undefined) => boolean
|
||||
/** PTY bound to this pane in the current session, if it has one. */
|
||||
getBoundPtyIdForPaneKey: (paneKey: string) => string | undefined
|
||||
/** PTY this pane was bound to when the session was last persisted; covers panes
|
||||
* whose surviving daemon session has not been reattached yet. */
|
||||
getPersistedPtyIdForPaneKey: (paneKey: string) => string | undefined
|
||||
reap: (
|
||||
isLocalExecutionHost: (worktreeId: string | undefined) => boolean,
|
||||
isLocalPaneAgentLive: (paneKey: string) => Promise<boolean>,
|
||||
isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean
|
||||
) => Promise<number>
|
||||
}
|
||||
|
||||
/** Drop restored rows only when the owning host is local and its provider proves
|
||||
* the exact PTY absent. */
|
||||
export async function sweepRestoredSubagentsWithoutLiveAgent(
|
||||
deps: RestoredSubagentLivenessSweepDeps
|
||||
): Promise<number> {
|
||||
const probesByPtyId = new Map<string, Promise<boolean | null>>()
|
||||
const boundPtyIdAtProbeByPaneKey = new Map<string, string | undefined>()
|
||||
return await deps.reap(
|
||||
(worktreeId) => deps.isLocalExecutionHost(worktreeId),
|
||||
async (paneKey) => {
|
||||
const boundPtyId = deps.getBoundPtyIdForPaneKey(paneKey)
|
||||
boundPtyIdAtProbeByPaneKey.set(paneKey, boundPtyId)
|
||||
const ptyId = boundPtyId ?? deps.getPersistedPtyIdForPaneKey(paneKey)
|
||||
if (!ptyId) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
let probe = probesByPtyId.get(ptyId)
|
||||
if (!probe) {
|
||||
probe = deps.probeLiveLocalPty(ptyId)
|
||||
probesByPtyId.set(ptyId, probe)
|
||||
}
|
||||
const live = await probe
|
||||
const currentBoundPtyId = deps.getBoundPtyIdForPaneKey(paneKey)
|
||||
// Why: cold restore can rebind the persisted id while its absence probe is in flight.
|
||||
return currentBoundPtyId !== boundPtyId || live !== false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
},
|
||||
(paneKey) =>
|
||||
!boundPtyIdAtProbeByPaneKey.has(paneKey) ||
|
||||
deps.getBoundPtyIdForPaneKey(paneKey) === boundPtyIdAtProbeByPaneKey.get(paneKey)
|
||||
)
|
||||
}
|
||||
|
||||
/** Index the persisted terminal layouts as `paneKey -> ptyId`. Layout leaves are
|
||||
* the only persisted binding that carries a stable pane key, so tab-level PTY ids
|
||||
* (legacy numeric panes) are deliberately skipped. */
|
||||
export function indexPersistedPaneKeyPtyIds(
|
||||
layoutsByTabId: Record<string, { ptyIdsByLeafId?: Record<string, string> } | undefined>
|
||||
): Map<string, string> {
|
||||
const byPaneKey = new Map<string, string>()
|
||||
for (const [tabId, layout] of Object.entries(layoutsByTabId)) {
|
||||
for (const [leafId, ptyId] of Object.entries(layout?.ptyIdsByLeafId ?? {})) {
|
||||
if (ptyId) {
|
||||
byPaneKey.set(`${tabId}:${leafId}`, ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return byPaneKey
|
||||
}
|
||||
|
||||
type AgentWorkspaceExecutionHostDeps = {
|
||||
getRepo: (repoId: string) => ExecutionHostOwner | null | undefined
|
||||
getWorktreeMeta: (worktreeId: string) => { hostId?: string | null } | null | undefined
|
||||
getFolderWorkspace: (
|
||||
folderWorkspaceId: string
|
||||
) => Pick<FolderWorkspace, 'projectGroupId' | 'connectionId'> | null | undefined
|
||||
getProjectGroups: () => readonly Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
}
|
||||
|
||||
type ExecutionHostOwner = {
|
||||
connectionId?: string | null
|
||||
executionHostId?: string | null
|
||||
}
|
||||
|
||||
function resolveDeclaredExecutionHost(owner: ExecutionHostOwner): ExecutionHostId | null {
|
||||
if (owner.executionHostId?.trim()) {
|
||||
return parseExecutionHostId(owner.executionHostId)?.id ?? null
|
||||
}
|
||||
const connectionId = owner.connectionId?.trim()
|
||||
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
/** Resolve persisted workspace ownership; unknown provenance is not local authority. */
|
||||
export function resolveAgentWorkspaceExecutionHostId(
|
||||
workspaceId: string | undefined,
|
||||
deps: AgentWorkspaceExecutionHostDeps
|
||||
): ExecutionHostId | null {
|
||||
if (!workspaceId) {
|
||||
return null
|
||||
}
|
||||
const scope = parseWorkspaceKey(workspaceId)
|
||||
if (scope?.type === 'folder') {
|
||||
const workspace = deps.getFolderWorkspace(scope.folderWorkspaceId)
|
||||
const group = workspace
|
||||
? deps.getProjectGroups().find((candidate) => candidate.id === workspace.projectGroupId)
|
||||
: undefined
|
||||
if (!workspace || !group) {
|
||||
return null
|
||||
}
|
||||
return resolveDeclaredExecutionHost({
|
||||
connectionId: workspace.connectionId ?? group.connectionId,
|
||||
executionHostId: group.executionHostId
|
||||
})
|
||||
}
|
||||
const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId
|
||||
const declaredWorktreeHost = deps.getWorktreeMeta(worktreeId)?.hostId?.trim()
|
||||
if (declaredWorktreeHost) {
|
||||
return parseExecutionHostId(declaredWorktreeHost)?.id ?? null
|
||||
}
|
||||
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
|
||||
return repo ? resolveDeclaredExecutionHost(repo) : null
|
||||
}
|
||||
|
||||
export function isLocalExecutionHost(hostId: string | null | undefined): boolean {
|
||||
return parseExecutionHostId(hostId)?.kind === 'local'
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AgentHookEventPayload } from '../../shared/agent-hook-listener'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { AgentHookServer } from './server'
|
||||
|
||||
const PANE_KEY = makePaneKey('tab-authority', '11111111-1111-4111-8111-111111111111')
|
||||
const SECOND_PANE_KEY = makePaneKey('tab-authority-2', '22222222-2222-4222-8222-222222222222')
|
||||
|
||||
describe('AgentHookServer authority evidence', () => {
|
||||
const servers: AgentHookServer[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
servers.length = 0
|
||||
})
|
||||
|
||||
it('freezes pre-listen commitments separately from current-runtime observations', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
const hydrated = {
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'launch-before-restart',
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::before',
|
||||
connectionId: 'ssh-target',
|
||||
payload: { state: 'working', prompt: 'before', agentType: 'codex' },
|
||||
receivedAt: 100,
|
||||
stateStartedAt: 100
|
||||
} satisfies AgentHookEventPayload & { receivedAt: number; stateStartedAt: number }
|
||||
server._getStateForTests().lastStatusByPaneKey.set(PANE_KEY, hydrated)
|
||||
|
||||
await server.start()
|
||||
const commitments = server.getHydratedAuthorityCommitments()
|
||||
|
||||
expect(commitments).toEqual([
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::before',
|
||||
connectionId: 'ssh-target',
|
||||
observedAt: 100
|
||||
}
|
||||
])
|
||||
expect(Object.isFrozen(commitments)).toBe(true)
|
||||
expect(Object.isFrozen(commitments[0])).toBe(true)
|
||||
expect(server.getCurrentAuthorityObservations()).toEqual([])
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'hydrated_commitment' })
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'launch-after-restart',
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::after',
|
||||
payload: { state: 'working', prompt: 'after', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
|
||||
expect(JSON.stringify(commitments)).not.toContain('launch-before-restart')
|
||||
expect(server.getCurrentAuthorityObservations()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
connectionId: 'ssh-target',
|
||||
worktreeId: 'repo::after'
|
||||
})
|
||||
])
|
||||
expect(JSON.stringify(server.getCurrentAuthorityObservations())).not.toContain(
|
||||
'launch-after-restart'
|
||||
)
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'current_runtime'
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-after-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-after-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'current_runtime'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken: 'launch-before-restart',
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::current',
|
||||
payload: { state: 'working', prompt: 'current', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: SECOND_PANE_KEY,
|
||||
launchToken: 'launch-before-restart',
|
||||
tabId: 'tab-authority-2',
|
||||
payload: { state: 'working', prompt: 'duplicate', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
|
||||
server.clearStatusEntriesForConnection('ssh-target')
|
||||
|
||||
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
|
||||
expect(server.getCurrentAuthorityObservations()).toEqual([])
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'hydrated_commitment' })
|
||||
|
||||
server.clearPaneState(PANE_KEY)
|
||||
|
||||
expect(server.getHydratedAuthorityCommitments()).toBe(commitments)
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash: createHash('sha256').update('launch-before-restart').digest('hex'),
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves hydrated authority when stale alias cleanup does not own the stable pane', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
const launchToken = 'launch-before-restart'
|
||||
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
|
||||
const hydrated = {
|
||||
paneKey: PANE_KEY,
|
||||
launchToken,
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::before',
|
||||
connectionId: 'ssh-target',
|
||||
payload: { state: 'working', prompt: 'before', agentType: 'codex' },
|
||||
receivedAt: 100,
|
||||
stateStartedAt: 100
|
||||
} satisfies AgentHookEventPayload & { receivedAt: number; stateStartedAt: number }
|
||||
server._getStateForTests().lastStatusByPaneKey.set(PANE_KEY, hydrated)
|
||||
server.registerPaneKeyAlias('tab-authority:0', PANE_KEY, 'old-pty')
|
||||
await server.start()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE_KEY,
|
||||
launchToken,
|
||||
tabId: 'tab-authority',
|
||||
worktreeId: 'repo::current',
|
||||
payload: { state: 'working', prompt: 'current', agentType: 'codex' }
|
||||
},
|
||||
'ssh-target'
|
||||
)
|
||||
|
||||
server.clearPaneKeyAliasesForPty('old-pty', { shouldClearStablePaneKey: () => false })
|
||||
|
||||
expect(
|
||||
server.attestCompatibilityAuthority({
|
||||
paneKey: PANE_KEY,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE_KEY, source: 'current_hook' })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: this suite exercises the full hook HTTP surface (Claude/Codex/Gemini parsing, transcript chunked scan, paneKey dispatch) and keeping the scenarios co-located avoids fixture drift across files. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -57,6 +58,7 @@ const TAB_A_PANE = makePaneKey('tab-A', LEAF_5)
|
||||
|
||||
type Body = {
|
||||
paneKey: string
|
||||
launchToken?: string
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
env?: string
|
||||
@@ -6254,7 +6256,10 @@ describe('Last-status persistence', () => {
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'persist me' })
|
||||
buildBody(
|
||||
{ hook_event_name: 'UserPromptSubmit', prompt: 'persist me' },
|
||||
{ launchToken: 'launch-bearer-must-not-persist' }
|
||||
)
|
||||
)
|
||||
// Synchronous flush via stop() captures the trailing-debounced write.
|
||||
server.flushStatusPersistSync()
|
||||
@@ -6269,11 +6274,148 @@ describe('Last-status persistence', () => {
|
||||
stateStartedAt: expect.any(Number),
|
||||
payload: expect.objectContaining({ state: 'working', prompt: 'persist me' })
|
||||
})
|
||||
expect(file.entries[PANE].launchToken).toBeUndefined()
|
||||
expect(file.entries[PANE].launchTokenHash).toBe(
|
||||
createHash('sha256').update('launch-bearer-must-not-persist').digest('hex')
|
||||
)
|
||||
expect(readFileSync(lastStatusPath(), 'utf8')).not.toContain('launch-bearer-must-not-persist')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('scrubs a legacy persisted launch bearer while retaining its authority commitment', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
const receivedAt = recentTs()
|
||||
const launchToken = 'legacy-launch-bearer'
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
[PANE]: {
|
||||
paneKey: PANE,
|
||||
launchToken,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: null,
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'legacy worker',
|
||||
agentType: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
|
||||
expect(server.getHydratedAuthorityCommitments()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, launchTokenHash })
|
||||
])
|
||||
expect(server.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined()
|
||||
|
||||
const persisted = readFileSync(lastStatusPath(), 'utf8')
|
||||
expect(persisted).not.toContain(launchToken)
|
||||
expect(JSON.parse(persisted).entries[PANE]).toMatchObject({ launchTokenHash })
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
|
||||
const restartedServer = new AgentHookServer()
|
||||
await restartedServer.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
expect(restartedServer.getHydratedAuthorityCommitments()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
launchTokenHash: createHash('sha256').update(launchToken).digest('hex')
|
||||
})
|
||||
])
|
||||
expect(restartedServer.getStatusSnapshotForPane(PANE)[0]?.launchToken).toBeUndefined()
|
||||
} finally {
|
||||
restartedServer.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists SSH authority through transient clears until explicit retirement', async () => {
|
||||
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
|
||||
const receivedAt = recentTs()
|
||||
const launchToken = 'retained-ssh-launch-bearer'
|
||||
const launchTokenHash = createHash('sha256').update(launchToken).digest('hex')
|
||||
writeFileSync(
|
||||
lastStatusPath(),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
entries: {
|
||||
[PANE]: {
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
connectionId: 'ssh-target',
|
||||
receivedAt,
|
||||
stateStartedAt: receivedAt,
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'retained SSH worker',
|
||||
agentType: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const first = new AgentHookServer()
|
||||
await first.start({ env: 'production', userDataPath })
|
||||
first.clearStatusEntriesForConnection('ssh-target')
|
||||
first.flushStatusPersistSync()
|
||||
first.stop()
|
||||
|
||||
const afterClear = JSON.parse(readFileSync(lastStatusPath(), 'utf8'))
|
||||
expect(afterClear.entries).toEqual({})
|
||||
expect(afterClear.authorityCommitments[PANE]).toMatchObject({
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target'
|
||||
})
|
||||
|
||||
const restored = new AgentHookServer()
|
||||
await restored.start({ env: 'production', userDataPath })
|
||||
expect(
|
||||
restored.attestCompatibilityAuthority({
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toEqual({ paneKey: PANE, source: 'hydrated_commitment' })
|
||||
restored.retirePaneAuthority(PANE)
|
||||
restored.flushStatusPersistSync()
|
||||
restored.stop()
|
||||
|
||||
const retired = new AgentHookServer()
|
||||
await retired.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
expect(
|
||||
retired.attestCompatibilityAuthority({
|
||||
paneKey: PANE,
|
||||
launchTokenHash,
|
||||
connectionId: 'ssh-target',
|
||||
terminalProvenance: 'restored'
|
||||
})
|
||||
).toBeNull()
|
||||
} finally {
|
||||
retired.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists and hydrates Pi session identity without creating status telemetry', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
const firstRendererListener = vi.fn()
|
||||
@@ -7640,6 +7782,82 @@ describe('AgentHookServer ingestRemote', () => {
|
||||
})
|
||||
|
||||
describe('AgentHookServer ingestTerminalStatus', () => {
|
||||
// Why: the OSC 9999 payload cannot carry a provider session, so letting it overwrite the row
|
||||
// erased the session id from persisted rows and from headless `orca serve` — which serves these
|
||||
// rows straight to mobile — leaving Chat UI with no transcript to subscribe to (#10630).
|
||||
it('keeps the cached provider session when an OSC status completes the turn', () => {
|
||||
const server = new AgentHookServer()
|
||||
const providerSession = { key: 'session_id' as const, id: 'claude-session-1' }
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
providerSession,
|
||||
payload: { state: 'working', prompt: 'summarize the diff', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
connectionId: 'conn-1',
|
||||
payload: { state: 'done', prompt: 'summarize the diff', agentType: 'claude' }
|
||||
})
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', providerSession })
|
||||
})
|
||||
|
||||
// Why: an OSC ping that names no agent — or the literal 'unknown', which
|
||||
// resolveAgentStatusIdentity treats identically — makes no claim about the pane's identity, so it
|
||||
// must not read as a mismatch. Missing this stripped the session from persisted and headless rows
|
||||
// while the live renderer kept it, blanking mobile chat only after a restart (#10630).
|
||||
it.each([undefined, 'unknown' as const])(
|
||||
'keeps the cached provider session when an OSC status claims agentType %s',
|
||||
(agentType) => {
|
||||
const server = new AgentHookServer()
|
||||
const providerSession = { key: 'session_id' as const, id: 'claude-session-1' }
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
providerSession,
|
||||
payload: { state: 'working', prompt: 'summarize the diff', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
connectionId: 'conn-1',
|
||||
payload: {
|
||||
state: 'done',
|
||||
prompt: 'summarize the diff',
|
||||
...(agentType ? { agentType } : {})
|
||||
}
|
||||
})
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ providerSession })
|
||||
}
|
||||
)
|
||||
|
||||
it('drops the cached provider session when an OSC status starts a new turn', () => {
|
||||
const server = new AgentHookServer()
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
providerSession: { key: 'session_id' as const, id: 'claude-session-1' },
|
||||
payload: { state: 'done', prompt: 'first', agentType: 'claude' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
connectionId: 'conn-1',
|
||||
payload: { state: 'working', prompt: 'second', agentType: 'claude' }
|
||||
})
|
||||
|
||||
expect(server.getStatusSnapshot()[0]?.providerSession).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards runtime terminal status through the normal listener and snapshot path', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
|
||||
+474
-18
@@ -25,6 +25,7 @@ import {
|
||||
normalizeHookPayload,
|
||||
parseFormEncodedBody,
|
||||
readRequestBody,
|
||||
reapRestoredClaudeSubagentsForDeadPane,
|
||||
reconcileRemoteCodexState,
|
||||
resolveHookSource,
|
||||
preparePendingGrokResultDiscovery,
|
||||
@@ -35,6 +36,11 @@ import {
|
||||
type AgentHookEventPayload,
|
||||
type HookListenerState
|
||||
} from '../../shared/agent-hook-listener'
|
||||
import {
|
||||
claudeRosterHasRestoredSnapshotSubagent,
|
||||
claudeRosterHasWorkingSubagent,
|
||||
claudeRosterToSnapshots
|
||||
} from '../../shared/claude-subagent-roster'
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import {
|
||||
CLAUDE_STATUSLINE_PATHNAME,
|
||||
@@ -79,6 +85,22 @@ type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
|
||||
stateStartedAt: number
|
||||
}
|
||||
|
||||
type PersistedAgentHookEventPayload = Omit<
|
||||
EnrichedAgentHookEventPayload,
|
||||
'launchToken' | 'promptInteractionKey'
|
||||
> & {
|
||||
launchTokenHash?: string
|
||||
}
|
||||
|
||||
type PersistedAgentHookAuthorityCommitment = {
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
connectionId: string | null
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
observedAt: number
|
||||
}
|
||||
|
||||
export type AgentHookStatusChangeEntry = {
|
||||
state: AgentStatusState
|
||||
receivedAt: number
|
||||
@@ -92,6 +114,20 @@ export type AgentHookProviderSessionIdentity = {
|
||||
worktreeId?: string
|
||||
}
|
||||
|
||||
export type AgentHookAuthorityEvidence = Readonly<{
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
connectionId: string | null
|
||||
tabId?: string
|
||||
worktreeId?: string
|
||||
observedAt: number
|
||||
}>
|
||||
|
||||
export type AgentHookAuthorityAttestation = Readonly<{
|
||||
paneKey: string
|
||||
source: 'current_hook' | 'hydrated_commitment'
|
||||
}>
|
||||
|
||||
type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void
|
||||
type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void
|
||||
type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void
|
||||
@@ -128,7 +164,8 @@ export const PANE_KEY_ALIASES_MAX = 1024
|
||||
|
||||
type LastStatusFile = {
|
||||
version: number
|
||||
entries: Record<string, EnrichedAgentHookEventPayload>
|
||||
entries: Record<string, PersistedAgentHookEventPayload>
|
||||
authorityCommitments?: Record<string, PersistedAgentHookAuthorityCommitment>
|
||||
}
|
||||
|
||||
type AgentPromptSentDedupeEntry = {
|
||||
@@ -251,7 +288,6 @@ function sanitizeHydratedEntry(
|
||||
}
|
||||
return {
|
||||
paneKey,
|
||||
launchToken: typeof record.launchToken === 'string' ? record.launchToken : undefined,
|
||||
tabId: typeof tabId === 'string' ? tabId : undefined,
|
||||
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
|
||||
connectionId,
|
||||
@@ -268,6 +304,63 @@ function sanitizeHydratedEntry(
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedLaunchTokenHash(rawEntry: unknown): string | null {
|
||||
if (typeof rawEntry !== 'object' || rawEntry === null) {
|
||||
return null
|
||||
}
|
||||
const record = rawEntry as Record<string, unknown>
|
||||
const launchTokenHash =
|
||||
typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : ''
|
||||
if (/^[a-f0-9]{64}$/.test(launchTokenHash)) {
|
||||
return launchTokenHash
|
||||
}
|
||||
const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : ''
|
||||
return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null
|
||||
}
|
||||
|
||||
function sanitizePersistedAuthorityCommitment(
|
||||
paneKey: string,
|
||||
value: unknown
|
||||
): AgentHookAuthorityEvidence | null {
|
||||
if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) {
|
||||
return null
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const launchTokenHash =
|
||||
typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : ''
|
||||
const connectionId = record.connectionId
|
||||
const observedAt = record.observedAt
|
||||
if (
|
||||
!/^[a-f0-9]{64}$/.test(launchTokenHash) ||
|
||||
(connectionId !== null && typeof connectionId !== 'string') ||
|
||||
typeof observedAt !== 'number' ||
|
||||
!Number.isFinite(observedAt)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return Object.freeze({
|
||||
paneKey,
|
||||
launchTokenHash,
|
||||
connectionId,
|
||||
...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}),
|
||||
...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}),
|
||||
observedAt
|
||||
})
|
||||
}
|
||||
|
||||
function authorityCommitmentsMatch(
|
||||
left: AgentHookAuthorityEvidence,
|
||||
right: AgentHookAuthorityEvidence
|
||||
): boolean {
|
||||
return (
|
||||
left.paneKey === right.paneKey &&
|
||||
left.launchTokenHash === right.launchTokenHash &&
|
||||
left.connectionId === right.connectionId &&
|
||||
left.tabId === right.tabId &&
|
||||
left.worktreeId === right.worktreeId
|
||||
)
|
||||
}
|
||||
|
||||
function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload {
|
||||
return {
|
||||
paneKey: entry.paneKey,
|
||||
@@ -479,6 +572,11 @@ export class AgentHookServer {
|
||||
private state: HookListenerState = createHookListenerState()
|
||||
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
|
||||
private runtimeObservedStatusPaneKeys = new Set<string>()
|
||||
private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([])
|
||||
private hydratedLaunchTokenHashByPaneKey = new Map<string, string>()
|
||||
private persistedAuthorityCommitmentsByPaneKey = new Map<string, AgentHookAuthorityEvidence>()
|
||||
private revokedHydratedAuthorityCommitments = new WeakSet<AgentHookAuthorityEvidence>()
|
||||
private currentAuthorityObservations = new Map<string, AgentHookAuthorityEvidence>()
|
||||
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
|
||||
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
|
||||
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
|
||||
@@ -562,6 +660,53 @@ export class AgentHookServer {
|
||||
return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : []
|
||||
}
|
||||
|
||||
getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] {
|
||||
return this.hydratedAuthorityCommitments
|
||||
}
|
||||
|
||||
getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] {
|
||||
return Object.freeze(
|
||||
Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry }))
|
||||
)
|
||||
}
|
||||
|
||||
attestCompatibilityAuthority(candidate: {
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
connectionId: string | null
|
||||
terminalProvenance: 'current_runtime' | 'restored'
|
||||
}): AgentHookAuthorityAttestation | null {
|
||||
const paneKey = this.resolvePaneKeyAlias(candidate.paneKey)
|
||||
const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean =>
|
||||
entry.launchTokenHash === candidate.launchTokenHash &&
|
||||
entry.connectionId === candidate.connectionId
|
||||
const commitments = this.hydratedAuthorityCommitments.filter(
|
||||
(entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry)
|
||||
)
|
||||
const current = Array.from(this.currentAuthorityObservations.values())
|
||||
const observations = current.filter(matchesCandidate)
|
||||
const paneObservations = current.filter(
|
||||
(entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey
|
||||
)
|
||||
const hasUniqueCurrentObservation =
|
||||
observations.length === 1 &&
|
||||
paneObservations.length === 1 &&
|
||||
this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey
|
||||
if (candidate.terminalProvenance === 'current_runtime') {
|
||||
return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null
|
||||
}
|
||||
if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) {
|
||||
return null
|
||||
}
|
||||
if (observations.length === 0 && paneObservations.length === 0) {
|
||||
return Object.freeze({ paneKey, source: 'hydrated_commitment' })
|
||||
}
|
||||
if (!hasUniqueCurrentObservation) {
|
||||
return null
|
||||
}
|
||||
return Object.freeze({ paneKey, source: 'current_hook' })
|
||||
}
|
||||
|
||||
inferInterrupt(request: AgentInterruptInferenceRequest): boolean {
|
||||
if (!isValidPaneKey(request.paneKey)) {
|
||||
return false
|
||||
@@ -1301,9 +1446,40 @@ export class AgentHookServer {
|
||||
tabId: owner?.tabId
|
||||
})
|
||||
}
|
||||
const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey)
|
||||
if (hydratedLaunchTokenHash) {
|
||||
this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey)
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash)
|
||||
}
|
||||
const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey)
|
||||
if (persistedAuthority) {
|
||||
const owner = parsePaneKey(toPaneKey)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(
|
||||
toPaneKey,
|
||||
Object.freeze({
|
||||
...persistedAuthority,
|
||||
paneKey: toPaneKey,
|
||||
...(owner?.tabId ? { tabId: owner.tabId } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) {
|
||||
this.runtimeObservedStatusPaneKeys.add(toPaneKey)
|
||||
}
|
||||
const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey)
|
||||
if (authorityObservation) {
|
||||
const owner = parsePaneKey(toPaneKey)
|
||||
this.currentAuthorityObservations.delete(previousOwnerPaneKey)
|
||||
this.currentAuthorityObservations.set(
|
||||
toPaneKey,
|
||||
Object.freeze({
|
||||
...authorityObservation,
|
||||
paneKey: toPaneKey,
|
||||
tabId: owner?.tabId
|
||||
})
|
||||
)
|
||||
}
|
||||
const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey)
|
||||
if (promptDedupe !== undefined) {
|
||||
this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey)
|
||||
@@ -1321,7 +1497,7 @@ export class AgentHookServer {
|
||||
this.boundPaneKeyAliases()
|
||||
this.closedAgentStatusPaneKeys.delete(toPaneKey)
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
if (hadStatus) {
|
||||
if (hadStatus || persistedAuthority) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -1339,6 +1515,7 @@ export class AgentHookServer {
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
|
||||
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
|
||||
for (const key of paneKeys) {
|
||||
this.markPaneClosedForAgentStatus(key)
|
||||
@@ -1346,12 +1523,13 @@ export class AgentHookServer {
|
||||
this.clearCodexSubagentPoll(key)
|
||||
clearPaneCacheState(this.state, key)
|
||||
this.runtimeObservedStatusPaneKeys.delete(key)
|
||||
this.currentAuthorityObservations.delete(key)
|
||||
this.promptSentDedupeByPaneKey.delete(key)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (hadStatus) {
|
||||
if (hadStatus || authorityChanged) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -1366,11 +1544,19 @@ export class AgentHookServer {
|
||||
const clearedStatusPaneKeys = new Set<string>()
|
||||
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
if (entry.ptyId === ptyId) {
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
const shouldClearStablePaneKey =
|
||||
options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true
|
||||
const revokedPaneKeys = new Set([legacyPaneKey])
|
||||
if (shouldClearStablePaneKey) {
|
||||
revokedPaneKeys.add(entry.stablePaneKey)
|
||||
}
|
||||
if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) {
|
||||
statusChanged = true
|
||||
}
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) {
|
||||
statusChanged = true
|
||||
clearedStatusPaneKeys.add(entry.stablePaneKey)
|
||||
@@ -1379,6 +1565,7 @@ export class AgentHookServer {
|
||||
// Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor.
|
||||
clearPaneCacheState(this.state, entry.stablePaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey)
|
||||
this.currentAuthorityObservations.delete(entry.stablePaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey)
|
||||
}
|
||||
aliasChanged = true
|
||||
@@ -1400,6 +1587,27 @@ export class AgentHookServer {
|
||||
return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey
|
||||
}
|
||||
|
||||
private revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet<string>): boolean {
|
||||
let changed = false
|
||||
for (const commitment of this.hydratedAuthorityCommitments) {
|
||||
if (
|
||||
paneKeys.has(commitment.paneKey) ||
|
||||
paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey))
|
||||
) {
|
||||
this.revokedHydratedAuthorityCommitments.add(commitment)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for (const paneKey of paneKeys) {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed
|
||||
changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed
|
||||
changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed
|
||||
changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
private normalizeHookBodyPaneKeyAlias(body: unknown): unknown {
|
||||
if (typeof body !== 'object' || body === null) {
|
||||
return body
|
||||
@@ -1463,12 +1671,31 @@ export class AgentHookServer {
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is
|
||||
// never evidence that the session ended — yet overwriting the row dropped the cached identity.
|
||||
// That erased it from persisted rows (lost across restart) and from headless `orca serve`, which
|
||||
// serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630).
|
||||
// A new turn after `done` still starts clean so a reused pane cannot inherit a finished session.
|
||||
// Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an
|
||||
// omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so
|
||||
// it must not be read as a mismatch and strip the session the renderer would have kept.
|
||||
const claimedAgentType =
|
||||
event.payload.agentType && event.payload.agentType !== 'unknown'
|
||||
? event.payload.agentType
|
||||
: undefined
|
||||
const preservedProviderSession =
|
||||
previous?.providerSession &&
|
||||
(claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) &&
|
||||
(previous.payload.state !== 'done' || event.payload.state === 'done')
|
||||
? previous.providerSession
|
||||
: undefined
|
||||
// Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks.
|
||||
this.applyNormalizedStatus({
|
||||
paneKey,
|
||||
tabId,
|
||||
worktreeId,
|
||||
connectionId,
|
||||
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
|
||||
payload: event.payload
|
||||
})
|
||||
}
|
||||
@@ -1602,6 +1829,7 @@ export class AgentHookServer {
|
||||
isReplay: envelope.isReplay === true ? true : undefined,
|
||||
payload: normalizedPayload
|
||||
}
|
||||
this.recordCurrentAuthorityObservation(event)
|
||||
this.applyNormalizedStatus(event)
|
||||
}
|
||||
|
||||
@@ -1632,6 +1860,7 @@ export class AgentHookServer {
|
||||
if (this.lastStatusFilePath) {
|
||||
this.hydrateLastStatusFromDisk()
|
||||
}
|
||||
this.captureHydratedAuthorityCommitments()
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(404)
|
||||
@@ -1673,6 +1902,7 @@ export class AgentHookServer {
|
||||
const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body)
|
||||
const normalized = normalizeHookPayload(this.state, source, aliasedBody, this.env)
|
||||
if (normalized && !this.shouldSuppressClosedTabStatus(normalized.paneKey)) {
|
||||
this.recordCurrentAuthorityObservation(normalized)
|
||||
const enriched = this.applyNormalizedStatus(normalized)
|
||||
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
|
||||
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
|
||||
@@ -1739,6 +1969,11 @@ export class AgentHookServer {
|
||||
this.lastStatusFilePath = null
|
||||
this.lastWrittenJson = null
|
||||
this.runtimeObservedStatusPaneKeys.clear()
|
||||
this.hydratedAuthorityCommitments = Object.freeze([])
|
||||
this.hydratedLaunchTokenHashByPaneKey.clear()
|
||||
this.persistedAuthorityCommitmentsByPaneKey.clear()
|
||||
this.revokedHydratedAuthorityCommitments = new WeakSet()
|
||||
this.currentAuthorityObservations.clear()
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
this.closedAgentStatusTabIds.clear()
|
||||
this.closedAgentStatusPaneKeys.clear()
|
||||
@@ -1750,7 +1985,7 @@ export class AgentHookServer {
|
||||
|
||||
/** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */
|
||||
dropStatusEntry(paneKey: string): void {
|
||||
if (!this.deleteStatusEntry(paneKey)) {
|
||||
if (!this.deleteStatusEntry(paneKey, { preserveAuthority: true })) {
|
||||
return
|
||||
}
|
||||
this.scheduleStatusPersist()
|
||||
@@ -1775,7 +2010,7 @@ export class AgentHookServer {
|
||||
if (entry.connectionId !== normalizedConnectionId) {
|
||||
continue
|
||||
}
|
||||
const deleted = this.deleteStatusEntry(paneKey)
|
||||
const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true })
|
||||
if (deleted) {
|
||||
statusChanged = true
|
||||
if (deleted.payload.agentType === 'codex') {
|
||||
@@ -1785,6 +2020,11 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [paneKey, evidence] of this.currentAuthorityObservations) {
|
||||
if (evidence.connectionId === normalizedConnectionId) {
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
}
|
||||
}
|
||||
if (statusChanged) {
|
||||
// Why: persist/notify once — one disconnect can own many panes.
|
||||
this.scheduleStatusPersist()
|
||||
@@ -1798,7 +2038,10 @@ export class AgentHookServer {
|
||||
})
|
||||
}
|
||||
|
||||
private deleteStatusEntry(paneKey: string): EnrichedAgentHookEventPayload | null {
|
||||
private deleteStatusEntry(
|
||||
paneKey: string,
|
||||
options?: { preserveAuthority?: boolean }
|
||||
): EnrichedAgentHookEventPayload | null {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
@@ -1807,9 +2050,14 @@ export class AgentHookServer {
|
||||
return null
|
||||
}
|
||||
this.state.lastStatusByPaneKey.delete(resolvedPaneKey)
|
||||
if (!options?.preserveAuthority) {
|
||||
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
|
||||
}
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.clearCodexSubagentPoll(resolvedPaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
if (existing.payload.state === 'done') {
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
}
|
||||
@@ -1854,6 +2102,11 @@ export class AgentHookServer {
|
||||
paneKeysToClear.add(paneKey)
|
||||
}
|
||||
}
|
||||
for (const commitment of this.hydratedAuthorityCommitments) {
|
||||
if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) {
|
||||
paneKeysToClear.add(commitment.paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
let aliasChanged = false
|
||||
for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
@@ -1867,6 +2120,7 @@ export class AgentHookServer {
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear)
|
||||
|
||||
let statusChanged = false
|
||||
for (const paneKey of paneKeysToClear) {
|
||||
@@ -1877,12 +2131,13 @@ export class AgentHookServer {
|
||||
this.clearCodexSubagentPoll(paneKey)
|
||||
clearPaneCacheState(this.state, paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
this.currentAuthorityObservations.delete(paneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(paneKey)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (statusChanged) {
|
||||
if (statusChanged || authorityChanged) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
@@ -1890,25 +2145,31 @@ export class AgentHookServer {
|
||||
|
||||
clearPaneState(paneKey: string): void {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const paneKeys = new Set([paneKey, resolvedPaneKey])
|
||||
// Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file.
|
||||
const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey)
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.clearCodexSubagentPoll(resolvedPaneKey)
|
||||
clearPaneCacheState(this.state, resolvedPaneKey)
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(resolvedPaneKey)
|
||||
let clearedAlias = false
|
||||
for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) {
|
||||
if (stablePaneKey.stablePaneKey === resolvedPaneKey) {
|
||||
this.legacyPaneKeyAliases.delete(legacyPaneKey)
|
||||
paneKeys.add(legacyPaneKey)
|
||||
paneKeys.add(stablePaneKey.stablePaneKey)
|
||||
clearPaneCacheState(this.state, legacyPaneKey)
|
||||
this.currentAuthorityObservations.delete(legacyPaneKey)
|
||||
this.promptSentDedupeByPaneKey.delete(legacyPaneKey)
|
||||
clearedAlias = true
|
||||
}
|
||||
}
|
||||
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
|
||||
if (clearedAlias) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
if (hadStatus) {
|
||||
if (hadStatus || authorityChanged) {
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
@@ -1916,6 +2177,91 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Second reap path for restored Claude subagent rows: drop the ones whose pane
|
||||
* has no live local agent process behind it any more. A PTY that dies while Orca
|
||||
* is down never runs the teardown that clears pane state, so hydrate rebuilds a
|
||||
* roster nothing can ever retire — the inventory reap needs the parent to emit a
|
||||
* complete `background_tasks` list and an idle parent never does. The row then
|
||||
* gates the pane 'working' for the rest of its life and hibernation, which
|
||||
* requires 'done', can never reclaim the agent's heap.
|
||||
*
|
||||
* Both the execution host and relay binding must prove local ownership before
|
||||
* targeted PTY liveness is consulted. Panes that reported in this runtime are
|
||||
* also skipped. Returns the number of panes changed. */
|
||||
async reapRestoredClaudeSubagentsWithoutLiveAgent(
|
||||
isLocalExecutionHost: (worktreeId: string | undefined) => boolean,
|
||||
isLocalPaneAgentLive: (paneKey: string) => Promise<boolean>,
|
||||
isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean
|
||||
): Promise<number> {
|
||||
const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = []
|
||||
for (const [paneKey, entry] of this.state.lastStatusByPaneKey) {
|
||||
const enriched = entry as EnrichedAgentHookEventPayload
|
||||
if (
|
||||
enriched.payload.agentType === 'claude' &&
|
||||
enriched.connectionId === null &&
|
||||
isLocalExecutionHost(enriched.worktreeId) &&
|
||||
claudeRosterHasRestoredSnapshotSubagent(
|
||||
this.state.claudeSubagentRosterByPaneKey.get(paneKey)
|
||||
) &&
|
||||
!this.runtimeObservedStatusPaneKeys.has(paneKey)
|
||||
) {
|
||||
candidates.push({ paneKey, entry: enriched })
|
||||
}
|
||||
}
|
||||
const liveness = await Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
try {
|
||||
return await isLocalPaneAgentLive(candidate.paneKey)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
)
|
||||
let changedPanes = 0
|
||||
for (const [index, candidate] of candidates.entries()) {
|
||||
const { paneKey, entry: enriched } = candidate
|
||||
if (
|
||||
liveness[index] ||
|
||||
!isLocalPaneLivenessEvidenceCurrent(paneKey) ||
|
||||
this.state.lastStatusByPaneKey.get(paneKey) !== enriched ||
|
||||
this.runtimeObservedStatusPaneKeys.has(paneKey) ||
|
||||
!isLocalExecutionHost(enriched.worktreeId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) {
|
||||
continue
|
||||
}
|
||||
changedPanes += 1
|
||||
const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey)
|
||||
const subagents = claudeRosterToSnapshots(roster)
|
||||
// Why: the pane's persisted 'working' was the child gate holding a finished
|
||||
// lead open (subagent events never set lead state). With the last working row
|
||||
// gone and no process left to report, 'done' is the only truthful state — and
|
||||
// the one hibernation needs once this pane's agent is restored.
|
||||
const state =
|
||||
enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster)
|
||||
? 'done'
|
||||
: enriched.payload.state
|
||||
const stateChanged = state !== enriched.payload.state
|
||||
const reconciledAt = stateChanged
|
||||
? Math.max(Date.now(), enriched.receivedAt + 1)
|
||||
: enriched.receivedAt
|
||||
const reconciled: EnrichedAgentHookEventPayload = {
|
||||
...enriched,
|
||||
receivedAt: reconciledAt,
|
||||
stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt,
|
||||
payload: { ...enriched.payload, state, subagents }
|
||||
}
|
||||
this.state.lastStatusByPaneKey.set(paneKey, reconciled)
|
||||
}
|
||||
if (changedPanes > 0) {
|
||||
this.scheduleStatusPersist()
|
||||
this.notifyStatusChangeListeners()
|
||||
}
|
||||
return changedPanes
|
||||
}
|
||||
|
||||
buildPtyEnv(): Record<string, string> {
|
||||
if (this.port <= 0 || !this.token) {
|
||||
return {}
|
||||
@@ -1963,6 +2309,8 @@ export class AgentHookServer {
|
||||
}
|
||||
// Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
|
||||
this.state.lastStatusByPaneKey.clear()
|
||||
this.hydratedLaunchTokenHashByPaneKey.clear()
|
||||
this.persistedAuthorityCommitmentsByPaneKey.clear()
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(this.lastStatusFilePath, 'utf8')
|
||||
@@ -2001,6 +2349,7 @@ export class AgentHookServer {
|
||||
let hydrated = 0
|
||||
let dropped = 0
|
||||
let prunedLegacyClaudeSubagents = 0
|
||||
let scrubbedLegacyLaunchTokens = 0
|
||||
// Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff).
|
||||
const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS
|
||||
for (const [paneKey, rawEntry] of Object.entries(entries)) {
|
||||
@@ -2011,6 +2360,21 @@ export class AgentHookServer {
|
||||
: { ...(rawEntry as Record<string, unknown>), paneKey: resolvedPaneKey }
|
||||
const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry)
|
||||
if (entry && entry.receivedAt >= ttlCutoff) {
|
||||
const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry)
|
||||
if (launchTokenHash) {
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash)
|
||||
const evidence = this.toAuthorityEvidence(entry, launchTokenHash)
|
||||
if (evidence) {
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence)
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof rawResolvedEntry === 'object' &&
|
||||
rawResolvedEntry !== null &&
|
||||
typeof (rawResolvedEntry as Record<string, unknown>).launchToken === 'string'
|
||||
) {
|
||||
scrubbedLegacyLaunchTokens += 1
|
||||
}
|
||||
const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload)
|
||||
if (hydratedPayload !== entry.payload) {
|
||||
prunedLegacyClaudeSubagents +=
|
||||
@@ -2041,13 +2405,30 @@ export class AgentHookServer {
|
||||
dropped += 1
|
||||
}
|
||||
}
|
||||
for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) {
|
||||
const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment)
|
||||
if (!commitment || commitment.observedAt < ttlCutoff) {
|
||||
dropped += 1
|
||||
continue
|
||||
}
|
||||
const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey)
|
||||
if (existing && !authorityCommitmentsMatch(existing, commitment)) {
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
|
||||
this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey)
|
||||
dropped += 1
|
||||
continue
|
||||
}
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment)
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash)
|
||||
}
|
||||
if (dropped > 0) {
|
||||
console.warn(
|
||||
`[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})`
|
||||
)
|
||||
}
|
||||
if (dropped > 0 || prunedLegacyClaudeSubagents > 0) {
|
||||
// Why: persist load-time pruning once so legacy idle rows aren't re-parsed every launch.
|
||||
if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) {
|
||||
// Why: persist load-time pruning and bearer scrubbing once.
|
||||
this.runStatusPersist()
|
||||
} else if (hydrated > 0) {
|
||||
// Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless.
|
||||
@@ -2055,17 +2436,92 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
private captureHydratedAuthorityCommitments(): void {
|
||||
this.revokedHydratedAuthorityCommitments = new WeakSet()
|
||||
for (const entry of this.state.lastStatusByPaneKey.values()) {
|
||||
const evidence = this.toAuthorityEvidence(
|
||||
entry as EnrichedAgentHookEventPayload,
|
||||
this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey)
|
||||
)
|
||||
if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) {
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence)
|
||||
}
|
||||
}
|
||||
this.hydratedAuthorityCommitments = Object.freeze(
|
||||
Array.from(this.persistedAuthorityCommitmentsByPaneKey.values())
|
||||
)
|
||||
}
|
||||
|
||||
private recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void {
|
||||
const evidence = this.toAuthorityEvidence(payload)
|
||||
if (evidence) {
|
||||
this.currentAuthorityObservations.set(evidence.paneKey, evidence)
|
||||
this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence)
|
||||
this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash)
|
||||
}
|
||||
}
|
||||
|
||||
private toAuthorityEvidence(
|
||||
payload: AgentHookEventPayload | EnrichedAgentHookEventPayload,
|
||||
launchTokenHashOverride?: string
|
||||
): AgentHookAuthorityEvidence | null {
|
||||
const launchToken = payload.launchToken?.trim()
|
||||
const launchTokenHash =
|
||||
launchTokenHashOverride ??
|
||||
(launchToken ? createHash('sha256').update(launchToken).digest('hex') : null)
|
||||
if (!launchTokenHash) {
|
||||
return null
|
||||
}
|
||||
return Object.freeze({
|
||||
paneKey: payload.paneKey,
|
||||
launchTokenHash,
|
||||
connectionId: payload.connectionId,
|
||||
...(payload.tabId ? { tabId: payload.tabId } : {}),
|
||||
...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}),
|
||||
observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
private serializeStatusFile(): string {
|
||||
const entries: Record<string, EnrichedAgentHookEventPayload> = {}
|
||||
const entries: Record<string, PersistedAgentHookEventPayload> = {}
|
||||
const authorityCommitments: Record<string, PersistedAgentHookAuthorityCommitment> = {}
|
||||
const conflictedCommitments = new Set<string>()
|
||||
for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) {
|
||||
authorityCommitments[paneKey] = { ...commitment }
|
||||
}
|
||||
for (const [paneKey, payload] of this.state.lastStatusByPaneKey) {
|
||||
// Why: never persist invalid keys (matches the hydrate-path invariant).
|
||||
if (!isValidPaneKey(paneKey)) {
|
||||
continue
|
||||
}
|
||||
const { promptInteractionKey: _promptInteractionKey, ...persistedPayload } = payload
|
||||
entries[paneKey] = persistedPayload as EnrichedAgentHookEventPayload
|
||||
const {
|
||||
promptInteractionKey: _promptInteractionKey,
|
||||
launchToken,
|
||||
...persistedPayload
|
||||
} = payload as EnrichedAgentHookEventPayload
|
||||
const launchTokenHash = launchToken?.trim()
|
||||
? createHash('sha256').update(launchToken.trim()).digest('hex')
|
||||
: this.hydratedLaunchTokenHashByPaneKey.get(paneKey)
|
||||
entries[paneKey] = {
|
||||
...persistedPayload,
|
||||
...(launchTokenHash ? { launchTokenHash } : {})
|
||||
}
|
||||
const commitment = this.toAuthorityEvidence(payload, launchTokenHash)
|
||||
if (commitment && !conflictedCommitments.has(paneKey)) {
|
||||
const existing = authorityCommitments[paneKey]
|
||||
if (existing && !authorityCommitmentsMatch(existing, commitment)) {
|
||||
delete authorityCommitments[paneKey]
|
||||
conflictedCommitments.add(paneKey)
|
||||
} else {
|
||||
authorityCommitments[paneKey] = { ...commitment }
|
||||
}
|
||||
}
|
||||
}
|
||||
const file: LastStatusFile = {
|
||||
version: LAST_STATUS_FILE_VERSION,
|
||||
entries,
|
||||
authorityCommitments
|
||||
}
|
||||
const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, entries }
|
||||
return JSON.stringify(file)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import {
|
||||
buildManagedHookDetectionCommands,
|
||||
detectedManagedHookAgents,
|
||||
type ManagedHookDetectionSettings
|
||||
} from './managed-hook-detection-commands'
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
|
||||
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
|
||||
@@ -18,11 +23,30 @@ export async function installWslGuestHooks(options: {
|
||||
guestHome: string
|
||||
distro: string
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
settings: ManagedHookDetectionSettings
|
||||
warn: (message: string) => void
|
||||
}): Promise<void> {
|
||||
const { mux, guestHome, distro, installHooks, warn } = options
|
||||
const { mux, guestHome, distro, installHooks, settings, warn } = options
|
||||
let agents
|
||||
try {
|
||||
const detected = (await mux.request('preflight.detectAgents', {
|
||||
commands: buildManagedHookDetectionCommands(settings, 'linux')
|
||||
})) as { agents?: unknown }
|
||||
agents = detectedManagedHookAgents(detected?.agents)
|
||||
} catch (error) {
|
||||
warn(
|
||||
`[agent-hooks] WSL agent detection for '${distro}' failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
return
|
||||
}
|
||||
if (agents.length === 0) {
|
||||
return
|
||||
}
|
||||
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
|
||||
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome)
|
||||
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
|
||||
agents
|
||||
})
|
||||
const failed = results.filter((r) => r.state === 'error').length
|
||||
if (failed > 0) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { agentHookServer } from './server'
|
||||
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
|
||||
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
|
||||
import { getOpenCodePluginSource } from '../opencode/hook-service'
|
||||
import type { PluginSources } from '../../relay/plugin-overlay'
|
||||
@@ -59,6 +60,7 @@ export type WslHookRelayManagerDeps = {
|
||||
waitForSentinel: typeof waitForWslRelaySentinel
|
||||
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
|
||||
installHooks: typeof installRemoteManagedAgentHooks
|
||||
managedHookSettings: () => ManagedHookDetectionSettings
|
||||
/** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */
|
||||
pluginSources: () => PluginSources
|
||||
warn: (message: string) => void
|
||||
@@ -89,6 +91,7 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
|
||||
connectionId
|
||||
),
|
||||
installHooks: installRemoteManagedAgentHooks,
|
||||
managedHookSettings: () => null,
|
||||
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
|
||||
pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }),
|
||||
warn: (message) => console.warn(message),
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
// exercises minus the wsl.exe byte transport (validated separately on-rig).
|
||||
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer } from 'node:net'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
@@ -59,7 +58,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
})
|
||||
|
||||
it('delivers a Claude hook POST from the live relay into ingestRemote and installs guest hooks', async () => {
|
||||
fakeHome = mkdtempSync(join(tmpdir(), 'wsl-live-home-'))
|
||||
fakeHome = mkdtempSync(join('/tmp', 'wsl-live-home-'))
|
||||
const preferredPort = await pickFreePort()
|
||||
const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim()
|
||||
|
||||
@@ -102,6 +101,12 @@ describe.skipIf(process.platform === 'win32')(
|
||||
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
|
||||
connectionId
|
||||
),
|
||||
managedHookSettings: () => ({
|
||||
agentCmdOverrides: {
|
||||
claude: process.execPath,
|
||||
codex: process.execPath
|
||||
}
|
||||
}),
|
||||
warn: (message) => warns.push(message),
|
||||
transientRetryDelayMs: 1
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// the per-distro relay manager state machine with fault injection.
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -34,9 +33,13 @@ function createGuestHarness(): GuestHarness {
|
||||
const clientDataCallbacks: ((data: Buffer) => void)[] = []
|
||||
const closeCallbacks: (() => void)[] = []
|
||||
const transport: MultiplexerTransport = {
|
||||
write: (data) => {
|
||||
setImmediate(() => relayFeed?.(data))
|
||||
write: (data, onSettled) => {
|
||||
setImmediate(() => {
|
||||
relayFeed?.(data)
|
||||
onSettled?.({ ok: true })
|
||||
})
|
||||
},
|
||||
supportsWriteSettlement: true,
|
||||
onData: (cb) => {
|
||||
clientDataCallbacks.push(cb)
|
||||
},
|
||||
@@ -44,21 +47,24 @@ function createGuestHarness(): GuestHarness {
|
||||
closeCallbacks.push(cb)
|
||||
}
|
||||
}
|
||||
const guestDispatcher = new RelayDispatcher((data: Buffer) => {
|
||||
setImmediate(() => {
|
||||
for (const cb of clientDataCallbacks) {
|
||||
cb(data)
|
||||
}
|
||||
})
|
||||
})
|
||||
const guestDispatcher = new RelayDispatcher(
|
||||
(data: Buffer, onSettled) => {
|
||||
setImmediate(() => {
|
||||
for (const cb of clientDataCallbacks) {
|
||||
cb(data)
|
||||
}
|
||||
onSettled({ ok: true })
|
||||
})
|
||||
},
|
||||
{ supportsWriteCallback: true }
|
||||
)
|
||||
relayFeed = (data) => guestDispatcher.feed(data)
|
||||
const mux = new SshChannelMultiplexer(transport)
|
||||
return { transport, guestDispatcher, mux }
|
||||
}
|
||||
|
||||
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only by
|
||||
// design (posix.resolve). On a Windows dev host tmpdir() yields C:\ paths the
|
||||
// bridge correctly refuses; Windows coverage comes from the live rig runs.
|
||||
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only;
|
||||
// Windows coverage comes from the live rig runs.
|
||||
describe.skipIf(process.platform === 'win32')(
|
||||
'createWslHookSftpAdapter over the guest fs bridge',
|
||||
() => {
|
||||
@@ -66,7 +72,7 @@ describe.skipIf(process.platform === 'win32')(
|
||||
let harness: GuestHarness
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'wsl-guest-home-'))
|
||||
home = mkdtempSync(join('/tmp', 'wsl-guest-home-'))
|
||||
harness = createGuestHarness()
|
||||
registerWslHookFsHandlers(harness.guestDispatcher, home)
|
||||
})
|
||||
@@ -166,13 +172,19 @@ describe('WslHookRelayManager', () => {
|
||||
return child as unknown as ChildProcessWithoutNullStreams & { emitClose: () => void }
|
||||
}
|
||||
|
||||
function guestTransport(registerInstallPlugins = true): MultiplexerTransport {
|
||||
function guestTransport(
|
||||
options: { registerInstallPlugins?: boolean; detectedAgents?: string[] } = {}
|
||||
): MultiplexerTransport {
|
||||
const { registerInstallPlugins = true, detectedAgents = ['codex'] } = options
|
||||
const harness = createGuestHarness()
|
||||
harnesses.push(harness)
|
||||
registerWslHookFsHandlers(harness.guestDispatcher, home)
|
||||
harness.guestDispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
|
||||
replayed: 0
|
||||
}))
|
||||
harness.guestDispatcher.onRequest('preflight.detectAgents', async () => ({
|
||||
agents: detectedAgents
|
||||
}))
|
||||
// A guest bundle predating the plugin overlay omits this handler (-32601).
|
||||
if (registerInstallPlugins) {
|
||||
harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({
|
||||
@@ -212,6 +224,7 @@ describe('WslHookRelayManager', () => {
|
||||
waitForSentinel: vi.fn(async () => guestTransport()),
|
||||
ingest: vi.fn(),
|
||||
installHooks: vi.fn(async () => []),
|
||||
managedHookSettings: () => null,
|
||||
pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }),
|
||||
warn: vi.fn(),
|
||||
transientRetryDelayMs: 1,
|
||||
@@ -228,7 +241,8 @@ describe('WslHookRelayManager', () => {
|
||||
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
|
||||
// Codex is the one agent whose home Orca redirects for WSL sessions.
|
||||
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
|
||||
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`
|
||||
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`,
|
||||
agents: ['codex']
|
||||
})
|
||||
|
||||
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
|
||||
@@ -261,7 +275,7 @@ describe('WslHookRelayManager', () => {
|
||||
})
|
||||
|
||||
it('leaves the overlay dir null when the guest bundle lacks the installPlugins handler', async () => {
|
||||
const waitForSentinel = vi.fn(async () => guestTransport(false))
|
||||
const waitForSentinel = vi.fn(async () => guestTransport({ registerInstallPlugins: false }))
|
||||
const { manager, deps } = createManager({ waitForSentinel })
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
// Connect still completes (hooks install); the -32601 is swallowed silently.
|
||||
@@ -272,6 +286,17 @@ describe('WslHookRelayManager', () => {
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('does not mutate managed agent homes when no WSL agents are detected', async () => {
|
||||
const waitForSentinel = vi.fn(async () => guestTransport({ detectedAgents: [] }))
|
||||
const { manager, deps } = createManager({ waitForSentinel })
|
||||
|
||||
manager.ensureForDistro('Ubuntu')
|
||||
await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir))
|
||||
|
||||
expect(deps.installHooks).not.toHaveBeenCalled()
|
||||
manager.disposeAll()
|
||||
})
|
||||
|
||||
it('resolves the default distro for null and dedupes it with the explicit name', async () => {
|
||||
const { manager, deps } = createManager({})
|
||||
manager.ensureForDistro(null)
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from './wsl-hook-relay-deps'
|
||||
import { wireWslRelayLink } from './wsl-hook-relay-link'
|
||||
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
|
||||
import { wslHookRelayStateKey } from './wsl-hook-relay-state-key'
|
||||
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
|
||||
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
|
||||
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
|
||||
@@ -44,10 +45,6 @@ type DistroState = {
|
||||
lastInstallAt?: number
|
||||
}
|
||||
|
||||
function distroKey(distro: string): string {
|
||||
return distro.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export class WslHookRelayManager {
|
||||
private deps: WslHookRelayManagerDeps
|
||||
private recovery: WslRelayRecovery
|
||||
@@ -62,12 +59,12 @@ export class WslHookRelayManager {
|
||||
isDistroRunning: (distro) => this.deps.isDistroRunning(distro),
|
||||
warn: (message) => this.deps.warn(message),
|
||||
isDisposed: () => this.disposed,
|
||||
isCurrent: (state) => this.states.get(distroKey(state.distro)) === state,
|
||||
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
|
||||
restart: (distro) => this.ensureForDistro(distro),
|
||||
dropState: (state) => {
|
||||
// Why: identity-guarded — a fresh ensure() may own this key by now;
|
||||
// deleting by key alone would orphan its live relay child.
|
||||
const key = distroKey(state.distro)
|
||||
const key = wslHookRelayStateKey(state.distro)
|
||||
if (this.states.get(key) === state) {
|
||||
this.states.delete(key)
|
||||
}
|
||||
@@ -75,6 +72,10 @@ export class WslHookRelayManager {
|
||||
})
|
||||
}
|
||||
|
||||
setManagedHookSettingsResolver(resolve: WslHookRelayManagerDeps['managedHookSettings']): void {
|
||||
this.deps.managedHookSettings = resolve
|
||||
}
|
||||
|
||||
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
|
||||
ensureForDistro(distro: string | null): void {
|
||||
if (this.disposed || this.deps.platform() !== 'win32' || !this.deps.remoteHooksEnabled()) {
|
||||
@@ -89,7 +90,7 @@ export class WslHookRelayManager {
|
||||
|
||||
private stateFor(distro: string | null): DistroState | undefined {
|
||||
// Empty key never matches a real (non-empty) distro state.
|
||||
return this.states.get(distroKey(distro ?? this.defaultDistro ?? ''))
|
||||
return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? ''))
|
||||
}
|
||||
|
||||
/** Guest endpoint file path once known; null before first connect
|
||||
@@ -120,7 +121,7 @@ export class WslHookRelayManager {
|
||||
if (!distro || this.disposed) {
|
||||
return
|
||||
}
|
||||
const key = distroKey(distro)
|
||||
const key = wslHookRelayStateKey(distro)
|
||||
const existing = this.states.get(key)
|
||||
if (existing) {
|
||||
if (existing.phase === 'running') {
|
||||
@@ -280,6 +281,7 @@ export class WslHookRelayManager {
|
||||
guestHome,
|
||||
distro: state.distro,
|
||||
installHooks: this.deps.installHooks,
|
||||
settings: this.deps.managedHookSettings(),
|
||||
warn: this.deps.warn
|
||||
})
|
||||
// Why: ship OpenCode's status plugin and record the guest overlay dir the
|
||||
|
||||
@@ -16,14 +16,20 @@ type FakeChild = ChildProcessWithoutNullStreams & { kill: ReturnType<typeof vi.f
|
||||
|
||||
function fakeChild(): FakeChild {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
stdout: EventEmitter & {
|
||||
pause: ReturnType<typeof vi.fn>
|
||||
resume: ReturnType<typeof vi.fn>
|
||||
}
|
||||
stderr: EventEmitter
|
||||
stdin: { write: ReturnType<typeof vi.fn> }
|
||||
stdin: EventEmitter & { write: ReturnType<typeof vi.fn> }
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
}
|
||||
child.stdout = new EventEmitter()
|
||||
child.stdout = Object.assign(new EventEmitter(), {
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn()
|
||||
})
|
||||
child.stderr = new EventEmitter()
|
||||
child.stdin = { write: vi.fn(() => true) }
|
||||
child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(() => true) })
|
||||
child.kill = vi.fn()
|
||||
return child as unknown as FakeChild
|
||||
}
|
||||
@@ -62,6 +68,10 @@ describe('waitForWslRelaySentinel', () => {
|
||||
const transport = await promise
|
||||
expect(typeof transport.write).toBe('function')
|
||||
expect(typeof transport.onData).toBe('function')
|
||||
transport.pauseReads?.()
|
||||
transport.resumeReads?.()
|
||||
expect(child.stdout.pause).toHaveBeenCalledOnce()
|
||||
expect(child.stdout.resume).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resolves past leading garbage and hands trailing bytes to onData', async () => {
|
||||
@@ -149,4 +159,25 @@ describe('waitForWslRelaySentinel', () => {
|
||||
expect(err.message).toContain('E_FAIL')
|
||||
expect(err.message).not.toContain(String.fromCharCode(0))
|
||||
})
|
||||
|
||||
it('forwards write(false), callback settlement, and drain from WSL stdin', async () => {
|
||||
const child = fakeChild()
|
||||
const callback = vi.fn()
|
||||
const drain = vi.fn()
|
||||
const writeMock = child.stdin.write as ReturnType<typeof vi.fn>
|
||||
writeMock.mockImplementation((_data, onWritten) => {
|
||||
onWritten(null)
|
||||
return false
|
||||
})
|
||||
const promise = waitForWslRelaySentinel(child)
|
||||
emitStdout(child, RELAY_SENTINEL)
|
||||
const transport = await promise
|
||||
|
||||
transport.onDrain?.(drain)
|
||||
expect(transport.write(Buffer.from('frame'), callback)).toBe(false)
|
||||
expect(callback).toHaveBeenCalledWith({ ok: true })
|
||||
child.stdin.emit('drain')
|
||||
expect(drain).toHaveBeenCalledOnce()
|
||||
expect(transport.supportsWriteSettlement).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,12 +123,15 @@ export function waitForWslRelaySentinel(
|
||||
pendingChunks.push(trailing)
|
||||
}
|
||||
const transport: MultiplexerTransport = {
|
||||
write: (data) => {
|
||||
try {
|
||||
child.stdin.write(data)
|
||||
} catch {
|
||||
// Channel already closing — mux close handling takes over.
|
||||
}
|
||||
write: (data, onSettled) => {
|
||||
return child.stdin.write(data, (error?: Error | null) => {
|
||||
onSettled?.(error ? { ok: false, error } : { ok: true })
|
||||
})
|
||||
},
|
||||
supportsWriteSettlement: true,
|
||||
onDrain: (cb) => {
|
||||
child.stdin.on('drain', cb)
|
||||
return () => child.stdin.off('drain', cb)
|
||||
},
|
||||
onData: (cb) => {
|
||||
dataCallbacks.push(cb)
|
||||
@@ -143,6 +146,8 @@ export function waitForWslRelaySentinel(
|
||||
}
|
||||
},
|
||||
onClose: (cb) => closeCallbacks.push(cb),
|
||||
pauseReads: () => child.stdout.pause(),
|
||||
resumeReads: () => child.stdout.resume(),
|
||||
close: () => child.kill()
|
||||
}
|
||||
resolve(transport)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user