diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index dc9a2f2f439..053fcdbe0fb 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -42,10 +42,16 @@ outputs: value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }} native-cache-scope: description: Operating-system image scope used by the native module cache. - value: ${{ steps.native-cache-scope.outputs.scope }} + value: ${{ steps.native-runtime.outputs.cache-scope }} + native-cache-key: + description: Exact native cache key requested after installation. + value: ${{ steps.native-runtime.outputs.cache-key }} + native-cache-path: + description: Native build directories covered by the requested cache. + value: ${{ steps.native-runtime.outputs.cache-path }} native-cache-hit: description: Whether the compiled native module cache was restored. - value: ${{ steps.native-cache-restore.outputs.cache-hit || steps.native-cache-restore-only.outputs.cache-hit }} + value: ${{ steps.native-runtime.outputs.cache-hit }} runs: using: composite @@ -203,68 +209,11 @@ runs: path: ${{ steps.electron-package-cache.outputs.cache-root }} key: electron-package-${{ runner.os }}-${{ runner.arch }}-${{ steps.electron-package-cache.outputs.version }} - # Why cached: `--ignore-scripts` leaves node-pty without build/Release, so - # ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime. - # The artifacts are ABI-bound, so the key carries the target runtime, the resolved - # Node version, and the patch whose contents the build has to match. - # Windows extra globs are empty on Linux. No restore-keys: a partial-match key is - # an ABI-mismatched build, and ensure-native-runtime would recompile it anyway. - # Native addons built on a newer Linux image can require glibc symbols - # missing from an older runner/container. ImageOS distinguishes hosted - # Windows/macOS images; /etc/os-release also distinguishes Linux containers. - - name: Resolve native cache scope - id: native-cache-scope - if: inputs.native-runtime != 'none' - shell: bash - run: | - scope="${ImageOS:-$RUNNER_OS}" - if [ -r /etc/os-release ]; then - . /etc/os-release - scope="${ID:-linux}-${VERSION_ID:-unknown}" - fi - echo "scope=$scope" >> "$GITHUB_OUTPUT" - - - name: Restore compiled native modules - id: native-cache-restore - if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false' - uses: actions/cache@v5 - with: - path: | - node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - native/windows-registry/build - node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} - - - name: Restore compiled native modules without saving - id: native-cache-restore-only - if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false' - uses: actions/cache/restore@v5 - with: - path: | - node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - native/windows-registry/build - node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} - - # pnpm's bundled gyp_main.py is not executable; Electron rebuild uses its own node-gyp API. - - name: Use external node-gyp - if: runner.os == 'Linux' && inputs.native-runtime == 'node' - shell: bash - env: - NATIVE_RUNTIME: ${{ inputs.native-runtime }} - NATIVE_CACHE_HIT: ${{ steps.native-cache-restore.outputs.cache-hit || steps.native-cache-restore-only.outputs.cache-hit }} - run: | - # A cache hit can contain unusable addons; probe before skipping the rebuild toolchain. - if [ "$NATIVE_RUNTIME" = node ] && [ "$NATIVE_CACHE_HIT" = true ] && - node config/scripts/ensure-native-runtime.mjs --check-only; then - exit 0 - fi - 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: Prepare native runtime + id: native-runtime if: inputs.native-runtime != 'none' - shell: bash - env: - NATIVE_RUNTIME: ${{ inputs.native-runtime }} - run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME" + uses: ./.github/actions/prepare-native-runtime + with: + native-runtime: ${{ inputs.native-runtime }} + node-version: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }} + persist-native-cache: ${{ inputs.persist-native-cache }} diff --git a/.github/actions/prepare-native-runtime/action.yml b/.github/actions/prepare-native-runtime/action.yml new file mode 100644 index 00000000000..ae56feeb68e --- /dev/null +++ b/.github/actions/prepare-native-runtime/action.yml @@ -0,0 +1,120 @@ +name: Prepare native runtime +description: Restore and probe source-built native modules for the requested runtime. + +inputs: + native-runtime: + description: Native runtime to prepare (none, node, or electron). + required: false + default: none + node-version: + description: Resolved Node.js version provided by setup-node. + required: true + persist-native-cache: + description: Save native modules at job end unless a later step changes their ABI. + required: false + default: 'true' + +outputs: + cache-scope: + description: Operating-system image scope used by the native module cache. + value: ${{ steps.native-cache-scope.outputs.scope }} + cache-key: + description: Exact key requested for this runtime, independent of whether restoration succeeds. + value: ${{ steps.native-cache-scope.outputs.key }} + cache-path: + description: Native build directories covered by the requested cache. + value: ${{ steps.native-cache-scope.outputs.path }} + cache-hit: + description: Whether compiled native modules were restored under the exact requested key. + value: ${{ steps.native-cache-restore.outputs.cache-hit || steps.native-cache-restore-only.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Validate native runtime + shell: bash + env: + NATIVE_RUNTIME: ${{ inputs.native-runtime }} + NODE_VERSION: ${{ inputs.node-version }} + run: | + case "$NATIVE_RUNTIME" in + none) ;; + node|electron) test -n "$NODE_VERSION" ;; + *) + echo "::error::native-runtime must be none, node, or electron" + exit 2 + ;; + esac + + # Exact keys separate runtime, architecture and host libc; every restored addon is probed. + - name: Resolve native cache scope + id: native-cache-scope + if: inputs.native-runtime != 'none' + shell: bash + env: + NATIVE_RUNTIME: ${{ inputs.native-runtime }} + NODE_VERSION: ${{ inputs.node-version }} + NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js') }} + run: | + scope="${ImageOS:-$RUNNER_OS}" + if [ -r /etc/os-release ]; then + . /etc/os-release + scope="${ID:-linux}-${VERSION_ID:-unknown}" + fi + pnpm_version="$(pnpm --version)" + test -n "$pnpm_version" + printf 'scope=%s\n' "$scope" >> "$GITHUB_OUTPUT" + printf 'key=native-modules-%s-%s-%s-%s-node%s-pnpm%s-%s\n' "$RUNNER_OS" "$scope" "$RUNNER_ARCH" "$NATIVE_RUNTIME" "$NODE_VERSION" "$pnpm_version" "$NATIVE_SOURCE_HASH" >> "$GITHUB_OUTPUT" + cat >> "$GITHUB_OUTPUT" <<'ORCA_NATIVE_CACHE_PATHS' + path<> "$GITHUB_ENV" + + - name: Prepare native runtime + if: inputs.native-runtime != 'none' + shell: bash + env: + NATIVE_RUNTIME: ${{ inputs.native-runtime }} + run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME" diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 4d69cd1c104..bdd3e185638 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -9,6 +9,7 @@ on: paths: - '.github/workflows/ci-cache-warmup.yml' - '.github/actions/install-node-dependencies/**' + - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-git-compatibility/**' - '.github/actions/prepare-linux-package-fixture/**' - 'config/docker/headless-serve-shutdown/**' @@ -17,9 +18,21 @@ on: - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' + - '.npmrc' + - '.pnpmfile.cjs' - 'config/tsconfig*.json' - 'config/scripts/ensure-native-runtime.mjs' - 'config/scripts/rebuild-native-deps.mjs' + - 'config/scripts/node-pty-job-ownership.cjs' + - 'config/scripts/windows-pe-machine.cjs' + - 'config/scripts/windows-process-tree-gyp-rebuild.mjs' + - 'config/scripts/windows-process-tree-creation-time.cjs' + - 'config/scripts/install-electron-package-binary.mjs' + - 'config/scripts/electron-platform-path.mjs' + - 'config/scripts/zip-extractor-command.mjs' + - 'src/shared/zip-extractor-command.ts' + - 'config/scripts/shared-electron-dist-cache.mjs' + - 'config/scripts/space-sharing-copy.mjs' - 'config/patches/node-pty@1.1.0.patch' - 'config/patches/@vscode__windows-process-tree@0.8.0.patch' - 'native/windows-registry/**' diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index ab86fcd6fe6..0a2840c60a1 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -16,6 +16,7 @@ on: - '.npmrc' - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' + - '.github/actions/prepare-native-runtime/**' - '.github/workflows/node-server-tests.yml' # The pull request qualifies one platform for an unflavoured change; this is where all six # are re-qualified, so a platform break surfaces minutes after merge instead of next cron. @@ -34,6 +35,7 @@ on: - '.npmrc' - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' + - '.github/actions/prepare-native-runtime/**' - '.github/workflows/node-server-tests.yml' workflow_dispatch: inputs: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9edeeece8f3..32a167cf071 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -860,21 +860,10 @@ jobs: DIFF_BASE="$(node config/scripts/git-pull-request-diff-base.mjs "$BASE_SHA")" pnpm run check:runtime-launcher-protocol-ratchet -- --base "$DIFF_BASE" - # A path filter that matches nothing exits 1 ("No test files found"), so this - # lane cannot report success while running zero tests. + # Runs the whole directory so a new file there cannot be left out; an empty match + # exits 1 ("No test files found"), so this lane cannot pass while running zero tests. - name: Old/new client and server compatibility journeys - run: >- - pnpm exec vitest run --config config/vitest.config.ts - tests/e2e/cross-version-wire/release-checkout.unit.test.ts - tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts - tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts - tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts - tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts - tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts - tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts - tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts - tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts - tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts + run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/ managed_hook_node18: name: managed hooks on Node 18 @@ -1102,11 +1091,8 @@ jobs: if: steps.deps.outputs.native-cache-hit != 'true' uses: actions/cache/save@v5 with: - path: | - node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - native/windows-registry/build - node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} + path: ${{ steps.deps.outputs.native-cache-path }} + key: ${{ steps.deps.outputs.native-cache-key }} # vitest runs here directly rather than through `pnpm test`, so the addon # assertions only hold once install-node-dependencies has rebuilt natives. @@ -1206,17 +1192,11 @@ jobs: - name: Build package inputs run: pnpm run build:release:parallel - - name: Restore compiled Electron native modules - uses: actions/cache@v5 - with: - path: | - node_modules/.pnpm/node-pty@*/node_modules/node-pty/build - native/windows-registry/build - node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build - key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json') }} - - name: Prepare Electron native runtime - run: node config/scripts/ensure-native-runtime.mjs --runtime=electron + uses: ./.github/actions/prepare-native-runtime + with: + native-runtime: electron + node-version: ${{ steps.deps.outputs.node-version }} - name: Package unpacked app env: diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 4b20c04b157..07c207bddce 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -8,6 +8,21 @@ const readWorkflow = (name) => const workflow = readWorkflow('ci-cache-warmup') const steps = workflow.jobs.warm.steps +it('seeds new native keys on main when any declared build or probe input changes', () => { + const native = parse(readFileSync('.github/actions/prepare-native-runtime/action.yml', 'utf8')) + const hash = native.runs.steps.find((step) => step.id === 'native-cache-scope').env + .NATIVE_SOURCE_HASH + const files = [...hash.matchAll(/'([^']+)'/g)].map((match) => match[1]) + for (const file of files) { + expect( + workflow.on.push.paths.some((pattern) => + pattern.endsWith('/**') ? file.startsWith(pattern.slice(0, -2)) : file === pattern + ), + file + ).toBe(true) + } +}) + it('warms the same Linux Node runtime the PR shards restore', () => { const arm = workflow.jobs['warm-linux-arm'] const install = arm.steps.find( @@ -56,6 +71,7 @@ it('bounds warming to the required platforms and validates changes without grant expect(workflow.jobs.warm['timeout-minutes']).toBeLessThanOrEqual(10) expect(workflow.permissions).toEqual({ contents: 'read' }) expect(workflow.on.push.branches).toEqual(['main']) + expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**') expect(workflow.on.schedule).toEqual([{ cron: '41 * * * *' }]) expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml') expect(workflow.concurrency['cancel-in-progress']).toBe(true) diff --git a/config/scripts/ci-native-cache.test.mjs b/config/scripts/ci-native-cache.test.mjs new file mode 100644 index 00000000000..6851ed62f9b --- /dev/null +++ b/config/scripts/ci-native-cache.test.mjs @@ -0,0 +1,248 @@ +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' +import { classifyPrJobs } from './pr-code-change-scope.mjs' +import { runProcessSync } from './script-child-process.mjs' + +const installerPath = '.github/actions/install-node-dependencies/action.yml' +const nativePath = '.github/actions/prepare-native-runtime/action.yml' +const installer = parse(readFileSync(installerPath, 'utf8')) +const native = parse(readFileSync(nativePath, 'utf8')) +const identity = native.runs.steps.find((step) => step.id === 'native-cache-scope') +const inputs = [...identity.env.NATIVE_SOURCE_HASH.matchAll(/'([^']+)'/g)].map((match) => match[1]) +const source = new Map( + [...inputs, installerPath].map((file) => [file, existsSync(file) ? readFileSync(file) : null]) +) +const paths = [ + 'node_modules/.pnpm/node-pty@*/node_modules/node-pty/build', + 'native/windows-registry/build', + 'node_modules/.pnpm/@vscode+windows-process-tre*/node_modules/@vscode/windows-process-tree/build' +] + +function sourceHash(files = source) { + const digest = createHash('sha256') + for (const file of inputs.toSorted()) { + const contents = files.get(file) + if (contents !== null) { + digest.update(createHash('sha256').update(contents).digest()) + } + } + return digest.digest('hex') +} + +function resolveIdentity({ + runtime = 'node', + node = 'v24.21.0', + pnpm = '12.0.0', + os = 'Windows', + arch = 'X64', + image = 'win22', + libc = null, + files = source +} = {}) { + const directory = mkdtempSync(join(tmpdir(), 'orca-native-identity-')) + try { + const output = join(directory, 'output') + const osRelease = join(directory, 'os-release') + if (libc) { + writeFileSync(osRelease, `ID=${libc.id}\nVERSION_ID=${libc.version}\n`) + } + // Isolate the container file lookup while running the production identity commands. + const script = `pnpm() { printf "%s\\n" "$TEST_PNPM_VERSION"; }\n${identity.run.replaceAll('/etc/os-release', '"$TEST_OS_RELEASE"')}` + const execution = runProcessSync({ + program: 'bash', + args: ['-e', '-o', 'pipefail', '-c', script], + env: { + ...process.env, + NATIVE_RUNTIME: runtime, + NODE_VERSION: node, + RUNNER_OS: os, + RUNNER_ARCH: arch, + ImageOS: image, + NATIVE_SOURCE_HASH: sourceHash(files), + TEST_PNPM_VERSION: pnpm, + TEST_OS_RELEASE: osRelease, + GITHUB_OUTPUT: output + } + }) + expect(execution.code, execution.stderr).toBe(0) + const result = readFileSync(output, 'utf8') + return { + key: /^key=(.+)$/m.exec(result)?.[1], + scope: /^scope=(.+)$/m.exec(result)?.[1], + path: /path< { + it('hashes the complete native preparation policy and build/probe source closure', () => { + expect(inputs).toEqual([ + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + '.npmrc', + '.pnpmfile.cjs', + nativePath, + 'config/scripts/ensure-native-runtime.mjs', + 'config/scripts/rebuild-native-deps.mjs', + 'config/scripts/node-pty-job-ownership.cjs', + 'config/scripts/windows-pe-machine.cjs', + 'config/scripts/windows-process-tree-gyp-rebuild.mjs', + 'config/scripts/windows-process-tree-creation-time.cjs', + 'config/scripts/install-electron-package-binary.mjs', + 'config/scripts/electron-platform-path.mjs', + 'config/scripts/zip-extractor-command.mjs', + 'src/shared/zip-extractor-command.ts', + 'config/scripts/shared-electron-dist-cache.mjs', + 'config/scripts/space-sharing-copy.mjs', + 'config/patches/node-pty@1.1.0.patch', + 'config/patches/@vscode__windows-process-tree@0.8.0.patch', + 'native/windows-registry/src/addon.cc', + 'native/windows-registry/binding.gyp', + 'native/windows-registry/package.json', + 'native/windows-registry/index.js' + ]) + expect(inputs).not.toContain(installerPath) + for (const file of inputs) { + expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true) + } + }) + + it.skipIf(process.platform === 'win32')( + 'keeps a pnpm-only installer edit on the same native key and paths', + () => { + const changed = new Map(source) + changed.set( + installerPath, + Buffer.from( + readFileSync(installerPath, 'utf8').replace( + "inputs.cache-pnpm-verification == 'true'", + "inputs.cache-pnpm-verification == 'false'" + ) + ) + ) + expect(changed.get(installerPath)).not.toEqual(source.get(installerPath)) + expect(resolveIdentity({ files: changed })).toEqual(resolveIdentity()) + } + ) + + it.skipIf(process.platform === 'win32').each(inputs)( + 'changes the requested key when native input %s changes', + (file) => { + const changed = new Map(source) + changed.set( + file, + Buffer.concat([source.get(file) ?? Buffer.alloc(0), Buffer.from('\nchanged')]) + ) + expect(resolveIdentity({ files: changed }).key).not.toBe(resolveIdentity().key) + } + ) + + it.skipIf(process.platform === 'win32')( + 'separates runtime, Node/pnpm versions, architecture, image and libc hosts', + () => { + const baseline = resolveIdentity() + expect(baseline.path.split('\n')).toEqual(paths) + const variants = [ + { runtime: 'electron' }, + { node: 'v24.22.0' }, + { pnpm: '12.1.0' }, + { arch: 'ARM64' }, + { image: 'win11' }, + { os: 'Linux', image: 'ubuntu22', libc: { id: 'ubuntu', version: '22.04' } }, + { os: 'Linux', image: 'ubuntu22', libc: { id: 'almalinux', version: '8.10' } } + ].map(resolveIdentity) + expect(new Set([baseline, ...variants].map(({ key }) => key)).size).toBe(variants.length + 1) + expect(variants.at(-1).scope).toBe('almalinux-8.10') + expect(variants.every(({ path }) => path === baseline.path)).toBe(true) + } + ) + + it('keeps early input rejection before dependency installation and native restoration', () => { + const early = installer.runs.steps.findIndex((step) => step.name === 'Validate native runtime') + const install = installer.runs.steps.findIndex((step) => step.name === 'Install dependencies') + const prepare = installer.runs.steps.findIndex((step) => step.id === 'native-runtime') + expect(early).toBeLessThan(install) + expect(install).toBeLessThan(prepare) + expect(installer.runs.steps[early].run).toContain('none|node|electron)') + expect(installer.runs.steps[prepare].if).toBe("inputs.native-runtime != 'none'") + }) + + it.skipIf(process.platform === 'win32')( + 'rejects invalid runtime and missing resolved Node before cache restoration', + () => { + const early = installer.runs.steps.find((step) => step.name === 'Validate native runtime') + const validate = native.runs.steps.find((step) => step.name === 'Validate native runtime') + for (const run of [early.run, validate.run]) { + expect( + runProcessSync({ + program: 'bash', + args: ['-e', '-c', run], + env: { ...process.env, NATIVE_RUNTIME: 'unsupported', NODE_VERSION: 'v24.21.0' }, + stdio: 'pipe' + }).code + ).not.toBe(0) + } + expect( + runProcessSync({ + program: 'bash', + args: ['-e', '-c', validate.run], + env: { ...process.env, NATIVE_RUNTIME: 'node', NODE_VERSION: '' }, + stdio: 'pipe' + }).code + ).not.toBe(0) + } + ) + + it('uses requested identity on both restores and saves the Node ABI before Windows switches to Electron', () => { + for (const step of native.runs.steps.filter((step) => step.uses?.startsWith('actions/cache'))) { + expect(step.with.key).toBe('${{ steps.native-cache-scope.outputs.key }}') + expect(step.with.path.trim().split('\n')).toEqual(paths) + expect(step.with['restore-keys']).toBeUndefined() + } + const job = parse(readFileSync('.github/workflows/pr.yml', 'utf8')).jobs.package_windows + const save = job.steps.find((step) => step.name === 'Save compiled Node native modules') + const install = job.steps.find((step) => step.id === 'deps') + const build = job.steps.find((step) => step.name === 'Build package inputs') + const electron = job.steps.find((step) => step.name === 'Prepare Electron native runtime') + expect(install.with['persist-native-cache']).toBe('false') + expect(save.with).toEqual({ + key: '${{ steps.deps.outputs.native-cache-key }}', + path: '${{ steps.deps.outputs.native-cache-path }}' + }) + expect(job.steps.indexOf(save)).toBeLessThan(job.steps.indexOf(build)) + expect(job.steps.indexOf(build)).toBeLessThan(job.steps.indexOf(electron)) + expect(electron.uses).toBe('./.github/actions/prepare-native-runtime') + expect(electron.with).toEqual({ + 'native-runtime': 'electron', + 'node-version': '${{ steps.deps.outputs.node-version }}' + }) + expect(native.outputs['cache-key'].value).toBe('${{ steps.native-cache-scope.outputs.key }}') + }) + + it.skipIf(process.platform === 'win32')( + 'keeps cache post-job paths when the nested step output context is absent', + () => { + const postContext = { steps: {} } + const evaluateInput = (value) => + value.replace( + /\$\{\{\s*steps\.([\w-]+)\.outputs\.([\w-]+)\s*\}\}/g, + (_expression, step, output) => postContext.steps[step]?.outputs?.[output] ?? '' + ) + expect(evaluateInput('${{ steps.native-cache-scope.outputs.path }}')).toBe('') + const emitted = resolveIdentity().path + for (const step of native.runs.steps.filter((step) => + step.uses?.startsWith('actions/cache') + )) { + expect(evaluateInput(step.with.path).trim()).toBe(emitted) + } + } + ) +}) diff --git a/config/scripts/ci-native-toolchain.test.mjs b/config/scripts/ci-native-toolchain.test.mjs index b9d7be83707..f0d58b97639 100644 --- a/config/scripts/ci-native-toolchain.test.mjs +++ b/config/scripts/ci-native-toolchain.test.mjs @@ -5,8 +5,8 @@ import { join } from 'node:path' import { parse } from 'yaml' import { describe, expect, it } from 'vitest' -const steps = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')) - .runs.steps +const steps = parse(readFileSync('.github/actions/prepare-native-runtime/action.yml', 'utf8')).runs + .steps const toolchain = steps.find((step) => step.name === 'Use external node-gyp') describe('CI native toolchain preparation', () => { diff --git a/config/scripts/ci-unit-exclusion-coverage.test.mjs b/config/scripts/ci-unit-exclusion-coverage.test.mjs new file mode 100644 index 00000000000..9061b5723a6 --- /dev/null +++ b/config/scripts/ci-unit-exclusion-coverage.test.mjs @@ -0,0 +1,89 @@ +import { globSync, readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { defaultExclude } from 'vitest/config' +import { describe, expect, it } from 'vitest' +import { CROSS_VERSION_WIRE_DIR, UNIT_INCLUDE, discoverUnitFiles } from './ci-unit-files.mjs' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +function posixPaths(files) { + return files.map((file) => file.replaceAll('\\', '/')) +} + +function workflowJobs(path) { + return parse(readFileSync(path, 'utf8')).jobs +} + +// Why verify.needs: a job left out of it (e2e, ime, wsl) can be red or skipped without blocking a merge. +// Each gating job carries the code_paths output that decides whether it runs; a called workflow's jobs inherit the caller's. +function gatingJobs() { + const jobs = workflowJobs('.github/workflows/pr.yml') + return jobs.verify.needs.flatMap((name) => { + const { if: condition = '', uses } = jobs[name] + const gate = /needs\.code_paths\.outputs\.([\w-]+) == 'true'/.exec(condition)?.[1] + const called = uses?.startsWith('./.github/workflows/') + ? Object.values(workflowJobs(uses.slice(2))) + : [jobs[name]] + return called.map((job) => ({ gate, pathArguments: vitestPathArguments(job) })) + }) +} + +// Path arguments of every step in the job that runs vitest; a trailing slash means a directory. +function vitestPathArguments(job) { + return (job.steps ?? []) + .map((step) => step.run) + .filter((run) => typeof run === 'string' && /\bvitest\b/.test(run)) + .flatMap((run) => run.replaceAll('\\\n', ' ').split(/\s+/)) + .map((token) => token.replace(/^['"]|['"]$/g, '')) + .filter((token) => /^(?:src|config|tests|mobile)\//.test(token)) +} + +function runByVitestStep(file, pathArguments) { + return pathArguments.some( + (path) => path === file || (path.endsWith('/') && file.startsWith(path)) + ) +} + +// Why module scope: globbing the unit tree costs seconds, and every test reads the same result. +const UNIT_FILES = posixPaths(globSync(UNIT_INCLUDE, { exclude: defaultExclude })) +const SHARDED_UNIT_FILES = new Set(discoverUnitFiles()) +const EXCLUDED_UNIT_FILES = UNIT_FILES.filter((file) => !SHARDED_UNIT_FILES.has(file)) +const GATING_JOBS = gatingJobs() + +describe('unit files kept out of the sharded test job', () => { + it('each still runs in a job that gates the PR', () => { + expect(EXCLUDED_UNIT_FILES.length).toBeGreaterThan(0) + // Why: an excluded file no gating step names guards nothing, and nothing else reports it. + expect( + EXCLUDED_UNIT_FILES.filter( + (file) => !GATING_JOBS.some((job) => runByVitestStep(file, job.pathArguments)) + ) + ).toEqual([]) + }) + + it('a change to each runs a gating job that names it', () => { + // Why: otherwise a PR editing only the file skips the one job that runs it. + expect( + EXCLUDED_UNIT_FILES.filter( + (file) => + !GATING_JOBS.some( + ({ gate, pathArguments }) => + runByVitestStep(file, pathArguments) && (!gate || classifyPrJobs([file])[gate]) + ) + ) + ).toEqual([]) + }) + + it('runs the whole cross-version-wire directory, not a list of its files', () => { + expect(GATING_JOBS.flatMap((job) => job.pathArguments)).toContain(CROSS_VERSION_WIRE_DIR) + }) + + it('names every cross-version-wire test so the unit include picks it up', () => { + // Why: a directory argument only reaches files the vitest include matches; any other name is silently skipped. + const tests = posixPaths( + globSync(`${CROSS_VERSION_WIRE_DIR}**/*.{test,spec}.{js,cjs,mjs,ts,tsx}`) + ) + const included = new Set(UNIT_FILES) + expect(tests.length).toBeGreaterThan(0) + expect(tests.filter((file) => !included.has(file))).toEqual([]) + }) +}) diff --git a/config/scripts/ci-unit-files.mjs b/config/scripts/ci-unit-files.mjs index ab8d5038aed..80252973e22 100644 --- a/config/scripts/ci-unit-files.mjs +++ b/config/scripts/ci-unit-files.mjs @@ -1,6 +1,9 @@ import { globSync } from 'node:fs' import { defaultExclude } from 'vitest/config' +// Why one constant: the cross-version-wire job runs this directory, so a new file there runs with no list to update. +export const CROSS_VERSION_WIRE_DIR = 'tests/e2e/cross-version-wire/' + export const UNIT_INCLUDE = [ 'src/**/*.test.ts', 'src/**/*.test.tsx', @@ -32,7 +35,7 @@ export const UNIT_EXCLUDE = [ 'src/shared/posix-command-path-lookup.test.ts', 'tests/e2e/relay-region-compatibility.unit.test.ts', 'tests/e2e/relay-region-correction.unit.test.ts', - 'tests/e2e/cross-version-wire/**' + `${CROSS_VERSION_WIRE_DIR}**` ] export function discoverUnitFiles(root = process.cwd()) { diff --git a/config/scripts/mobile-release-shell-switch-workflow.test.mjs b/config/scripts/mobile-release-shell-switch-workflow.test.mjs index 40cc20a95ba..f6f05c779eb 100644 --- a/config/scripts/mobile-release-shell-switch-workflow.test.mjs +++ b/config/scripts/mobile-release-shell-switch-workflow.test.mjs @@ -171,7 +171,7 @@ describe('what the mobile jobs restore from cache', () => { expect(names).toEqual( expect.arrayContaining([ './.github/actions/install-node-dependencies: Cache Electron package archive', - './.github/actions/install-node-dependencies: Restore compiled native modules', + './.github/actions/prepare-native-runtime: Restore compiled native modules', './.github/actions/install-node-dependencies: Setup Node.js', 'ios-build: Setup Ruby and fastlane' ]) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index b1a523cd120..d190855f7c0 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -41,6 +41,7 @@ const ALWAYS_FILES = new Set([ ]) const ALWAYS_PREFIXES = [ '.github/actions/install-node-dependencies/', + '.github/actions/prepare-native-runtime/', // These areas also contain worker paths and fixtures opened without an import. 'src/main/persistence/', 'src/main/sqlite/', diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index a6910fe9c76..78b511dc1a8 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -83,6 +83,7 @@ it.each([ 'config/patches/node-pty@1.1.0.patch', 'native/windows-registry/src/addon.cc', '.github/actions/install-node-dependencies/action.yml', + '.github/actions/prepare-native-runtime/action.yml', '.github/workflows/node-server-tests.yml', 'src/main/persistence/profile-state/new-worker.ts' ])('always selects build, native and dynamically opened inputs: %s', async (file) => { diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index 0fa5923eb4a..b1faf6eef17 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -14,6 +14,7 @@ const PLATFORM_PREFIXES = [ 'native/', 'config/patches/', '.github/actions/install-node-dependencies/', + '.github/actions/prepare-native-runtime/', 'src/main/persistence/', 'src/main/sqlite/', 'src/main/orcad/', diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index a4d367d58b2..480d7e45912 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -27,6 +27,7 @@ it.each([ 'native/windows-registry/src/addon.cc', 'config/patches/node-pty.patch', '.github/actions/install-node-dependencies/action.yml', + '.github/actions/prepare-native-runtime/action.yml', 'src/main/ssh/ssh-provider.ts', 'src/main/providers/local-pty-provider.ts', 'src/shared/child-process/run-process.ts', diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index c72825c9fe1..7f34a8304a9 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -38,6 +38,7 @@ const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test']) const GLOBAL_FORCE_PREFIXES = [ '.github/workflows/pr.yml', '.github/actions/install-node-dependencies/', + '.github/actions/prepare-native-runtime/', 'config/scripts/pr-code-change-scope' ] @@ -98,6 +99,9 @@ const SHELL_PREFIXES = [ 'src/main/shell-wrapper-', 'src/main/terminal-history-fish', 'src/main/zsh-', + 'src/main/runtime/structured-session-cli-login-shell', + 'src/main/runtime/structured-session-login-shell-test-harness', + 'src/main/runtime/structured-session-child-identity-env', 'src/renderer/src/components/terminal-pane/fish-color-scheme', 'src/shared/fish-', 'src/shared/pty-reply-echo-shapes', @@ -178,7 +182,19 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/runtime/runtime-worktree-agent-', 'src/main/runtime/runtime-worktree-pty-agent-sources', 'src/shared/runtime-worktree-contracts', - 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer' + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer', + // Turn-end status a newer host publishes and an older desktop reads (cross-version-host-observed-turn-end). + 'src/shared/agent-turn-outcome', + 'src/shared/agent-status-types', + 'src/shared/agent-lead-status-fold', + 'src/shared/agent-session-turn-record', + 'src/shared/structured-agent-session-agent-status', + 'src/shared/structured-agent-session-projection', + 'src/shared/workspace-session-sleeping-agents', + // An older app opening a newer orchestration database (orchestration-delivery-downgrade). + 'src/main/runtime/orchestration/db.ts', + 'src/main/runtime/orchestration/db/', + 'src/main/runtime/orchestration/orchestration-schema-version-skew' ] const MANAGED_HOOK_PREFIXES = [ @@ -207,8 +223,26 @@ const NATIVE_CACHE_FILES = new Set([ 'package.json', 'pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', + '.github/actions/prepare-native-runtime/action.yml', + 'pnpm-workspace.yaml', + '.npmrc', + '.pnpmfile.cjs', 'config/scripts/ensure-native-runtime.mjs', - 'config/scripts/rebuild-native-deps.mjs' + 'config/scripts/rebuild-native-deps.mjs', + 'config/scripts/node-pty-job-ownership.cjs', + 'config/scripts/windows-pe-machine.cjs', + 'config/scripts/windows-process-tree-gyp-rebuild.mjs', + 'config/scripts/windows-process-tree-creation-time.cjs', + 'config/scripts/install-electron-package-binary.mjs', + 'config/scripts/electron-platform-path.mjs', + 'config/scripts/zip-extractor-command.mjs', + 'src/shared/zip-extractor-command.ts', + 'config/scripts/shared-electron-dist-cache.mjs', + 'config/scripts/space-sharing-copy.mjs', + 'native/windows-registry/src/addon.cc', + 'native/windows-registry/binding.gyp', + 'native/windows-registry/package.json', + 'native/windows-registry/index.js' ]) const NATIVE_CACHE_PREFIXES = [ diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 5c1302ca354..297d1f8e671 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -286,6 +286,23 @@ describe('per-job path classification', () => { }) }) + it('runs shell contracts for the structured-session login-shell test, its harness and its subject', () => { + expectClassification( + ['src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts'], + { shell_contracts: true } + ) + for (const file of [ + 'src/main/runtime/structured-session-login-shell-test-harness.ts', + 'src/main/runtime/structured-session-child-identity-env.ts' + ]) { + expectClassification([file], { + shell_contracts: true, + package: true, + package_windows: true + }) + } + }) + it('runs orcad browser when Chrome launch, session, or tab modules change', () => { for (const file of [ 'src/main/orcad/external-chromium-browser-session.ts', @@ -367,7 +384,11 @@ describe('per-job path classification', () => { 'src/main/runtime/runtime-worktree-agent-rows.ts', 'src/main/runtime/runtime-worktree-pty-agent-sources.ts', 'src/shared/runtime-worktree-contracts.ts', - 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts', + 'src/shared/structured-agent-session-projection.ts', + 'src/shared/agent-turn-outcome.ts', + 'src/main/runtime/orchestration/db.ts', + 'src/main/runtime/orchestration/orchestration-schema-version-skew.ts' ]) { expectClassification([file], { 'cross-version-wire': true, @@ -398,6 +419,7 @@ describe('per-job path classification', () => { 'package.json', 'pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', + '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch' diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index ac2be049bdf..9f841ffb1be 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -13,6 +13,9 @@ const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat. const dependencyAction = parse( readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8') ) +const nativeAction = parse( + readFileSync('.github/actions/prepare-native-runtime/action.yml', 'utf8') +) const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) const pnpmWorkspace = parse(readFileSync('pnpm-workspace.yaml', 'utf8')) const shellContractFiles = [ @@ -360,9 +363,9 @@ describe('PR workflow parallelism', () => { ).toBe("steps.deps.outputs.native-cache-hit != 'true'") expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true') - expect( - dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if - ).toBe("runner.os == 'Linux' && inputs.native-runtime == 'node'") + expect(nativeAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if).toBe( + "runner.os == 'Linux' && inputs.native-runtime == 'node'" + ) const dependencyInstall = dependencyAction.runs.steps.find( (step) => step.name === 'Install dependencies' ) @@ -383,7 +386,13 @@ describe('PR workflow parallelism', () => { (step) => step.name === 'Prepare native runtime' ) expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'") - expect(prepareRuntime.run).toContain('ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"') + expect(prepareRuntime.uses).toBe('./.github/actions/prepare-native-runtime') + expect(prepareRuntime.with).toEqual({ + 'native-runtime': '${{ inputs.native-runtime }}', + 'node-version': + '${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}', + 'persist-native-cache': '${{ inputs.persist-native-cache }}' + }) }) it('reuses native preparation after the dependency action gate', () => { @@ -399,14 +408,16 @@ describe('PR workflow parallelism', () => { }) it('restores compiled native modules after the install that strips them', () => { - const steps = dependencyAction.runs.steps - const installIndex = steps.findIndex((step) => step.name === 'Install dependencies') + const installerSteps = dependencyAction.runs.steps + const installIndex = installerSteps.findIndex((step) => step.name === 'Install dependencies') + const nativeIndex = installerSteps.findIndex((step) => step.id === 'native-runtime') + expect(installIndex).toBeLessThan(nativeIndex) + const steps = nativeAction.runs.steps const cacheIndex = steps.findIndex((step) => step.name === 'Restore compiled native modules') const prepareIndex = steps.findIndex((step) => step.name === 'Prepare native runtime') // `--ignore-scripts` leaves no build/, so a restore before the install would be // overwritten and one after the rebuild would never save a hit. - expect(installIndex).toBeLessThan(cacheIndex) expect(cacheIndex).toBeLessThan(prepareIndex) expect(steps[cacheIndex].if).toBe( "inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'" @@ -421,20 +432,8 @@ describe('PR workflow parallelism', () => { // Native artifacts are ABI-bound: a key missing either dimension serves a build // that cannot load, and ensure-native-runtime would recompile it anyway. for (const cacheStep of [steps[cacheIndex], restoreOnly]) { - expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}') - expect(cacheStep.with.key).toContain('${{ runner.os }}') - expect(cacheStep.with.key).toContain('${{ runner.arch }}') - expect(cacheStep.with.key).toContain( - 'steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version' - ) - expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope') - expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch') - expect(cacheStep.with.key).toContain( - 'config/patches/@vscode__windows-process-tree@0.8.0.patch' - ) - expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml') - expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs') - expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs') + expect(cacheStep.with.key).toBe('${{ steps.native-cache-scope.outputs.key }}') + expect(cacheStep.with.path).not.toContain('${{') expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build') expect(cacheStep.with.path).toContain('native/windows-registry/build') expect(cacheStep.with.path).toContain('@vscode+windows-process-tre*') @@ -445,17 +444,24 @@ describe('PR workflow parallelism', () => { const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope') expect(cacheScope.if).toBe("inputs.native-runtime != 'none'") expect(cacheScope.run).toContain('/etc/os-release') - expect(dependencyAction.outputs['native-cache-scope'].value).toBe( + expect(nativeAction.outputs['cache-scope'].value).toBe( '${{ steps.native-cache-scope.outputs.scope }}' ) - expect(dependencyAction.outputs['native-cache-hit'].value).toContain( + expect(nativeAction.outputs['cache-hit'].value).toContain( 'steps.native-cache-restore.outputs.cache-hit' ) - expect(dependencyAction.outputs['native-cache-hit'].value).toContain( + expect(nativeAction.outputs['cache-hit'].value).toContain( 'steps.native-cache-restore-only.outputs.cache-hit' ) - const electronCache = steps.find((step) => step.name === 'Cache Electron package archive') - const electronCacheResolution = steps.find( + for (const output of ['scope', 'hit', 'key', 'path']) { + expect(dependencyAction.outputs[`native-cache-${output}`].value).toBe( + `\${{ steps.native-runtime.outputs.cache-${output} }}` + ) + } + const electronCache = installerSteps.find( + (step) => step.name === 'Cache Electron package archive' + ) + const electronCacheResolution = installerSteps.find( (step) => step.name === 'Resolve Electron package cache' ) expect(electronCacheResolution.if).toBe( diff --git a/config/scripts/skills-cli-package-workflow.test.mjs b/config/scripts/skills-cli-package-workflow.test.mjs index 489437dd61f..6117ff99ba8 100644 --- a/config/scripts/skills-cli-package-workflow.test.mjs +++ b/config/scripts/skills-cli-package-workflow.test.mjs @@ -14,7 +14,11 @@ describe('packaged skills CLI PR gates', () => { expect(job['runs-on']).toBe('windows-2022') expect(buildStep.run).toBe('pnpm run build:release:parallel') - expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron') + expect(prepareStep.uses).toBe('./.github/actions/prepare-native-runtime') + expect(prepareStep.with).toEqual({ + 'native-runtime': 'electron', + 'node-version': '${{ steps.deps.outputs.node-version }}' + }) expect(packageStep.run).toContain('electron-builder') expect(packageStep.run).toContain('--dir') expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') diff --git a/docs/reference/antigravity-readiness-evidence.md b/docs/reference/antigravity-readiness-evidence.md index 22839299eec..18317d7e5ce 100644 --- a/docs/reference/antigravity-readiness-evidence.md +++ b/docs/reference/antigravity-readiness-evidence.md @@ -1,7 +1,10 @@ # Antigravity readiness: what the transcripts show -`findAntigravityReadyPromptIndex` in `src/main/runtime/terminal-wait-detection.ts` decides whether -an Antigravity pane is ready for a prompt. It has been written five times, each version tuned +Antigravity readiness lives in `src/main/runtime/agent-state-rules/antigravity.json`: a screen rule +over the trusted grid, and a text anchor that runs the named scan +`findAntigravityComposerIndex` (`agent-state-rules/antigravity-text-composer.ts`) over the +line-folded tail when no trusted grid exists. That text scan decides whether a pane is ready for a +prompt from its tail alone. It has been written five times, each version tuned against a five-line screen typed from memory into a `.spec.ts` fixture. Three of the first four were found worse than the bug they replaced, and the fifth was reverted. @@ -41,7 +44,7 @@ replays them. What they show: - **The text tail misses three ready screens.** On `ready-accept-edits`, `ready-plan` and - `turn-ended` the line-folded tail never satisfies `findAntigravityReadyPromptIndex`; the screen + `turn-ended` the line-folded tail never satisfies `findAntigravityComposerIndex`; the screen rule does. (`turn-ended` is the 1.2.14 form of the old `busy-turn-ended` known defect.) - **A caret rule is wrong on the screen.** The grid keeps the bare `>` through a turn and behind the picker. The text tail happened to lose it mid-turn (section 8); the screen does not. diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 5fccd8f8b7f..f4443b05c43 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -1060,3 +1060,138 @@ warnings remained non-fatal. Every full-repository scan passed cleanly. Keep the existing production waves. The temporary workflow and 601-line benchmark driver were removed after recording this rejected result. + +## Native cache ownership: retain the extraction + +Native restoration, toolchain recovery, and preparation now belong to +`.github/actions/prepare-native-runtime/action.yml`. The installer forwards +its requested key and three build paths; Windows packaging saves the Node +build before calling the same action for Electron. Existing native load, +patched-build, Windows job-ownership, registry, and process-table probes remain +unchanged on restored consumers. Exact keys still separate OS/image or Linux +container libc, architecture, runtime, resolved Node version, and actual pnpm +version, without partial-key restoration. + +The source hash covers the dedicated action, `pnpm-lock.yaml`, +`pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, both native dependency patches, +and these complete build/probe inputs: + +- `config/scripts/ensure-native-runtime.mjs`, `rebuild-native-deps.mjs`, + `node-pty-job-ownership.cjs`, `windows-pe-machine.cjs`, + `windows-process-tree-gyp-rebuild.mjs`, and + `windows-process-tree-creation-time.cjs`; +- `config/scripts/install-electron-package-binary.mjs`, + `electron-platform-path.mjs`, `zip-extractor-command.mjs`, + `shared-electron-dist-cache.mjs`, `space-sharing-copy.mjs`, and + `src/shared/zip-extractor-command.ts`; +- `native/windows-registry/src/addon.cc`, `binding.gyp`, `package.json`, and + `index.js`. + +The patches are `config/patches/node-pty@1.1.0.patch` and +`config/patches/@vscode__windows-process-tree@0.8.0.patch`. Root app version and +script metadata are excluded; installed package versions remain owned by the +full lockfile, and the external node-gyp pin belongs to the native action. +A negative control changing only the installer's +pnpm verification condition preserves the native key and paths. Every declared +native input mutation changes the key, and main warming watches those inputs. + +This policy creates one cold namespace. The bounded 50-head main sample has +49 adjacent transitions and three native-key changes under both the old and +expanded policies: the added node-pty helper export still invalidates #24448. +There is no measured historical net saving. + +The [cold warming run](https://github.com/stablyai/orca/actions/runs/36945655208/attempts/1) +published all four exact Node keys, and its +[warm rerun](https://github.com/stablyai/orca/actions/runs/36945655208/attempts/2) +restored them on fresh runners with the same frozen inputs, Node 24.21.0, and +pnpm 12.0.0. All five jobs passed in both attempts. These times cover the entire +native action: runtime validation, key resolution, restore, any toolchain +recovery, and the unchanged native preparation probes. + +| Native Node lane | Cold action | Warm action | Cold post-job save | +| ---------------- | ----------- | ----------- | ------------------ | +| Linux x64 | 18.288 s | 0.993 s | 0.387 s | +| Linux ARM64 | 11.540 s | 1.062 s | 1.113 s | +| Windows x64 | 104.482 s | 1.381 s | 2.424 s | +| Windows ARM64 | 256.662 s | 3.832 s | 1.243 s | + +Cold Windows jobs rebuilt all three native addons. Warm jobs loaded and probed +the restored builds; Linux also ran the existing check-only probe before +skipping the external node-gyp installation. Warm post-job steps recognized +their primary keys and did not save again. An earlier trial exposed unavailable +nested composite outputs during post-job saving; both cache variants now use +the same literal path inventory as the requested output, and the fixed cold +jobs published their caches without missing-path warnings. + +Both [PR package jobs](https://github.com/stablyai/orca/actions/runs/36945659474) +passed. Windows packaging consumed the same-run Node seed in 1.374 seconds +before its Node tests and Electron transition. Its Electron cache initially +missed while the modules were already healthy, so that stage does not establish +an avoided compilation. Linux's Electron cache was also published, and all 19 +bundled native binaries passed the existing glibc floor check. + +The [first six-platform headless run](https://github.com/stablyai/orca/actions/runs/36945658897) +ran every persistence lane: five passed, while Mac Intel failed waiting for a +cancel-test worker's ready file before its 500 ms timeout. That lane deliberately +uses `native-runtime: none`; its separate slot build and smoke passed. The failure +blocked the five downstream Linux glibc/musl qualifications, so this run does not +establish complete headless qualification. All six persistence lanes, Node 18 +handoffs, and Linux floor/musl gates remain; final qualification is tracked in +the [PR's latest checks](https://github.com/stablyai/orca/pull/24476/checks). + +These are single cold/warm observations, not paired medians or a measured +whole-workflow saving. They demonstrate usable exact-key reuse after publication; +future savings depend on cache availability and unchanged native inputs. The +trial seeds belong to this PR's merge ref. Other PRs require a main-branch seed +after merging this new namespace; the existing main push and hourly warming +jobs provide that seed. + +## Separate mobile install verification: retain the current policy + +Three local paired pnpm 12 mobile installs reduced the median from 17.155 to +15.871 seconds, a 1.284-second difference before cache transfer and postinstall +scripts. That narrow margin does not establish a net hosted saving, so the +separate mobile verification record was not adopted. + +## Unit shard weights: retain the current allocation + +The latest five shard wall times were 526/495/503/508/510 seconds. Reweighting +projected roughly a 4% reduction in the slowest shard without reducing total +CPU work; the evidence across runs was weak. That estimate does not justify +changing allocation, so the current weights remain. + +## Serializer oracle allocations: retain the change + +The serializer round-trip oracle now reloads one xterm cell per buffer traversal +and writes flag digits directly, avoiding fresh cell objects and flag arrays for +every comparison. Independent replay terminals, cell descriptors, transcript +fixtures, resize schedules, ConPTY modes and seeds remain unchanged. + +The [hosted comparison](https://github.com/stablyai/orca/actions/runs/36944080887) +used one Ubuntu 24.04 ARM64 runner, image 20260927.135.1, Node 24.21.0 and one +isolated fork. The baseline formatter was frozen from f69052e. Byte-parity capture +ran separately; these three alternating pairs had no payload instrumentation. +Times cover the complete Vitest invocation, including startup and shutdown. + +| Pair/order | Baseline | Candidate | Change | +| ------------------ | -------- | --------- | ------ | +| 1: baseline first | 70.631 s | 61.661 s | -12.7% | +| 2: candidate first | 70.619 s | 62.284 s | -11.8% | +| 3: baseline first | 70.750 s | 62.091 s | -12.2% | +| Median | 70.631 s | 62.091 s | -12.1% | + +Median test-body time fell from 69.519 to 60.953 seconds. All eight full-cohort +invocations preserved the same 89 passes and two existing conditional skips +across three files. Separate baseline/candidate captures produced identical +95,017,559-byte payloads for all 1,435 scenarios and 7,649 checkpoints, with zero +source crashes; both SHA256 hashes matched the local captures. + +Seven focused controls compare against the original allocating oracle, including +all 128 text-flag combinations, styled blanks, wide cells, cell reuse and immutable +snapshots. Five deliberate faults were detected: stale cell contents, a missing +bold flag, changed empty-cell policy, removed scratch reuse and a source-parser +crash. The last control also proved that crash returns enter the capture. + +This measures the three-file oracle cohort. Whole-shard timings include other +test bodies, imports and transforms, so a whole-suite saving needs separate +measurement. diff --git a/docs/reference/cline-and-prime-agent-readiness-evidence.md b/docs/reference/cline-and-prime-agent-readiness-evidence.md index 5780226f0b0..6509c185f7b 100644 --- a/docs/reference/cline-and-prime-agent-readiness-evidence.md +++ b/docs/reference/cline-and-prime-agent-readiness-evidence.md @@ -1,8 +1,9 @@ # Cline and Prime Agent readiness: what the transcripts show Both agents paint their composer with cursor addressing on the alternate screen, so the line-folded -text tail cannot see it (#23268, #22153). Their readiness is read off the live screen by -`isClineComposerReadyScreen` and `isPrimeAgentComposerReadyScreen`, through the same tiering as +text tail cannot see it (#23268, #22153). Their readiness is read off the live screen by the +`composer_ready` rules in `src/main/runtime/agent-state-rules/cline.json` and `prime-agent.json`, +through the same tiering as Antigravity ([`antigravity-readiness-evidence.md`](./antigravity-readiness-evidence.md)): a pane with an output clock is believed only once quiet, and when a trustworthy screen exists it decides, so the quiet-process lane cannot settle a dialog it cannot see. Without a readable screen (a diff --git a/src/main/claude/claude-informational-row.test.ts b/src/main/claude/claude-informational-row.test.ts new file mode 100644 index 00000000000..e41b71e1a2c --- /dev/null +++ b/src/main/claude/claude-informational-row.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: 'leaf-1' } +} + +/** A frame as Claude Code 2.1.280 sends it: a transcript note at a render level. */ +function informational( + level: 'info' | 'notice' | 'suggestion' | 'warning', + content: string +): Record { + return { + type: 'system', + subtype: 'informational', + content, + level, + uuid: `5d3c1f0e-7a2b-4c6d-9e8f-00000000000${level.length}`, + session_id: 'provider-1' + } +} + +let root = '' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-claude-informational-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function itemsFor(frames: Record[]) { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + database: openTestJournalHostDatabase(root), + now: () => 1_700_000_000_000, + mintEpoch: () => 'epoch-1' + }) + const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) + deferred.bind({ journal, fence: 1, publish: vi.fn() }) + const translator = createClaudeJournalTranslator({ sink: deferred.sink, fallbackIdPrefix: '1' }) + for (const frame of frames) { + translator.handle({ type: 'message', sessionId: 'orca-session', message: frame }) + await deferred.drained() + } + return journal.snapshot().items.map((item) => item.body) +} + +describe('a Claude informational frame', () => { + it.each(['info', 'notice', 'suggestion'] as const)('writes no row at level %s', async (level) => { + expect(await itemsFor([informational(level, `A ${level} note`)])).toEqual([]) + }) + + it('writes its own words as one warning row at level warning', async () => { + const content = + 'UserPromptSubmit operation blocked by hook: secrets are not allowed\n\nOriginal prompt: deploy it' + const items = await itemsFor([informational('warning', content)]) + + expect(items).toEqual([{ kind: 'status', tone: 'warning', text: content }]) + }) + + it('writes no row for a warning with nothing to say', async () => { + expect(await itemsFor([informational('warning', ' ')])).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-informational-row.ts b/src/main/claude/claude-informational-row.ts new file mode 100644 index 00000000000..2b4b28be278 --- /dev/null +++ b/src/main/claude/claude-informational-row.ts @@ -0,0 +1,25 @@ +// Only a `warning` frame (a UserPromptSubmit hook that blocked the prompt, say) earns a row, in its +// own words; `info`, `notice` (a hook's systemMessage) and `suggestion` are session chrome, not chat rows. + +import type { AgentJournalStatusItem } from '../../shared/agent-session-journal-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { claudeText } from './claude-structured-item-translation' + +export const CLAUDE_INFORMATIONAL_FRAME_KIND = 'message:system:informational' + +/** The row a warning-level note writes; null for every other level and for a warning with no words. */ +export function claudeInformationalRowBody( + message: Record +): AgentJournalStatusItem | null { + const text = message.level === 'warning' ? claudeText(message.content)?.trim() : null + return text + ? { + kind: 'status', + tone: 'warning', + text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text + } + : null +} diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index 0fa645ea648..af6ed824dd2 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -22,6 +22,10 @@ import { claudeApiRetryRowBody, createClaudeApiRetryRuns } from './claude-api-retry-row' +import { + CLAUDE_INFORMATIONAL_FRAME_KIND, + claudeInformationalRowBody +} from './claude-informational-row' export function claudeProviderFrameKind(message: Record): string { const type = claudeText(message.type) ?? 'unknown' @@ -147,6 +151,21 @@ export function createClaudeProviderFrameFallback( sink.publish() return true } + if (kind === CLAUDE_INFORMATIONAL_FRAME_KIND) { + // Never the frame as a row: a warning in its own words, any other level nothing. + const body = claudeInformationalRowBody(claudeRecord(payload) ?? {}) + if (!body) { + return false + } + beforeAppend?.() + const identity = { + provider: 'orca', + clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}` + } as const + sink.appendItem(identity, body, stamp?.(identity, body) ?? { turnScope: turnScope() }) + sink.publish() + return true + } const translated = unhandledProviderFrameJournalItem( 'claude', kind, diff --git a/src/main/daemon/serialize-grid-cell-descriptors.test.ts b/src/main/daemon/serialize-grid-cell-descriptors.test.ts new file mode 100644 index 00000000000..490b40a1c9d --- /dev/null +++ b/src/main/daemon/serialize-grid-cell-descriptors.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Terminal } from '@xterm/headless' +import { bufferRows, cellDescriptor } from './serialize-grid-cell-descriptors' +import { createFuzzTerminal, writeTerminal } from './serialize-grid-roundtrip' + +// Frozen allocating oracle from f69052e; a reused cell must preserve every descriptor. +type BufferLine = NonNullable> +type Buffer = Terminal['buffer']['active'] + +const COLOR_MODE_P16 = 16777216 +const COLOR_MODE_P256 = 33554432 +const DEFAULT_BLANK = '▯·w1·b0:-1·000' +const CLIPPED = 'CLIPPED' + +// SerializeAddon re-emits palette 0-15 set via 38;5;N as SGR 30-37/90-97; same theme slot. +function canonicalColorMode(mode: number, colorValue: number): number { + return mode === COLOR_MODE_P256 && colorValue >= 0 && colorValue < 16 ? COLOR_MODE_P16 : mode +} + +function flags(values: boolean[]): string { + return values.map((flag) => (flag ? '1' : '0')).join('') +} + +/** Visually effective cell state, same blank-cell policy as terminal-restore-parity-fixture. */ +function allocatingCellDescriptor(line: BufferLine | undefined, x: number, cols: number): string { + if (!line || x >= line.length) { + return DEFAULT_BLANK + } + const cell = line.getCell(x) + if (!cell) { + return DEFAULT_BLANK + } + if (x === cols - 1 && line.length > cols && cell.getWidth() > 1) { + return CLIPPED + } + const chars = cell.getChars() + const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor()) + const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor()) + if (chars === '' || chars === ' ') { + const blank = chars === ' ' + const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : '' + return `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${flags([ + blank && cell.isUnderline() !== 0, + blank && cell.isStrikethrough() !== 0, + blank && cell.isOverline() !== 0 + ])}${inverseFg}` + } + const cellFlags = flags([ + cell.isBold() !== 0, + cell.isDim() !== 0, + cell.isItalic() !== 0, + cell.isUnderline() !== 0, + cell.isInverse() !== 0, + cell.isInvisible() !== 0, + cell.isStrikethrough() !== 0 + ]) + return `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${cellFlags}` +} + +function rowCells(line: BufferLine | undefined, cols: number): string[] { + return Array.from({ length: cols }, (_, x) => allocatingCellDescriptor(line, x, cols)) +} + +function allocatingBufferRows( + buffer: Buffer, + start: number, + end: number, + cols: number +): string[][] { + const rows: string[][] = [] + for (let y = start; y < end; y++) { + rows.push(rowCells(buffer.getLine(y), cols)) + } + while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) { + rows.pop() + } + return rows +} + +describe('serialize oracle cell reuse', () => { + it.each([false, true])( + 'matches allocating cells across SGR, wide text, buffers and resize (ConPTY=%s)', + (conpty) => { + const terminal = createFuzzTerminal({ cols: 14, rows: 4, scrollback: 30, conpty }) + try { + const writes = [ + 'plain \x1b[1;2;3;4;7;8;9mstyled\x1b[0m\r\n', + '\x1b[38;5;2;48;5;10m palette \x1b[38;2;11;22;33;48;2;44;55;66m RGB \x1b[0m\r\n', + '\x1b[4:3;9;53m \x1b[0m\x1b[7m \x1b[0m界👩‍💻é\r\n', + 'scroll1\r\nscroll2\r\nscroll3\r\n', + '\x1b[?1049h\x1b[1;2;3;4;7;8;9malt界\x1b[0m', + '\x1b[?1049l\x1b[2J\x1b[Hclear' + ] + for (const data of writes) { + writeTerminal(terminal, data) + for (const buffer of [ + terminal.buffer.normal, + terminal.buffer.alternate, + terminal.buffer.active + ]) { + expect(bufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2)).toEqual( + allocatingBufferRows(buffer, -1, buffer.length + 1, terminal.cols + 2) + ) + } + terminal.resize(terminal.cols === 14 ? 9 : 14, 4) + expect( + bufferRows(terminal.buffer.active, 0, terminal.buffer.active.length, terminal.cols) + ).toEqual( + allocatingBufferRows( + terminal.buffer.active, + 0, + terminal.buffer.active.length, + terminal.cols + ) + ) + } + } finally { + terminal.dispose() + } + } + ) + + it('preserves the order of every text flag combination while reloading a plain cell', () => { + const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) + try { + const sgr = [1, 2, 3, 4, 7, 8, 9] + const scratch = terminal.buffer.active.getNullCell() + for (let mask = 0; mask < 128; mask++) { + const codes = sgr.filter((_code, bit) => (mask & (1 << bit)) !== 0) + writeTerminal(terminal, `\x1b[H\x1b[0m\x1b[${codes.length ? codes.join(';') : 0}mA\x1b[0mB`) + const line = terminal.buffer.active.getLine(0) + expect(cellDescriptor(line, 0, 4, scratch)).toBe(allocatingCellDescriptor(line, 0, 4)) + expect(cellDescriptor(line, 1, 4, scratch)).toBe(allocatingCellDescriptor(line, 1, 4)) + } + } finally { + terminal.dispose() + } + }) + + it('preserves styled spaces, empty cells and inverse foreground colors', () => { + const terminal = createFuzzTerminal({ cols: 4, rows: 1, scrollback: 0 }) + try { + const scratch = terminal.buffer.active.getNullCell() + for (const inverse of [false, true]) { + for (const glyph of ['', ' ']) { + for (let mask = 0; mask < 8; mask++) { + const codes = [4, 9, 53].filter((_code, bit) => (mask & (1 << bit)) !== 0) + writeTerminal( + terminal, + `\x1b[H\x1b[0m\x1b[38;2;3;4;5;48;5;2${inverse ? ';7' : ''}${codes.length ? `;${codes.join(';')}` : ''}m\x1b[2J${glyph}` + ) + const line = terminal.buffer.active.getLine(0) + expect(cellDescriptor(line, 0, 4, scratch)).toBe(allocatingCellDescriptor(line, 0, 4)) + } + } + } + } finally { + terminal.dispose() + } + }) + + it('preserves a clipped wide leading cell at the comparison grid edge', () => { + const terminal = createFuzzTerminal({ cols: 8, rows: 1, scrollback: 0 }) + try { + writeTerminal(terminal, 'abc界') + const line = terminal.buffer.active.getLine(0) + const scratch = terminal.buffer.active.getNullCell() + expect(cellDescriptor(line, 3, 4, scratch)).toBe('CLIPPED') + expect(cellDescriptor(line, 3, 4, scratch)).toBe(allocatingCellDescriptor(line, 3, 4)) + expect(bufferRows(terminal.buffer.active, 0, 1, 4)).toEqual( + allocatingBufferRows(terminal.buffer.active, 0, 1, 4) + ) + } finally { + terminal.dispose() + } + }) + + it('loads every cell into one traversal-local scratch object and retains immutable descriptors', () => { + const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 }) + try { + writeTerminal(terminal, '\x1b[1mA\x1b[0m B界') + const buffer = terminal.buffer.active + const scratch = buffer.getNullCell() + const allocate = vi.spyOn(buffer, 'getNullCell').mockReturnValue(scratch) + const getLine = buffer.getLine.bind(buffer) + const loaded: unknown[] = [] + vi.spyOn(buffer, 'getLine').mockImplementation((y) => { + const line = getLine(y) + if (line) { + const getCell = line.getCell.bind(line) + vi.spyOn(line, 'getCell').mockImplementation((x, cell) => { + loaded.push(cell) + return getCell(x, cell) + }) + } + return line + }) + const expected = allocatingBufferRows(buffer, 0, 2, 4) + loaded.length = 0 + const actual = bufferRows(buffer, 0, 2, 4) + expect(actual).toEqual(expected) + expect(allocate).toHaveBeenCalledTimes(1) + expect(loaded).toHaveLength(8) + expect(loaded.every((cell) => cell === scratch)).toBe(true) + writeTerminal(terminal, '\x1b[2J\x1b[Hnew') + bufferRows(buffer, 0, 2, 4) + expect(actual).toEqual(expected) + } finally { + terminal.dispose() + vi.restoreAllMocks() + } + }) + + it('keeps missing lines and invalid columns blank after a styled cell occupied the scratch', () => { + const terminal = createFuzzTerminal({ cols: 4, rows: 2, scrollback: 0 }) + try { + writeTerminal(terminal, '\x1b[1;7;38;2;5;6;7mX') + const line = terminal.buffer.active.getLine(0) + const scratch = terminal.buffer.active.getNullCell() + expect(cellDescriptor(line, 0, 4, scratch)).toBe(allocatingCellDescriptor(line, 0, 4)) + for (const x of [-1, 4, 5]) { + expect(cellDescriptor(line, x, 4, scratch)).toBe(allocatingCellDescriptor(line, x, 4)) + } + expect(cellDescriptor(undefined, 0, 4, scratch)).toBe( + allocatingCellDescriptor(undefined, 0, 4) + ) + expect(cellDescriptor(line, 1, 4, scratch)).toBe(allocatingCellDescriptor(line, 1, 4)) + } finally { + terminal.dispose() + } + }) +}) diff --git a/src/main/daemon/serialize-grid-cell-descriptors.ts b/src/main/daemon/serialize-grid-cell-descriptors.ts index 391990de741..8b6c331f5fc 100644 --- a/src/main/daemon/serialize-grid-cell-descriptors.ts +++ b/src/main/daemon/serialize-grid-cell-descriptors.ts @@ -18,16 +18,17 @@ function canonicalColorMode(mode: number, colorValue: number): number { return mode === COLOR_MODE_P256 && colorValue >= 0 && colorValue < 16 ? COLOR_MODE_P16 : mode } -function flags(values: boolean[]): string { - return values.map((flag) => (flag ? '1' : '0')).join('') -} - /** Visually effective cell state, same blank-cell policy as terminal-restore-parity-fixture. */ -export function cellDescriptor(line: BufferLine | undefined, x: number, cols: number): string { +export function cellDescriptor( + line: BufferLine | undefined, + x: number, + cols: number, + reusableCell?: ReturnType +): string { if (!line || x >= line.length) { return DEFAULT_BLANK } - const cell = line.getCell(x) + const cell = line.getCell(x, reusableCell) if (!cell) { return DEFAULT_BLANK } @@ -40,26 +41,22 @@ export function cellDescriptor(line: BufferLine | undefined, x: number, cols: nu if (chars === '' || chars === ' ') { const blank = chars === ' ' const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : '' - return `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${flags([ - blank && cell.isUnderline() !== 0, - blank && cell.isStrikethrough() !== 0, - blank && cell.isOverline() !== 0 - ])}${inverseFg}` + return `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blank && cell.isUnderline() !== 0 ? '1' : '0'}${blank && cell.isStrikethrough() !== 0 ? '1' : '0'}${blank && cell.isOverline() !== 0 ? '1' : '0'}${inverseFg}` } - const cellFlags = flags([ - cell.isBold() !== 0, - cell.isDim() !== 0, - cell.isItalic() !== 0, - cell.isUnderline() !== 0, - cell.isInverse() !== 0, - cell.isInvisible() !== 0, - cell.isStrikethrough() !== 0 - ]) + const cellFlags = + `${cell.isBold() !== 0 ? '1' : '0'}${cell.isDim() !== 0 ? '1' : '0'}` + + `${cell.isItalic() !== 0 ? '1' : '0'}${cell.isUnderline() !== 0 ? '1' : '0'}` + + `${cell.isInverse() !== 0 ? '1' : '0'}${cell.isInvisible() !== 0 ? '1' : '0'}` + + `${cell.isStrikethrough() !== 0 ? '1' : '0'}` return `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${cellFlags}` } -function rowCells(line: BufferLine | undefined, cols: number): string[] { - return Array.from({ length: cols }, (_, x) => cellDescriptor(line, x, cols)) +function rowCells( + line: BufferLine | undefined, + cols: number, + reusableCell: ReturnType +): string[] { + return Array.from({ length: cols }, (_, x) => cellDescriptor(line, x, cols, reusableCell)) } // A wide glyph whose trailing half lies past the grid cannot be replayed; any blank is faithful. @@ -73,8 +70,9 @@ function rowsMatch(expected: string[], actual: string[]): boolean { export function bufferRows(buffer: Buffer, start: number, end: number, cols: number): string[][] { const rows: string[][] = [] + const reusableCell = buffer.getNullCell() for (let y = start; y < end; y++) { - rows.push(rowCells(buffer.getLine(y), cols)) + rows.push(rowCells(buffer.getLine(y), cols, reusableCell)) } while (rows.length > 0 && rows.at(-1)!.every((c) => c === DEFAULT_BLANK)) { rows.pop() diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.ts index 1fe7c49cdda..0f16da4de07 100644 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.ts +++ b/src/main/ipc/orcad-runtime-maintenance-handlers.ts @@ -23,7 +23,10 @@ export function registerOrcadRuntimeMaintenanceHandlers(options: { }): void { ipcMain.handle( 'runtimeEnvironments:updateOrcad', - async (_event, args: { selector: string; force?: boolean }): Promise => { + async ( + _event, + args: { selector: string; force?: boolean } + ): Promise => { const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), { selector: requiredString(args?.selector, 'Server'), force: args?.force === true diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 4cc90430d7e..1b89afd3997 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -133,7 +133,8 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { 'message:system:permission_denied': 'error-surface', 'message:prompt_suggestion': 'status-chrome', 'message:system:mirror_error': 'error-surface', - 'message:system:informational': 'timeline-substantive', + // Written by its own row (`claude-informational-row`): a warning in its words, else nothing. + 'message:system:informational': 'status-chrome', 'message:conversation_reset': 'status-chrome', // A `started`/`completed`/`cancelled` state for one queued command uuid and // nothing else; the CLI keeps it out of its own transcript too. A state that diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 5c9f5215a95..f74532ac9ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -1,4 +1,5 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** @@ -54,3 +55,44 @@ export function listStructuredAgentSessionTabs( agent: session.params.provider })) } + +type TabSessions = ReadonlyMap< + string, + { + child?: unknown + params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } + } +> + +/** The host's chat-tab surface; reads `host.deps` per call, so it sees the host's wrapped deps. */ +export function createStructuredAgentSessionTabSurface( + host: Parameters[0] & { + deps: { + store: Pick< + AgentSessionRecordStore, + 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' + > + } + }, + sessions: TabSessions, + forgetStatus: (sessionId: string) => void +) { + return { + listSessionTabs: () => listStructuredAgentSessionTabs(sessions), + getPersistedVisibleSessionTabIndex: () => host.deps.store.getVisibleSessionTabIndex(), + getSessionTabId: (sessionId: string): string | null => + host.deps.store.getSessionTabId(sessionId), + showSessionTabs: (sessionIds: readonly string[]) => host.deps.store.showSessionTabs(sessionIds), + setSessionTabVisibility: async ( + sessionId: string, + visible: boolean, + tabId?: string + ): Promise => { + await setStructuredAgentSessionTabVisibility(host, sessionId, visible, tabId) + // The tab edge of the row's lifetime; the handle close is the other. + if (!visible && !sessions.get(sessionId)?.child) { + forgetStatus(sessionId) + } + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 4c29fd5e3d0..44a576e2f78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -216,28 +216,22 @@ export class StructuredAgentSessionHost { supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) - listSessionTabs = () => sessionTabs.listStructuredAgentSessionTabs(this.sessions) - getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() - getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) - showSessionTabs = (sessionIds: readonly string[]) => this.deps.store.showSessionTabs(sessionIds) + private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( + this, + this.sessions, + (sessionId) => this.clientDelivery.forgetStatus(sessionId) + ) + listSessionTabs = this.tabs.listSessionTabs + getPersistedVisibleSessionTabIndex = this.tabs.getPersistedVisibleSessionTabIndex + getSessionTabId = this.tabs.getSessionTabId + showSessionTabs = this.tabs.showSessionTabs + setSessionTabVisibility = this.tabs.setSessionTabVisibility /** The records file could not be read this launch, so chats it holds are not listed yet. */ legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true /** This runtime holds a chat: a record, or the records file's chats still owed their copy. */ holdsSessions = (): boolean => this.deps.store.holdsRecords() || this.legacyRecordImportOwed() onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener) - setSessionTabVisibility = async ( - sessionId: string, - visible: boolean, - tabId?: string - ): Promise => { - await sessionTabs.setStructuredAgentSessionTabVisibility(this, sessionId, visible, tabId) - // The tab edge of the row's lifetime; the handle close is the other. - if (!visible && !this.sessions.get(sessionId)?.child) { - this.clientDelivery.forgetStatus(sessionId) - } - } - reconcileRestartLeases = (): Promise => this.restore.reconcileRestartLeases() restoreReadableSessions = (sessionIds?: readonly string[]): Promise => diff --git a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts index 26457dd4434..4f331ed711e 100644 --- a/src/main/persistence/profile-state/profile-state-backup-worker.test.ts +++ b/src/main/persistence/profile-state/profile-state-backup-worker.test.ts @@ -181,11 +181,12 @@ describe('profile state backup worker', () => { const cancellation = new AbortController() const pending = runProfileStateBackupWorker(job, { workerPath: worker, - timeoutMs: 500, + // Worker startup must not race the cancellation assertion. + timeoutMs: mode === 'cancel' ? 10_000 : 500, signal: cancellation.signal }) const failed = expect(pending).rejects.toThrow(mode === 'cancel' ? 'cancelled' : 'timed out') - await vi.waitFor(() => expect(existsSync(ready)).toBe(true)) + await vi.waitFor(() => expect(existsSync(ready)).toBe(true), { timeout: 5_000 }) expect(readdirSync(directory).filter((name) => name.startsWith('backup.db.'))).toHaveLength(4) if (mode === 'cancel') { cancellation.abort() diff --git a/src/main/runtime/agent-state-rules/agent-state-rule-matchers.ts b/src/main/runtime/agent-state-rules/agent-state-rule-matchers.ts new file mode 100644 index 00000000000..458d7ec523b --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rule-matchers.ts @@ -0,0 +1,63 @@ +import type { ScreenCondition, TextTest } from './agent-state-rules-schema' + +export type TextMatcher = (text: string) => boolean + +type TextTerm = Extract + +function compileTerm(term: TextTerm): TextMatcher { + if ('contains' in term) { + return (text) => text.includes(term.contains) + } + const pattern = new RegExp(term.regex, term.ignoreCase ? 'i' : '') + return (text) => pattern.test(text) +} + +export function compileTextTest(test: TextTest): TextMatcher { + if ('regex' in test || 'contains' in test) { + return compileTerm(test) + } + const all = (test.all ?? []).map(compileTerm) + const any = (test.any ?? []).map(compileTerm) + const none = (test.none ?? []).map(compileTerm) + return (text) => + all.every((matches) => matches(text)) && + (any.length === 0 || any.some((matches) => matches(text))) && + !none.some((matches) => matches(text)) +} + +export type ScreenMatcher = (screenLines: readonly string[]) => boolean + +export function compileScreenCondition(screen: ScreenCondition): ScreenMatcher { + if (!screen.rows) { + return () => true + } + const rows = screen.rows.map((row) => + 'optional' in row + ? { matches: compileTextTest(row.optional), optional: true } + : { matches: compileTextTest(row), optional: false } + ) + const endsWithinBottom = screen.endsWithinBottom ?? 1 + const noneAbove = screen.noneAbove ? compileTextTest(screen.noneAbove) : null + const lastRow = rows.at(-1) + const rowsUpward = rows.slice(0, -1).toReversed() + return (screenLines) => { + const lines = screenLines.map((line) => line.trim()) + let end = lines.length - 1 + const lowestEnd = Math.max(0, lines.length - endsWithinBottom) + while (end >= lowestEnd && !lastRow?.matches(lines[end])) { + end -= 1 + } + if (end < lowestEnd) { + return false + } + let cursor = end - 1 + for (const row of rowsUpward) { + if (row.matches(lines[cursor] ?? '')) { + cursor -= 1 + } else if (!row.optional) { + return false + } + } + return noneAbove === null || !lines.slice(0, Math.max(0, cursor + 1)).some(noneAbove) + } +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rule-pattern-safety.ts b/src/main/runtime/agent-state-rules/agent-state-rule-pattern-safety.ts new file mode 100644 index 00000000000..8d7ea4a7483 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rule-pattern-safety.ts @@ -0,0 +1,60 @@ +// Why a subset: rule patterns run on every poll, and Node has no linear-time regex engine, so a +// pattern that can backtrack exponentially is refused when the file loads. Overlapping adjacent +// quantifiers (`\s*\s*x`) are polynomial and not detected. +export function findUnsafePatternReason(pattern: string): string | null { + try { + new RegExp(pattern) + } catch { + return 'does not compile' + } + if (/\\[1-9]|\\k` opens a group; it quantifies nothing. + if (pattern[index + 1] === '?') { + index += 1 + } + } else if (char === ')') { + const bodyVaries = groupVaries.pop() ?? false + if (bodyVaries && isRepeatingQuantifier(pattern[index + 1])) { + return true + } + if (bodyVaries && groupVaries.length > 0) { + groupVaries[groupVaries.length - 1] = true + } + } else if ( + (isRepeatingQuantifier(char) || char === '?' || char === '|') && + groupVaries.length > 0 + ) { + groupVaries[groupVaries.length - 1] = true + } + } + return false +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rule-regions.test.ts b/src/main/runtime/agent-state-rules/agent-state-rule-regions.test.ts new file mode 100644 index 00000000000..09b74cd7a69 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rule-regions.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { + detectExplicitIdleStatusFromTitle, + detectTerminalWaitBlockedReason, + isKnownReadyPromptBody, + isKnownReadyPromptPreview, + isKnownReadyPromptSettled +} from '../terminal-wait-detection' +import { nameOnlyIdleNeedsCorroboration } from '../tui-idle-evidence' +import { + compileAgentRules, + evaluateCompiledRules, + readsTrustedScreen, + type AgentStateRegions +} from './agent-state-rules-engine' +import { parseAgentStateRuleFiles } from './agent-state-rules-catalog' +import { showsIdleTitleAnchor } from './agent-state-title-anchors' + +const STRONG_QUIET = { state: 'idle', strength: 'strong', requiresQuiet: true } +const TITLE = { region: 'title', status: 'idle' } + +function rule(id: string, when: Record, answer: Record) { + return { id, why: 'test', priority: 100, when, answer } +} + +const READY_ANCHOR = { + id: 'ready', + why: 'test', + when: { region: 'text', find: { lastOf: 'ready>' } }, + answer: { state: 'idle' } +} + +function file(rules: unknown[], extra: Record = {}): Record { + return { id: 'codex', engineVersion: 1, anchors: [READY_ANCHOR], rules, ...extra } +} + +function evaluate(rules: unknown[], regions: AgentStateRegions): string | null { + const [parsed] = parseAgentStateRuleFiles([file(rules, { profile: { screenSource: 'live' } })]) + return evaluateCompiledRules(compileAgentRules(parsed), regions)?.ruleId ?? null +} + +describe('region schema', () => { + const screen = { region: 'screen', predicate: 'codex-composer-ready' } + + it.each([ + [ + 'screen rows beside a predicate', + file([rule('a', { ...screen, rows: [{ contains: '>' }] }, STRONG_QUIET)], { + profile: { screenSource: 'live' } + }) + ], + ['screen rules with no screenSource', file([rule('a', screen, STRONG_QUIET)])], + [ + 'withoutClock on a weak rule', + file([ + rule( + 'a', + { region: 'title', status: 'idle' }, + { state: 'idle', strength: 'weak', requiresQuiet: true, withoutClock: 'skip' } + ) + ]) + ], + [ + 'a text rule naming no anchor of its file', + file([rule('a', { region: 'text', anchor: 'missing' }, STRONG_QUIET)]) + ], + [ + 'a text rule naming a non-idle anchor', + file([rule('a', { region: 'text', anchor: 'live' }, STRONG_QUIET)], { + anchors: [{ ...READY_ANCHOR, id: 'live', answer: { state: 'live' } }] + }) + ], + [ + 'a title anchor answering other than idle', + file([], { + anchors: [ + { + id: 't', + why: 'test', + when: { region: 'title', match: { contains: '◇' } }, + answer: { state: 'live' } + } + ] + }) + ], + [ + 'a title rule on a status other than idle', + file([rule('a', { region: 'title', status: 'working' }, STRONG_QUIET)]) + ], + ['an unknown pane id', { ...file([]), id: 'unknown' }], + ['two anchors with one id', file([], { anchors: [READY_ANCHOR, READY_ANCHOR] })], + [ + 'two rules with one id', + file([rule('a', TITLE, STRONG_QUIET), rule('a', TITLE, STRONG_QUIET)]) + ] + ])('rejects %s', (_label, candidate) => { + expect(() => parseAgentStateRuleFiles([candidate])).toThrow() + }) + + it('accepts the unknown-pane file', () => { + expect(() => parseAgentStateRuleFiles([{ ...file([]), id: 'unknown-pane' }])).not.toThrow() + }) +}) + +describe('regions', () => { + const title = rule('title', { region: 'title', status: 'idle' }, STRONG_QUIET) + const text = rule('text', { region: 'text', anchor: 'ready' }, STRONG_QUIET) + + it('skips a rule whose region the lane does not read', () => { + expect(evaluate([title], { readScreenLines: () => [] })).toBeNull() + expect(evaluate([title], { readTitleStatus: () => 'idle' })).toBe('title') + expect(evaluate([title], { readTitleStatus: () => 'working' })).toBeNull() + }) + + it('reads a named screen predicate over the lowercased screen', () => { + const composer = rule( + 'c', + { region: 'screen', predicate: 'codex-composer-ready' }, + STRONG_QUIET + ) + expect(evaluate([composer], { readScreenLines: () => ['› Ask Codex to do anything'] })).toBe( + 'c' + ) + expect( + evaluate([composer], { + readScreenLines: () => ['esc to interrupt)', '› Ask Codex to do anything'] + }) + ).toBeNull() + }) + + it('needs its text anchor settled: no blocker painted after it', () => { + const readText = (value: string) => ({ readText: () => value }) + expect(evaluate([text], readText('ready>'))).toBe('text') + expect(evaluate([text], readText('ready>\ndo you trust this folder?'))).toBeNull() + expect(evaluate([text], readText('do you trust this folder?\nready>'))).toBe('text') + }) + + it('skips a rule marked withoutClock skip only on a pane with no output clock', () => { + const skipped = rule( + 's', + { region: 'title', status: 'idle' }, + { ...STRONG_QUIET, withoutClock: 'skip' } + ) + const regions = { readTitleStatus: () => 'idle' as const } + expect(evaluate([skipped, title], { ...regions, hasOutputClock: true })).toBe('s') + expect(evaluate([skipped, title], { ...regions, hasOutputClock: false })).toBe('title') + }) +}) + +describe('the bundled Codex text anchors', () => { + const header = + '╭───╮\n│ >_ openai codex (v0.157.0) │\n│ model: gpt-5 │\n│ directory: ~/repo │\n╰───╯' + + it('settles on the loaded header, and takes the header as proof a dialog was answered', () => { + expect(isKnownReadyPromptSettled(header)).toBe(true) + expect( + detectTerminalWaitBlockedReason( + 'do you trust this folder?\n1. yes\n>_ openai codex (v0.158.0)' + ) + ).toBeNull() + }) + + it('holds a provisional header: present, but not yet taking input', () => { + const provisional = header.replace('gpt-5', 'loading') + expect(isKnownReadyPromptPreview(provisional)).toBe(true) + expect(isKnownReadyPromptSettled(provisional)).toBe(false) + expect(isKnownReadyPromptSettled(`${provisional}\n› hi\ngpt-5 · ~/repo`)).toBe(true) + }) + + it('holds its own ready text to quiet on a clocked Codex pane, and believes it clockless', () => { + expect(isKnownReadyPromptBody(header, 'codex', () => null, true)).toBe(false) + expect(isKnownReadyPromptBody(header, 'codex', () => null, false)).toBe(true) + expect(isKnownReadyPromptBody(header, 'claude', () => null, true)).toBe(true) + }) + + it("takes no other agent's ready text on a clocked Codex pane, as before the rule files", () => { + const cursorPrompt = '>_ openai codex (v0.158.0)\ncursor agent\n→' + expect(isKnownReadyPromptBody(cursorPrompt, 'codex', () => null, true)).toBe(false) + expect(isKnownReadyPromptBody(cursorPrompt, 'codex', () => null, false)).toBe(true) + expect(isKnownReadyPromptBody(cursorPrompt, 'claude', () => null, true)).toBe(true) + }) + + it('settles an unknown pane on the live-screen Codex header at once, even clocked', () => { + const screen = () => header.split('\n') + expect(isKnownReadyPromptBody('', null, screen, true)).toBe(true) + expect(isKnownReadyPromptBody('', 'claude', screen, true)).toBe(false) + }) + + it('reads the live screen for Codex, not the trusted grid', () => { + expect(readsTrustedScreen('codex')).toBe(false) + expect(readsTrustedScreen('cline')).toBe(true) + }) +}) + +describe('the bundled title anchors', () => { + it.each(['✳ Claude Code', '* Claude Code', '◇ Ready (repo)', 'π - orca', 'OC | orca'])( + 'reads %s as an explicit idle title', + (title) => { + expect(detectExplicitIdleStatusFromTitle(title)).toBe('idle') + } + ) + + it('marks an agent rest title only when an anchor matches it', () => { + expect(showsIdleTitleAnchor('✳ Claude Code')).toBe(true) + expect(showsIdleTitleAnchor('Claude Code')).toBe(false) + }) + + it('leaves a name-only title to the agent idle-title rule', () => { + expect(detectExplicitIdleStatusFromTitle('claude')).toBeNull() + expect(nameOnlyIdleNeedsCorroboration('pi')).toBe(true) + expect(nameOnlyIdleNeedsCorroboration('omp')).toBe(true) + expect(nameOnlyIdleNeedsCorroboration('gemini')).toBe(false) + expect(nameOnlyIdleNeedsCorroboration('opencode')).toBe(false) + }) +}) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts b/src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts new file mode 100644 index 00000000000..029696fc851 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts @@ -0,0 +1,50 @@ +import { AgentStateRulesFileSchema, type AgentStateRulesFile } from './agent-state-rules-schema' +import antigravity from './antigravity.json' +import claude from './claude.json' +import cline from './cline.json' +import codex from './codex.json' +import cursor from './cursor.json' +import gemini from './gemini.json' +import omp from './omp.json' +import opencode from './opencode.json' +import opencode2 from './opencode2.json' +import pi from './pi.json' +import primeAgent from './prime-agent.json' +import unknownPane from './unknown-pane.json' + +/** Validates bundled rule files; a malformed one throws, naming the file and the bad field. */ +export function parseAgentStateRuleFiles(files: readonly unknown[]): AgentStateRulesFile[] { + const parsed = files.map((file, index) => { + const result = AgentStateRulesFileSchema.safeParse(file) + if (!result.success) { + throw new Error(`agent state rules file ${index}: ${result.error.message}`) + } + return result.data + }) + const seen = new Set() + for (const file of parsed) { + if (seen.has(file.id)) { + throw new Error(`agent state rules: two files for ${file.id}`) + } + seen.add(file.id) + } + return parsed +} + +// Why imported, not read from disk: the bundler inlines them, so packaged and headless builds +// carry the rules with no resource path to resolve. +export const BUNDLED_AGENT_STATE_RULE_FILES: readonly AgentStateRulesFile[] = + parseAgentStateRuleFiles([ + antigravity, + claude, + cline, + codex, + cursor, + gemini, + omp, + opencode, + opencode2, + pi, + primeAgent, + unknownPane + ]) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts new file mode 100644 index 00000000000..c15c60cb9d2 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest' +import { evaluateTuiIdle, type TuiIdleEvaluationInput } from '../tui-idle-evidence' +import { isKnownReadyPromptBody, isQuietReadyScreenBody } from '../terminal-wait-detection' +import { + compileAgentRules, + evaluateAgentStateRules, + evaluateCompiledRules, + type AgentStateVerdict +} from './agent-state-rules-engine' +import { parseAgentStateRuleFiles } from './agent-state-rules-catalog' + +const QUIESCENCE_MS = 3000 + +function anchor(when: Record, answer: Record) { + return { id: 'anchor', why: 'test', when, answer } +} + +type RuleFileOverrides = { rules?: unknown[]; anchors?: unknown[] } & Record + +function ruleFile(overrides: RuleFileOverrides = {}): Record { + return { + id: 'cline', + engineVersion: 1, + profile: { screenSource: 'trusted' }, + anchors: [], + rules: [], + ...overrides + } +} + +const SCREEN = { region: 'screen' } + +function idleRule(id: string, priority: number, rows: unknown[]): Record { + return { + id, + why: 'test', + priority, + when: { ...SCREEN, rows }, + answer: { state: 'idle', strength: 'strong', requiresQuiet: true } + } +} + +const HOLD = { id: 'hold', why: 'test', priority: 100, when: SCREEN, answer: { state: 'hold' } } + +function evaluate(rules: unknown[], screen: readonly string[] | null): string | null { + const [file] = parseAgentStateRuleFiles([ruleFile({ rules })]) + return ( + evaluateCompiledRules(compileAgentRules(file), { readScreenLines: () => screen })?.ruleId ?? + null + ) +} + +describe('agent state rules schema', () => { + it.each([ + ['an unknown field', ruleFile({ fallback: 'quiet' })], + ['an unknown agent', ruleFile({ id: 'not-an-agent' })], + ['another engine version', ruleFile({ engineVersion: 2 })], + ['a misspelled rule field', ruleFile({ rules: [{ ...HOLD, requireQuiet: true }] })], + ['a rule with no why', ruleFile({ rules: [{ ...HOLD, why: undefined }] })], + [ + 'row modifiers with no rows', + ruleFile({ rules: [{ ...HOLD, when: { ...SCREEN, endsWithinBottom: 2 } }] }) + ], + ['a pattern that does not compile', ruleFile({ rules: [idleRule('a', 1, [{ regex: '(' }])] })], + [ + 'an optional last row', + ruleFile({ rules: [idleRule('a', 1, [{ optional: { contains: '>' } }])] }) + ], + [ + 'a codex-only blocked reason', + ruleFile({ + anchors: [ + anchor( + { region: 'text', find: { lastOf: 'update' } }, + { state: 'blocked', reason: 'codex-update-prompt' } + ) + ] + }) + ], + [ + 'an unregistered named anchor', + ruleFile({ + anchors: [anchor({ region: 'text', find: { predicate: 'nope' } }, { state: 'idle' })] + }) + ], + [ + 'a blocked anchor the prefilter cannot key on', + ruleFile({ + anchors: [ + anchor( + { region: 'text', find: { predicate: 'antigravity-text-composer' } }, + { state: 'blocked', reason: 'agent-approval-prompt' } + ) + ] + }) + ], + [ + 'an uppercase anchor literal, which the lowercased tail never contains', + ruleFile({ + anchors: [anchor({ region: 'text', find: { lastOf: 'Cursor' } }, { state: 'idle' })] + }) + ], + [ + 'an uppercase anchor contains term', + ruleFile({ + anchors: [ + anchor( + { region: 'text', find: { lastOf: 'x' }, after: { contains: 'Run' } }, + { state: 'idle' } + ) + ] + }) + ] + ])('rejects %s', (_label, file) => { + expect(() => parseAgentStateRuleFiles([file])).toThrow() + }) + + it('rejects two files for one agent', () => { + expect(() => parseAgentStateRuleFiles([ruleFile(), ruleFile()])).toThrow(/two files/) + }) + + const withPattern = (regex: string) => ruleFile({ rules: [idleRule('a', 1, [{ regex }])] }) + + it.each([ + ['a backreference', '(a)\\1'], + ['a lookbehind', '(?<=a)b'], + ['nested quantifiers', '(a+)+$'], + ['a repeated optional', '(a?)*$'], + ['a repeated alternation', '(?:a|aa)+$'], + ['a variable group nested in a repeated one', '(?:x(?:a|b))+'] + ])('rejects %s', (_label, regex) => { + expect(() => parseAgentStateRuleFiles([withPattern(regex)])).toThrow(/pattern/) + }) + + it.each([ + ['a repeated fixed group', '(?: or [a-z])*'], + ['an unrepeated alternation holding a repeat', '\\((?:tab|esc(?: or [a-z])*)\\)$'], + ['an optional alternation', '^(?:yes|no)?$'] + ])('accepts %s', (_label, regex) => { + expect(() => parseAgentStateRuleFiles([withPattern(regex)])).not.toThrow() + }) +}) + +describe('priority evaluation', () => { + const ready = idleRule('ready', 500, [{ regex: '^>$' }]) + + it('answers with the highest-priority match whatever the file order', () => { + expect(evaluate([HOLD, ready], ['>'])).toBe('ready') + expect(evaluate([HOLD, ready], ['busy'])).toBe('hold') + }) + + it('breaks priority ties by file order', () => { + const tiedHold = { ...HOLD, priority: 500 } + expect(evaluate([tiedHold, ready], ['>'])).toBe('hold') + expect(evaluate([ready, tiedHold], ['>'])).toBe('ready') + }) + + it('gives no answer without a readable screen, so the caller decides', () => { + expect(evaluate([ready, HOLD], null)).toBeNull() + }) + + it('gives no answer when nothing matches and there is no hold', () => { + expect(evaluate([ready], ['busy'])).toBeNull() + }) +}) + +describe('screen rows', () => { + const block = (match: Record) => [ + { ...HOLD, id: 'm', when: { ...SCREEN, ...match } } + ] + + it('reads rows above the screen as empty', () => { + const rows = [{ none: [{ contains: '⠋' }] }, { regex: '^>$' }] + expect(evaluate(block({ rows }), ['>'])).toBe('m') + expect(evaluate(block({ rows: [{ regex: '^─+$' }, { regex: '^>$' }] }), ['>'])).toBeNull() + }) + + it('skips an optional row that does not match', () => { + const rows = [{ regex: '^top$' }, { optional: { contains: 'hint' } }, { regex: '^>$' }] + expect(evaluate(block({ rows }), ['top', 'hint', '>'])).toBe('m') + expect(evaluate(block({ rows }), ['top', '>'])).toBe('m') + expect(evaluate(block({ rows }), ['other', '>'])).toBeNull() + }) + + it('ends the block at the lowest match within endsWithinBottom', () => { + const rows = [{ regex: '^─+$' }, { regex: '^mode$' }] + expect(evaluate(block({ rows, endsWithinBottom: 2 }), ['───', 'mode', 'cwd'])).toBe('m') + expect(evaluate(block({ rows, endsWithinBottom: 1 }), ['───', 'mode', 'cwd'])).toBeNull() + }) + + it('vetoes a block when a row above it matches noneAbove', () => { + const match = { rows: [{ regex: '^>$' }], noneAbove: { contains: '⠋' } } + expect(evaluate(block(match), ['⠋ thinking', '>'])).toBeNull() + expect(evaluate(block(match), ['done', '>'])).toBe('m') + }) + + it('combines all, any and none on one row', () => { + const rows = [{ all: [{ regex: '\\)$' }], any: [{ contains: 'yes' }, { contains: 'no' }] }] + expect(evaluate(block({ rows }), ['yes (y)'])).toBe('m') + expect(evaluate(block({ rows }), ['maybe (m)'])).toBeNull() + }) +}) + +describe('strength and quiet through the tui-idle ranking', () => { + const now = Date.now() + + function verdictFor(ruled: AgentStateVerdict, lastOutputAt: number | null): string { + const input: TuiIdleEvaluationInput = { + record: { lastAgentStatus: null, lastOutputAt, lastOscTitle: null }, + readTailBlockedReason: () => null, + readPositiveBodyEvidence: () => false, + readQuietReadyBodyEvidence: () => false, + readAgentRuleVerdict: () => ruled, + agent: 'cline', + firstPartyStatus: null, + quiescenceMs: QUIESCENCE_MS + } + const verdict = evaluateTuiIdle(input) + return verdict.kind === 'pending' ? `pending:${verdict.quietForeground}` : verdict.kind + } + + const weak = (requiresQuiet: boolean): AgentStateVerdict => ({ + ruleId: 'w', + state: 'idle', + strength: 'weak', + requiresQuiet + }) + + it('settles weak idle only on the weak lane, and only once quiet when it asks to', () => { + expect(verdictFor(weak(false), now)).toBe('ready-weak') + expect(verdictFor(weak(true), now)).toBe('pending:closed') + expect(verdictFor(weak(true), now - QUIESCENCE_MS)).toBe('ready-weak') + expect(verdictFor(weak(true), null)).toBe('pending:closed') + }) + + it('holds every weak lane on a hold', () => { + expect(verdictFor({ ruleId: 'h', state: 'hold' }, now - QUIESCENCE_MS)).toBe('pending:closed') + }) +}) + +describe('a bundled strong, quiet idle rule', () => { + // Antigravity's idle composer (agent-state-rules/antigravity.json). + const readyScreen = ['─'.repeat(20), '>', '─'.repeat(20), '? for shortcuts'] + + it('is believed at once on a pane with no output clock', () => { + expect(isKnownReadyPromptBody('', 'antigravity', () => readyScreen, false)).toBe(true) + }) + + it('waits for quiet on a clocked pane', () => { + expect(isKnownReadyPromptBody('', 'antigravity', () => readyScreen, true)).toBe(false) + expect(isQuietReadyScreenBody('', 'antigravity', () => readyScreen)).toBe(true) + }) + + it('holds when the screen shows something else', () => { + const picker = [...readyScreen.slice(0, 3), 'esc to cancel'] + expect(evaluateAgentStateRules('antigravity', { readScreenLines: () => picker })?.state).toBe( + 'hold' + ) + }) +}) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts new file mode 100644 index 00000000000..795d68c019e --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts @@ -0,0 +1,199 @@ +import type { AgentStatus } from '../../../shared/agent-detection' +import type { TuiAgent } from '../../../shared/tui-agent' +import { compileScreenCondition } from './agent-state-rule-matchers' +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { + UNKNOWN_PANE_RULES_ID, + type AgentStateRuleAnswer, + type AgentStateRuleCondition, + type AgentStateRulesFile, + type NamedScreenPredicate +} from './agent-state-rules-schema' +import { compileTextAnchor } from './agent-state-text-anchors' +import { isSettledAfter } from './blocked-text-layer' +import { isCodexComposerReadyScreen, isCodexHeaderReadyScreen } from './codex-screen-predicates' + +/** What the first matching rule answered. Callers rank it among the other readiness lanes. */ +export type AgentStateVerdict = { ruleId: string } & AgentStateRuleAnswer + +/** + * The regions a lane lets the rules read. A region that is absent, or null because no trustworthy + * copy exists, skips the rules that read it: the lane reads only the evidence it ranks. + */ +export type AgentStateRegions = { + readScreenLines?: () => readonly string[] | null + /** The lowercased text tail. */ + readText?: () => string + /** The status the shared title classifier gave the pane title. */ + readTitleStatus?: () => AgentStatus | null + /** False on a pane with no output clock (restored or adopted); absent reads as clocked. */ + hasOutputClock?: boolean +} + +type Region = AgentStateRuleCondition['region'] + +type RegionReader = () => T | null + +type RegionReads = { + screen: RegionReader + /** The screen joined and lowercased, for named screen predicates. */ + screenText: RegionReader + text: RegionReader + title: RegionReader +} + +type CompiledRule = { + verdict: AgentStateVerdict + region: Region + skipWithoutClock: boolean + /** False when its region is unreadable, which skips the rule. */ + matches: (reads: RegionReads) => boolean +} + +const NAMED_SCREEN_PREDICATES: Record boolean> = { + 'codex-header-ready': isCodexHeaderReadyScreen, + 'codex-composer-ready': isCodexComposerReadyScreen +} + +function readThen(read: RegionReader, test: (value: T) => boolean): boolean { + const value = read() + return value !== null && test(value) +} + +function compileCondition( + when: AgentStateRuleCondition, + file: AgentStateRulesFile +): CompiledRule['matches'] { + switch (when.region) { + case 'screen': { + if (when.predicate) { + const predicate = NAMED_SCREEN_PREDICATES[when.predicate] + return (reads) => readThen(reads.screenText, predicate) + } + const matches = compileScreenCondition(when) + return (reads) => readThen(reads.screen, matches) + } + case 'title': + return (reads) => readThen(reads.title, (status) => status === when.status) + case 'text': { + const anchor = file.anchors.find((candidate) => candidate.id === when.anchor) + // Why unreachable: the schema requires the anchor to be one of this file's text anchors. + if (anchor?.when.region !== 'text') { + throw new Error(`agent state rules ${file.id}: no text anchor ${when.anchor}`) + } + const find = compileTextAnchor(anchor.when, anchor.answer) + return (reads) => + readThen(reads.text, (text) => isSettledAfter(text, find(text)?.index ?? null)) + } + } +} + +export function compileAgentRules(file: AgentStateRulesFile): CompiledRule[] { + // Why stable: equal priorities keep file order. + return file.rules + .toSorted((left, right) => right.priority - left.priority) + .map((rule) => ({ + verdict: { ruleId: rule.id, ...rule.answer }, + region: rule.when.region, + skipWithoutClock: rule.answer.state === 'idle' && rule.answer.withoutClock === 'skip', + matches: compileCondition(rule.when, file) + })) +} + +type RulesKey = TuiAgent | typeof UNKNOWN_PANE_RULES_ID + +type CompiledFile = { rules: CompiledRule[]; readsTrustedScreen: boolean } + +const FILES_BY_KEY: ReadonlyMap = new Map( + BUNDLED_AGENT_STATE_RULE_FILES.map((file) => [ + file.id, + { + rules: compileAgentRules(file), + readsTrustedScreen: file.profile?.screenSource === 'trusted' + } + ]) +) + +// Why the unknown-pane file for a null agent: an adopted pane can still run a known agent. +function compiledFileFor(agent: TuiAgent | null | undefined): CompiledFile | undefined { + return FILES_BY_KEY.get(agent ?? UNKNOWN_PANE_RULES_ID) +} + +/** + * Whether the agent's rules read the PTY's trusted grid. Why it changes which screen is read, and + * how a clockless wait probes: those rules were recorded against that grid, while every other + * agent keeps the live screen. + */ +export function readsTrustedScreen(agent: TuiAgent | null | undefined): boolean { + return compiledFileFor(agent)?.readsTrustedScreen ?? false +} + +function someRule( + agent: TuiAgent | null | undefined, + test: (rule: CompiledRule) => boolean +): boolean { + return compiledFileFor(agent)?.rules.some(test) ?? false +} + +function isStrongQuietIdle(verdict: AgentStateVerdict): boolean { + return verdict.state === 'idle' && verdict.strength === 'strong' && verdict.requiresQuiet +} + +/** Whether the agent has a ready sign it also paints mid-turn, which the quiet lane must read. */ +export function hasQuietReadyRules(agent: TuiAgent | null | undefined): boolean { + return someRule(agent, (rule) => isStrongQuietIdle(rule.verdict)) +} + +/** + * Whether the agent's own ready text is held to quiet. Why it shuts the shared text lane on a + * clocked pane: that lane settles any rule file's ready text at once, which would skip the quiet. + */ +export function holdsReadyTextToQuiet(agent: TuiAgent | null | undefined): boolean { + return someRule(agent, (rule) => rule.region === 'text' && isStrongQuietIdle(rule.verdict)) +} + +/** Whether the agent's own idle-title rule waits for quiet; null when it has none. */ +export function idleTitleRequiresQuiet(agent: TuiAgent | null | undefined): boolean | null { + const verdict = compiledFileFor(agent)?.rules.find((rule) => rule.region === 'title')?.verdict + return verdict?.state === 'idle' ? verdict.requiresQuiet : null +} + +function memoize(read: (() => T | null) | undefined): RegionReader { + if (!read) { + return () => null + } + let value: T | null | undefined + return () => (value === undefined ? (value = read()) : value) +} + +export function evaluateCompiledRules( + rules: readonly CompiledRule[], + regions: AgentStateRegions +): AgentStateVerdict | null { + const screen = memoize(regions.readScreenLines) + const reads: RegionReads = { + screen, + screenText: memoize(() => screen()?.join('\n').toLowerCase() ?? null), + text: memoize(regions.readText), + title: memoize(regions.readTitleStatus) + } + // Why no answer rather than a refusal: with no readable region the caller's other lanes decide. + for (const rule of rules) { + if (rule.skipWithoutClock && regions.hasOutputClock === false) { + continue + } + if (rule.matches(reads)) { + return rule.verdict + } + } + return null +} + +/** The agent's priority list over its regions: the first match answers; none leaves it to the caller. */ +export function evaluateAgentStateRules( + agent: TuiAgent | null | undefined, + regions: AgentStateRegions +): AgentStateVerdict | null { + const file = compiledFileFor(agent) + return file ? evaluateCompiledRules(file.rules, regions) : null +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts new file mode 100644 index 00000000000..de2dbae4cc5 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts @@ -0,0 +1,282 @@ +import { z } from 'zod' +import type { RuntimeTerminalWaitBlockedReason } from '../../../shared/runtime-types' +import type { TuiAgent } from '../../../shared/tui-agent' +import { isTuiAgent } from '../../../shared/tui-agent-config' +import { findUnsafePatternReason } from './agent-state-rule-pattern-safety' + +/** + * One file per agent (`.json` beside this schema), plus `unknown-pane.json` for panes whose + * agent Orca does not know. Every object is strict, so a misspelled field rejects the file instead + * of silently dropping a condition. Every rule and anchor is `when` (a region and what it must + * show) plus `answer`. Once a version ships, adding a region, predicate or answer bumps + * `engineVersion`; until then version 1 is still being defined. + */ +const AGENT_STATE_RULES_ENGINE_VERSION = 1 + +const MAX_PATTERN_LENGTH = 200 +const MAX_RULES = 32 +const MAX_ROWS = 12 +const MAX_TERMS = 8 + +const Literal = z.string().min(1).max(MAX_PATTERN_LENGTH) + +// Why: these are matched against the lowercased text tail, so an uppercase letter never matches. +const TailLiteral = Literal.refine( + (text) => text === text.toLowerCase(), + 'must be lowercase: the text tail is lowercased' +) + +const SafeRegex = Literal.superRefine((pattern, ctx) => { + const reason = findUnsafePatternReason(pattern) + if (reason) { + ctx.addIssue({ code: 'custom', message: `pattern ${reason}` }) + } +}) + +/** A test on one row or one text segment: a single term, or every `all`, some `any`, no `none`. */ +function textTestSchema(containsLiteral: typeof Literal) { + const term = z.union([ + z.object({ regex: SafeRegex, ignoreCase: z.boolean().optional() }).strict(), + z.object({ contains: containsLiteral }).strict() + ]) + const terms = z.array(term).min(1).max(MAX_TERMS) + return z.union([ + term, + z + .object({ all: terms.optional(), any: terms.optional(), none: terms.optional() }) + .strict() + .refine((test) => Boolean(test.all ?? test.any ?? test.none), 'needs all, any or none') + ]) +} + +const TextTestSchema = textTestSchema(Literal) +const TailTextTestSchema = textTestSchema(TailLiteral) + +const RowSchema = z.union([TextTestSchema, z.object({ optional: TextTestSchema }).strict()]) + +/** The evidence behind a rule, since JSON carries no comments; it ships beside the pattern it explains. */ +const Why = z.string().min(1).max(600) + +const NAMED_SCREEN_PREDICATES = ['codex-header-ready', 'codex-composer-ready'] as const + +/** + * The screen `profile.screenSource` names. `rows` are consecutive trimmed rows, top-down; the + * block ends at the bottom-most row, among the last `endsWithinBottom`, that passes the final + * test, and a row above the screen reads as empty. Or `predicate`: a named engine scan over the + * lowercased screen, for a shape rows cannot state. With neither, it holds whenever readable. + */ +const ScreenConditionSchema = z + .object({ + region: z.literal('screen'), + rows: z.array(RowSchema).min(1).max(MAX_ROWS).optional(), + endsWithinBottom: z.number().int().min(1).max(MAX_ROWS).optional(), + noneAbove: TextTestSchema.optional(), + predicate: z.enum(NAMED_SCREEN_PREDICATES).optional() + }) + .strict() + .refine( + (screen) => screen.rows || (!screen.endsWithinBottom && !screen.noneAbove), + 'endsWithinBottom and noneAbove need rows' + ) + .refine( + (screen) => !('optional' in (screen.rows?.at(-1) ?? {})), + 'the last row cannot be optional' + ) + .refine((screen) => !(screen.rows && screen.predicate), 'rows or a predicate, not both') + +/** The pane title's status, as the shared title classifier read it (`lastAgentStatus`). */ +const TitleConditionSchema = z + .object({ region: z.literal('title'), status: z.enum(['idle']) }) + .strict() + +/** + * The text tail: this file's `anchor` (an idle text anchor) is found and settled, meaning no + * blocker was painted after it and no hold anchor shows anywhere. + */ +const TextConditionSchema = z.object({ region: z.literal('text'), anchor: Literal }).strict() + +const RuleConditionSchema = z.discriminatedUnion('region', [ + ScreenConditionSchema, + TitleConditionSchema, + TextConditionSchema +]) + +const RuleAnswerSchema = z.discriminatedUnion('state', [ + z + .object({ + state: z.literal('idle'), + /** Strong settles a wait at once; weak only on the poll, once nothing stronger spoke. */ + strength: z.enum(['strong', 'weak']), + /** Believed only after the output clock has been quiet (agents paint this mid-turn too). */ + requiresQuiet: z.boolean(), + /** On a pane with no output clock (restored or adopted), quiet cannot be measured: a strong + * quiet rule is believed at once there, unless it says `skip` (then it does not apply). */ + withoutClock: z.literal('skip').optional() + }) + .strict() + .refine( + (answer) => !answer.withoutClock || (answer.requiresQuiet && answer.strength === 'strong'), + 'withoutClock is for strong rules that require quiet' + ), + // Why hold: the agent's own evidence was readable and said "not ready", which must also shut + // the weak lanes (a name-only title or quiet process cannot see what the screen refused). + z.object({ state: z.literal('hold') }).strict() +]) + +/** One entry in the agent's priority list: highest `priority` first, ties in file order. */ +const AgentStateRuleSchema = z + .object({ + id: Literal, + why: Why, + priority: z.number().int().min(0).max(1000), + when: RuleConditionSchema, + answer: RuleAnswerSchema + }) + .strict() + +const NAMED_TEXT_ANCHORS = ['antigravity-text-composer'] as const +const NAMED_TITLE_PREDICATES = ['opencode-native-title'] as const + +const AGENT_BLOCKED_REASONS = [ + 'agent-update-prompt', + 'agent-trust-workspace', + 'agent-cwd-prompt', + 'agent-hooks-review-prompt', + 'agent-interactive-prompt', + 'agent-approval-prompt' +] as const satisfies readonly RuntimeTerminalWaitBlockedReason[] + +/** + * A position in the lowercased text tail. Text anchors read every pane whatever agent it runs (a + * tail can show another agent's dialog, and an adopted pane has no known agent), and the latest one + * in the text wins. A blocked anchor reads the blocked layer's live window. An idle or live one is + * a live prompt, which cancels an earlier blocker; only an idle one settles a wait. A hold anchor, + * found anywhere, stops every text anchor from settling one (the agent is up, not ready). + */ +const TextAnchorConditionSchema = z + .object({ + region: z.literal('text'), + /** Where the anchor starts: a literal's last occurrence, or a named engine scan. */ + find: z.union([ + z.object({ lastOf: TailLiteral }).strict(), + z.object({ predicate: z.enum(NAMED_TEXT_ANCHORS) }).strict() + ]), + /** Reads only the last N lines of its input. */ + withinLastLines: z.number().int().min(1).max(64).optional(), + /** The text from the anchor to the end must pass this. */ + after: TailTextTestSchema.optional(), + /** Over the lines read, trailing blanks dropped: at least `atLeast` pass, the last one too + * when `includingLast`. */ + lines: z + .object({ + atLeast: z.number().int().min(1).max(MAX_ROWS), + includingLast: z.boolean(), + test: TailTextTestSchema + }) + .strict() + .optional() + }) + .strict() + +/** + * A title the shared classifier already calls idle, marked as an agent's own rest title. Like a + * text anchor it is read whatever agent the pane runs: an adopted pane has no known agent, and a + * pane can run another agent than it launched. `match` is a test, or a named engine predicate + * shared with other title readers. + */ +const TitleAnchorConditionSchema = z + .object({ + region: z.literal('title'), + match: z.union([ + TextTestSchema, + z.object({ predicate: z.enum(NAMED_TITLE_PREDICATES) }).strict() + ]) + }) + .strict() + +const AnchorSchema = z + .object({ + id: Literal, + why: Why, + when: z.discriminatedUnion('region', [TextAnchorConditionSchema, TitleAnchorConditionSchema]), + answer: z.discriminatedUnion('state', [ + z.object({ state: z.literal('blocked'), reason: z.enum(AGENT_BLOCKED_REASONS) }).strict(), + z.object({ state: z.literal('idle') }).strict(), + z.object({ state: z.literal('live') }).strict(), + z.object({ state: z.literal('hold') }).strict() + ]) + }) + .strict() + .refine( + (anchor) => + anchor.answer.state !== 'blocked' || + (anchor.when.region === 'text' && 'lastOf' in anchor.when.find), + "a blocked anchor needs find.lastOf: the blocked layer's prefilter keys on it" + ) + .refine( + (anchor) => anchor.when.region !== 'title' || anchor.answer.state === 'idle', + 'a title anchor answers idle' + ) + +/** Facts about the agent that are not detection rules. */ +const ProfileSchema = z + .object({ + /** Text whose presence in a pane's tail makes a tui-idle wait read its visible screen once. */ + screenProbeBanner: TailLiteral.optional(), + /** The screen the rules read: the PTY's own grid, trusted only while the PTY still has that + * size, or the live emulator's. Rules keep the source they were recorded against. */ + screenSource: z.enum(['trusted', 'live']).optional() + }) + .strict() + +/** The panes no agent file covers: no launch record, and no recognised foreground process. */ +export const UNKNOWN_PANE_RULES_ID = 'unknown-pane' + +function hasUniqueIds(entries: readonly { id: string }[]): boolean { + return new Set(entries.map((entry) => entry.id)).size === entries.length +} + +export const AgentStateRulesFileSchema = z + .object({ + id: z.union([ + z.literal(UNKNOWN_PANE_RULES_ID), + z.custom(isTuiAgent, 'not a known agent') + ]), + engineVersion: z.literal(AGENT_STATE_RULES_ENGINE_VERSION), + profile: ProfileSchema.optional(), + anchors: z.array(AnchorSchema).max(MAX_RULES), + rules: z.array(AgentStateRuleSchema).max(MAX_RULES) + }) + .strict() + .refine( + (file) => + !file.rules.some((rule) => rule.when.region === 'screen') || + file.profile?.screenSource !== undefined, + 'a file with screen rules names its profile.screenSource' + ) + .refine((file) => { + const idleTextAnchors = new Set( + file.anchors + .filter((anchor) => anchor.when.region === 'text' && anchor.answer.state === 'idle') + .map((anchor) => anchor.id) + ) + return file.rules.every( + (rule) => rule.when.region !== 'text' || idleTextAnchors.has(rule.when.anchor) + ) + }, "a text rule names one of this file's idle text anchors") + .refine( + (file) => hasUniqueIds(file.anchors) && hasUniqueIds(file.rules), + 'anchor ids, and rule ids, are unique within the file' + ) + +export type TextTest = z.infer +export type ScreenCondition = z.infer +export type AgentStateRuleCondition = z.infer +export type AgentStateRuleAnswer = z.infer +export type Anchor = z.infer +export type TextAnchorCondition = z.infer +export type TitleAnchorCondition = z.infer +export type NamedTextAnchor = (typeof NAMED_TEXT_ANCHORS)[number] +export type NamedScreenPredicate = (typeof NAMED_SCREEN_PREDICATES)[number] +export type NamedTitlePredicate = (typeof NAMED_TITLE_PREDICATES)[number] +export type AgentStateRulesFile = z.infer diff --git a/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts b/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts new file mode 100644 index 00000000000..97fda216dc6 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { parseAgentStateRuleFiles } from './agent-state-rules-catalog' +import { compileTextAnchors, findPromptAnchorIndexes } from './agent-state-text-anchors' +import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './blocked-text-layer' +import { detectTerminalWaitBlockedReason } from '../terminal-wait-detection' + +function anchorsOf(anchors: unknown[]) { + return compileTextAnchors( + parseAgentStateRuleFiles([{ id: 'cursor', engineVersion: 1, anchors, rules: [] }]) + ) +} + +describe('blocked anchors', () => { + const [menu] = anchorsOf([ + { + id: 'menu', + why: 'test', + when: { + region: 'text', + find: { lastOf: 'run it?' }, + withinLastLines: 4, + lines: { atLeast: 2, includingLast: true, test: { regex: '\\([a-z]\\)$' } } + }, + answer: { state: 'blocked', reason: 'agent-approval-prompt' } + } + ]).blocked + + it('reports where the anchor starts once enough choices own the bottom', () => { + const text = 'chat\nrun it?\nyes (y)\nno (n)\n' + expect(menu(text)).toEqual({ + answer: { state: 'blocked', reason: 'agent-approval-prompt' }, + index: text.indexOf('run it?') + }) + }) + + it('refuses a menu with too few choices, or one no longer at the bottom', () => { + expect(menu('run it?\nyes (y)\n')).toBeNull() + expect(menu('run it?\nyes (y)\nno (n)\nlater output')).toBeNull() + }) + + it('reads only the last lines', () => { + expect(menu('run it?\na\nb\nyes (y)\nno (n)')).toBeNull() + }) +}) + +describe('prompt anchors', () => { + const [prompt] = anchorsOf([ + { + id: 'prompt', + why: 'test', + when: { region: 'text', find: { lastOf: 'banner' }, after: { contains: '→' } }, + answer: { state: 'idle' } + } + ]).prompts + + it('needs the after test to pass on the text after the last banner', () => { + expect(prompt('banner\n→')).toEqual({ answer: { state: 'idle' }, index: 0 }) + expect(prompt('→ banner')).toBeNull() + }) + + it('reads a bundled busy prompt as live but not ready', () => { + expect(findPromptAnchorIndexes('cursor agent\n⠋ generating\n→')).toEqual({ + live: 0, + ready: null + }) + expect(findPromptAnchorIndexes('cursor agent\n→')).toEqual({ live: 0, ready: 0 }) + expect(findPromptAnchorIndexes('cursor agent\n⠋ starting')).toEqual({ live: null, ready: null }) + }) +}) + +describe('the bundled Cursor approval menu', () => { + it('blocks once two choices own the bottom of the tail', () => { + const menu = + 'cursor agent\n→ fix it\nrun this command?\n→ run (once) (y)\n skip & tell the agent (esc or n)' + expect(detectTerminalWaitBlockedReason(menu)).toBe('agent-approval-prompt') + expect(detectTerminalWaitBlockedReason(menu.split('\n').slice(0, -1).join('\n'))).toBeNull() + }) +}) + +describe('the blocked layer prefilter', () => { + it('includes every bundled blocked anchor', () => { + expect(TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test('Run this command?')).toBe(true) + }) +}) diff --git a/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts b/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts new file mode 100644 index 00000000000..5de562de05a --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts @@ -0,0 +1,143 @@ +import type { RuntimeTerminalWaitBlockedReason } from '../../../shared/runtime-types' +import { startOfLastLines } from '../terminal-wait-tail-window' +import { compileTextTest, type TextMatcher } from './agent-state-rule-matchers' +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import type { + Anchor, + AgentStateRulesFile, + NamedTextAnchor, + TextAnchorCondition +} from './agent-state-rules-schema' +import { findAntigravityComposerIndex } from './antigravity-text-composer' + +export type BlockedTextSignal = { reason: RuntimeTerminalWaitBlockedReason; index: number } + +type TextAnchorHit = { answer: Anchor['answer']; index: number } + +export type TextAnchorFinder = (normalized: string) => TextAnchorHit | null + +const NAMED_TEXT_ANCHOR_FINDERS: Record number | null> = { + 'antigravity-text-composer': findAntigravityComposerIndex +} + +function compileFind(find: TextAnchorCondition['find']): (text: string) => number | null { + if ('predicate' in find) { + return NAMED_TEXT_ANCHOR_FINDERS[find.predicate] + } + return (text) => { + const index = text.lastIndexOf(find.lastOf) + return index === -1 ? null : index + } +} + +function compileLineCount(lines: NonNullable): TextMatcher { + const test = compileTextTest(lines.test) + return (text) => { + const rows = text.split('\n') + while (rows.length > 0 && rows.at(-1)?.trim() === '') { + rows.pop() + } + return ( + rows.filter(test).length >= lines.atLeast && (!lines.includingLast || test(rows.at(-1) ?? '')) + ) + } +} + +export function compileTextAnchor( + when: TextAnchorCondition, + answer: Anchor['answer'] +): TextAnchorFinder { + const { withinLastLines } = when + const find = compileFind(when.find) + const after = when.after ? compileTextTest(when.after) : null + const lines = when.lines ? compileLineCount(when.lines) : null + return (text) => { + const start = withinLastLines ? startOfLastLines(text, withinLastLines) : 0 + const region = text.slice(start) + const index = find(region) + if (index === null || (after && !after(region.slice(index))) || (lines && !lines(region))) { + return null + } + return { answer, index: start + index } + } +} + +type TextAnchor = { when: TextAnchorCondition; answer: Anchor['answer'] } + +function textAnchorsOf(files: readonly AgentStateRulesFile[]): TextAnchor[] { + return files.flatMap((file) => + file.anchors.flatMap(({ when, answer }) => (when.region === 'text' ? [{ when, answer }] : [])) + ) +} + +function compileEach(anchors: readonly TextAnchor[]): TextAnchorFinder[] { + return anchors.map(({ when, answer }) => compileTextAnchor(when, answer)) +} + +export function compileTextAnchors(files: readonly AgentStateRulesFile[]): { + blocked: TextAnchorFinder[] + prompts: TextAnchorFinder[] + holds: TextAnchorFinder[] + blockedLiterals: string[] + screenProbeBanners: string[] +} { + const anchors = textAnchorsOf(files) + const blocked = anchors.filter((anchor) => anchor.answer.state === 'blocked') + return { + blocked: compileEach(blocked), + prompts: compileEach( + anchors.filter(({ answer }) => answer.state !== 'blocked' && answer.state !== 'hold') + ), + holds: compileEach(anchors.filter(({ answer }) => answer.state === 'hold')), + blockedLiterals: blocked.flatMap(({ when }) => + 'lastOf' in when.find ? [when.find.lastOf] : [] + ), + screenProbeBanners: files.flatMap((file) => file.profile?.screenProbeBanner ?? []) + } +} + +const TEXT_ANCHORS = compileTextAnchors(BUNDLED_AGENT_STATE_RULE_FILES) + +/** The literal every blocked anchor needs, for the blocked layer's one-pass prefilter. */ +export const BLOCKED_ANCHOR_LITERALS: readonly string[] = TEXT_ANCHORS.blockedLiterals + +/** Every rule file's blocked anchor found in the blocked layer's live window. */ +export function findBlockedAnchorSignals(window: string): BlockedTextSignal[] { + return TEXT_ANCHORS.blocked.flatMap((find) => { + const hit = find(window) + return hit?.answer.state === 'blocked' ? [{ reason: hit.answer.reason, index: hit.index }] : [] + }) +} + +/** + * The latest live prompt (`live`: an idle or live anchor, which proves an earlier startup + * dialog was answered) and the latest idle one (`ready`) that any rule file's anchors find. + */ +export function findPromptAnchorIndexes(normalized: string): { + live: number | null + ready: number | null +} { + let live: number | null = null + let ready: number | null = null + for (const find of TEXT_ANCHORS.prompts) { + const hit = find(normalized) + if (hit === null) { + continue + } + live = Math.max(live ?? -1, hit.index) + if (hit.answer.state === 'idle') { + ready = Math.max(ready ?? -1, hit.index) + } + } + return { live, ready } +} + +/** Whether any rule file's hold anchor shows: an agent is up but not yet taking input. */ +export function showsHoldAnchor(normalized: string): boolean { + return TEXT_ANCHORS.holds.some((find) => find(normalized) !== null) +} + +export function showsScreenProbeBanner(text: string): boolean { + const normalized = text.toLowerCase() + return TEXT_ANCHORS.screenProbeBanners.some((banner) => normalized.includes(banner)) +} diff --git a/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts b/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts new file mode 100644 index 00000000000..67fc6063408 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts @@ -0,0 +1,34 @@ +import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' +import { compileTextTest } from './agent-state-rule-matchers' +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import type { + AgentStateRulesFile, + NamedTitlePredicate, + TitleAnchorCondition +} from './agent-state-rules-schema' + +// Why shared code: the same marker also proves OpenCode presence, so both must read it alike. +const NAMED_TITLE_PREDICATES: Record boolean> = { + 'opencode-native-title': isOpenCodeNativeTitle +} + +type TitleAnchorMatcher = (title: string) => boolean + +function compileTitleAnchor(when: TitleAnchorCondition): TitleAnchorMatcher { + return 'predicate' in when.match + ? NAMED_TITLE_PREDICATES[when.match.predicate] + : compileTextTest(when.match) +} + +function compileTitleAnchors(files: readonly AgentStateRulesFile[]): TitleAnchorMatcher[] { + return files.flatMap((file) => + file.anchors.flatMap(({ when }) => (when.region === 'title' ? [compileTitleAnchor(when)] : [])) + ) +} + +const TITLE_ANCHORS = compileTitleAnchors(BUNDLED_AGENT_STATE_RULE_FILES) + +/** Whether any rule file's title anchor marks an idle-classified `title` as an agent's own rest title. */ +export function showsIdleTitleAnchor(title: string): boolean { + return TITLE_ANCHORS.some((matches) => matches(title)) +} diff --git a/src/main/runtime/antigravity-terminal-readiness.ts b/src/main/runtime/agent-state-rules/antigravity-text-composer.ts similarity index 71% rename from src/main/runtime/antigravity-terminal-readiness.ts rename to src/main/runtime/agent-state-rules/antigravity-text-composer.ts index 81e42109f45..894218f24a9 100644 --- a/src/main/runtime/antigravity-terminal-readiness.ts +++ b/src/main/runtime/agent-state-rules/antigravity-text-composer.ts @@ -1,34 +1,4 @@ -import { isTerminalWaitWhitespace } from './terminal-wait-tail-window' - -/** - * Antigravity paints its chrome with cursor addressing, so model/account rows are not stable - * line anchors. The idle composer is the only captured marker that survives every ready screen. - */ -export function findAntigravityReadyPromptIndex(normalized: string): number | null { - return findAntigravityComposerIndex(normalized) -} - -const SCREEN_RULE_RE = /^─{8,}$/ - -/** - * The live screen's bottom four rows at an idle composer: rule, caret, rule, `? for shortcuts`. - * Why the hint row decides: the caret stays painted through a turn and behind the `/model` - * picker, but the hint reads `esc to cancel` mid-turn and in the palette, and the picker covers it. - */ -export function isAntigravityComposerReadyScreen(screenLines: readonly string[]): boolean { - if (screenLines.length < 4) { - return false - } - const [top = '', composer = '', bottom = '', hint = ''] = screenLines - .slice(-4) - .map((line) => line.trim()) - return ( - SCREEN_RULE_RE.test(top) && - isComposerLine(composer) && - SCREEN_RULE_RE.test(bottom) && - hint.toLowerCase().startsWith('? for shortcuts') - ) -} +import { isTerminalWaitWhitespace } from '../terminal-wait-tail-window' /** * The composer is a bare `>` on the captured 3.7 Flash screens, but agy 1.2.7 paints the active @@ -67,7 +37,12 @@ function isModelRow(line: string): boolean { return true } -function findAntigravityComposerIndex(normalized: string): number | null { +/** + * The named text anchor `antigravity-text-composer`: the idle composer in the folded text tail. + * Why code, not rows: Antigravity paints its chrome with cursor addressing, so model/account rows + * are not stable line anchors, and telling them apart takes this whole-tail scan. + */ +export function findAntigravityComposerIndex(normalized: string): number | null { const contentStart = normalized.lastIndexOf('antigravity cli') if (contentStart === -1) { return null @@ -127,7 +102,3 @@ function findAntigravityComposerIndex(normalized: string): number | null { } return modelAfterComposer && !workspaceAfterComposer ? null : composerStart } - -export function hasAntigravityTerminalHeader(text: string): boolean { - return text.toLowerCase().includes('antigravity cli') -} diff --git a/src/main/runtime/agent-state-rules/antigravity.json b/src/main/runtime/agent-state-rules/antigravity.json new file mode 100644 index 00000000000..8d178f2bfcc --- /dev/null +++ b/src/main/runtime/agent-state-rules/antigravity.json @@ -0,0 +1,37 @@ +{ + "id": "antigravity", + "engineVersion": 1, + "profile": { "screenProbeBanner": "antigravity cli", "screenSource": "trusted" }, + "anchors": [ + { + "id": "text_composer", + "why": "Without a trusted grid, the folded text tail is all there is; a trailing caret there also belongs to trust, sign-in and model menus, which the named scan rules out.", + "when": { "region": "text", "find": { "predicate": "antigravity-text-composer" } }, + "answer": { "state": "idle" } + } + ], + "rules": [ + { + "id": "composer_ready", + "why": "The bottom four rows at an idle composer. The hint row decides: the caret stays painted through a turn and behind the /model picker, but the hint reads `esc to cancel` mid-turn and in the palette, and the picker covers it. The caret row may carry the edit mode (`> Accept-edits mode: ...`), but never other text: menus prefix their highlighted row with `> ` too. Quiet because a submit repaints the composer for a moment mid-turn.", + "priority": 500, + "when": { + "region": "screen", + "rows": [ + { "regex": "^─{8,}$" }, + { "regex": "^>$|^>\\s+[a-z][a-z-]*\\s+mode:\\s", "ignoreCase": true }, + { "regex": "^─{8,}$" }, + { "regex": "^\\? for shortcuts", "ignoreCase": true } + ] + }, + "answer": { "state": "idle", "strength": "strong", "requiresQuiet": true } + }, + { + "id": "composer_not_shown", + "why": "A readable screen that is not the idle composer may be a picker, dialog or busy turn, which a name-only title or a quiet process cannot see.", + "priority": 100, + "when": { "region": "screen" }, + "answer": { "state": "hold" } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/blocked-text-layer.ts b/src/main/runtime/agent-state-rules/blocked-text-layer.ts new file mode 100644 index 00000000000..9e8d486bb35 --- /dev/null +++ b/src/main/runtime/agent-state-rules/blocked-text-layer.ts @@ -0,0 +1,130 @@ +import { escapeRegex } from '../../../shared/string-utils' +import { findStartupDialogBlockedSignals } from '../startup-dialog-blocked-signals' +import { startOfLastNonBlankLines } from '../terminal-wait-tail-window' +import { + BLOCKED_ANCHOR_LITERALS, + findBlockedAnchorSignals, + showsHoldAnchor, + type BlockedTextSignal +} from './agent-state-text-anchors' + +/** + * The shared blocked layer, read before any agent's own rules. Why shared and positional: a tail + * can show any agent's dialog whatever the pane runs, and an answered dialog stays in it, so the + * blocker painted latest wins. + */ + +const BUILT_IN_SENTINEL_RE = + /update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always/ + +/** Matches any line that may carry a blocker; a cheap negative test before the full scan. */ +export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = new RegExp( + [BUILT_IN_SENTINEL_RE.source, ...BLOCKED_ANCHOR_LITERALS.map(escapeRegex)].join('|'), + 'i' +) + +// Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the +// retained tail; only a dialog owning the screen bottom is live. Real Codex dialogs (trust, hooks review, +// update, exec approval) are 4-8 lines; the slack covers a wrapped command or a longer hook list. +const LIVE_PROMPT_TAIL_LINES = 12 + +export function findTerminalWaitBlockedSignal(fullTail: string): BlockedTextSignal | null { + const windowStart = startOfLastNonBlankLines(fullTail, LIVE_PROMPT_TAIL_LINES) + const normalized = windowStart === 0 ? fullTail : fullTail.slice(windowStart) + // Why: one combined negative scan avoids a dozen searches when no prompt can match. + if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + return null + } + const signal = findBlockedSignalInLiveWindow(normalized) + // Why: callers compare this index against ready-header indexes found over the full tail. + return signal === null ? null : { reason: signal.reason, index: signal.index + windowStart } +} + +/** Whether a ready sign at `readyIndex` still owns the text: no blocker was painted after it. */ +export function isUnblockedAfter(normalized: string, readyIndex: number | null): boolean { + if (readyIndex === null) { + return false + } + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + return blockedSignal === null || blockedSignal.index <= readyIndex +} + +/** Unblocked, and no hold anchor says the agent behind it is still starting: input would land. */ +export function isSettledAfter(normalized: string, readyIndex: number | null): boolean { + return isUnblockedAfter(normalized, readyIndex) && !showsHoldAnchor(normalized) +} + +function findBlockedSignalInLiveWindow(normalized: string): BlockedTextSignal | null { + const candidates = findStartupDialogBlockedSignals(normalized) + const trustIndex = Math.max( + normalized.lastIndexOf('do you trust'), + normalized.lastIndexOf('trust this'), + normalized.lastIndexOf('trusted workspace') + ) + const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) + if ( + trustIndex !== -1 && + (trustSegment.includes('workspace') || + trustSegment.includes('folder') || + trustSegment.includes('directory') || + trustSegment.includes('repo')) + ) { + // Why neutral: this matcher never inspects the agent -- every TUI agent ships a workspace-trust dialog. + candidates.push({ reason: 'agent-trust-workspace', index: trustIndex }) + } + const interactivePromptIndex = Math.max( + normalized.lastIndexOf('press enter to confirm'), + normalized.lastIndexOf('press enter to continue'), + normalized.lastIndexOf('press enter to view'), + normalized.lastIndexOf('press enter to insert'), + normalized.lastIndexOf('press t to trust') + ) + const interactivePromptContext = + interactivePromptIndex === -1 + ? '' + : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) + // Why 'codex' only widens detection and never names the reason: the sole Codex evidence here is + // that word somewhere in 600 chars of scrollback, which an agent narrating about Codex satisfies + // on any pane -- enough to suspect a dialog, not enough to label a non-Codex user's pane. + const hasInteractiveDialogContext = + interactivePromptContext.includes('codex') || + interactivePromptContext.includes('permission') || + interactivePromptContext.includes('sandbox') || + interactivePromptContext.includes('trust') || + interactivePromptContext.includes('hook') + if (interactivePromptIndex !== -1 && hasInteractiveDialogContext) { + const contextStart = Math.max(0, interactivePromptIndex - 600) + const hasSpecificPromptInContext = candidates.some( + (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex + ) + if (!hasSpecificPromptInContext) { + candidates.push({ reason: 'agent-interactive-prompt', index: interactivePromptIndex }) + } + } + // Why after the generic prompt: it yields only to the startup and trust dialogs above. + candidates.push(...findBlockedAnchorSignals(normalized)) + const permissionPromptIndex = Math.max( + normalized.lastIndexOf('permission required'), + normalized.lastIndexOf('requires permission') + ) + if (permissionPromptIndex !== -1) { + const permissionSegment = normalized.slice(permissionPromptIndex, permissionPromptIndex + 1_500) + const decisionCount = ['allow once', 'allow always', 'reject', 'deny'].filter((choice) => + permissionSegment.includes(choice) + ).length + if (decisionCount >= 2) { + // Why neutral: an approval dialog with named choices identifies no agent; older hosts publish + // 'codex-interactive-prompt' here and clients alias the two. Rule 1 additive member -- + // remote-wire-compatibility.md names RuntimeTerminalWaitBlockedReason as Rule 1 because no + // consumer switches exhaustively on it. + // Why alias rather than drop the old spelling: preserve the existing remote receipt value for + // mixed-version clients -- an older host still publishes codex-* on this path. + candidates.push({ reason: 'agent-interactive-prompt', index: permissionPromptIndex }) + } + } + return candidates.length > 0 + ? candidates.reduce((latest, candidate) => + candidate.index > latest.index ? candidate : latest + ) + : null +} diff --git a/src/main/runtime/agent-state-rules/claude.json b/src/main/runtime/agent-state-rules/claude.json new file mode 100644 index 00000000000..61eaa1de037 --- /dev/null +++ b/src/main/runtime/agent-state-rules/claude.json @@ -0,0 +1,27 @@ +{ + "id": "claude", + "engineVersion": 1, + "anchors": [ + { + "id": "idle_title_glyph", + "why": "Claude Code prefixes its title with `✳` at rest; a working title carries a spinner instead.", + "when": { "region": "title", "match": { "regex": "^✳" } }, + "answer": { "state": "idle" } + }, + { + "id": "idle_title_ascii", + "why": "Claude Code's ASCII rest prefix, `* ` (`. ` while working).", + "when": { "region": "title", "match": { "regex": "^\\* " } }, + "answer": { "state": "idle" } + } + ], + "rules": [ + { + "id": "idle_title", + "why": "Claude announces rest with its own `✳` title, while a shell auto-title (`claude`, `claude ~/repo`) names it from launch, over the workspace-trust dialog or a busy turn alike, so a bare name counts only once quiet (#6011).", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": true } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/cline.json b/src/main/runtime/agent-state-rules/cline.json new file mode 100644 index 00000000000..8604db7dac5 --- /dev/null +++ b/src/main/runtime/agent-state-rules/cline.json @@ -0,0 +1,35 @@ +{ + "id": "cline", + "engineVersion": 1, + "profile": { "screenSource": "trusted" }, + "anchors": [], + "rules": [ + { + "id": "composer_ready", + "why": "Cline 3.0.x's empty composer box (startup, after-turn and Plan placeholders) over its Plan/Act mode row, which sits at most two rows above the bottom. Quiet and no spinner above because Cline repaints the same box while a reply streams and its spinner row has scrolled away.", + "priority": 500, + "when": { + "region": "screen", + "rows": [ + { "regex": "^─{8,}$" }, + { + "regex": "^❯ (?:what can i do for you\\?|ask anything\\.\\.\\.|plan something\\.\\.\\.)$", + "ignoreCase": true + }, + { "regex": "^─{8,}$" }, + { "regex": "[○●] plan [○●] act \\(tab\\)$", "ignoreCase": true } + ], + "endsWithinBottom": 3, + "noneAbove": { "regex": "[⠀-⣿]" } + }, + "answer": { "state": "idle", "strength": "strong", "requiresQuiet": true } + }, + { + "id": "composer_not_shown", + "why": "A readable screen that is not the idle composer may be a picker, dialog or busy turn, which a name-only title or a quiet process cannot see.", + "priority": 100, + "when": { "region": "screen" }, + "answer": { "state": "hold" } + } + ] +} diff --git a/src/main/runtime/codex-terminal-readiness.ts b/src/main/runtime/agent-state-rules/codex-screen-predicates.ts similarity index 52% rename from src/main/runtime/codex-terminal-readiness.ts rename to src/main/runtime/agent-state-rules/codex-screen-predicates.ts index 7ce60fa9933..46c4837681c 100644 --- a/src/main/runtime/codex-terminal-readiness.ts +++ b/src/main/runtime/agent-state-rules/codex-screen-predicates.ts @@ -1,3 +1,5 @@ +import { isUnblockedAfter } from './blocked-text-layer' + // Why both shapes: 0.150-0.157 paint `model: loading` in a box, 0.158 a bare `loading` under the title. const CODEX_HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m // Why a line cap: 0.158 draws no box, so nothing else ends its header before the chat. @@ -23,45 +25,29 @@ function findCodexHeader(screen: string): { index: number; text: string } | null return { index, text } } -/** The 0.150-0.157 header, which only a grid reassembles (see isCodexScreenHeaderReady). */ -export function findCodexScreenReadyPromptIndex(screen: string): number | null { +/** + * The named screen predicate `codex-header-ready`: the 0.150-0.157 header, loaded, with no blocker + * painted below it. Why the screen: Codex repaints that header by cell diff (`ESC[5;3Hdir + * ESC[5;7Hctory:`), which only a grid reassembles; the line-folded wait text reads `dirctory:`. + */ +export function isCodexHeaderReadyScreen(screen: string): boolean { const header = findCodexHeader(screen) - return header !== null && + return ( + header !== null && header.text.includes('model:') && header.text.includes('directory:') && - !CODEX_HEADER_LOADING_RE.test(header.text) - ? header.index - : null -} - -// Why the text copy: 0.157 leaves its alternate screen while it starts its daemon, so the live -// screen shows no header then, while the text copy keeps the provisional one until the live chat -// paints its footer after it (a later model repaint rewrites only the value, never the label). -// Why `·`: every live footer row draws one (status row, `← for agents · ?`, `⚠ N warning · f2`); -// startup dialogs draw one too, which is why startup-dialog-blocked-signals.ts matches them first. -export function isCodexProvisionalStartupText(normalized: string): boolean { - const headerIndex = normalized.lastIndexOf('openai codex') - if (headerIndex === -1) { - return false - } - const loading = /model:\s+loading/.exec(normalized.slice(headerIndex)) - return loading !== null && !normalized.includes('·', headerIndex + loading.index) -} - -// Why: Codex repaints its whole screen, header included, once a startup dialog closes, and the -// dialog never draws the header; 0.158's header has no labels, so the header alone marks it answered. -export function findCodexHeaderIndex(normalized: string): number | null { - const index = normalized.lastIndexOf('openai codex (v') - return index === -1 ? null : index + !CODEX_HEADER_LOADING_RE.test(header.text) && + isUnblockedAfter(screen, header.index) + ) } /** - * Tier 1b, codex panes only: the empty composer with no busy status row just above it and no - * header load. Codex 0.158 dropped `model:`/`directory:`, and a long session scrolls the header - * away, so this is its only version-stable rest body. No dialog check: every Codex dialog - * replaces the composer, while an answer ending "Would you like to…?" must not block the lane. - * The mid-turn guard is the caller's quiescence, fed by the ~100 ms title spinner and status - * timer; `tui.animations=false` (set by a screen reader), `tui.effects.progress=false`, or a + * The named screen predicate `codex-composer-ready`: the empty composer with no busy status row + * just above it and no header load. Codex 0.158 dropped `model:`/`directory:`, and a long session + * scrolls the header away, so this is its only version-stable rest body. No dialog check: every + * Codex dialog replaces the composer, while an answer ending "Would you like to…?" must not block + * the lane. The mid-turn guard is quiescence, fed by the ~100 ms title spinner and status timer; + * `tui.animations=false` (set by a screen reader), `tui.effects.progress=false`, or a * `tui.terminal_title` without activity/spinner removes it. */ export function isCodexComposerReadyScreen(screen: string): boolean { diff --git a/src/main/runtime/agent-state-rules/codex.json b/src/main/runtime/agent-state-rules/codex.json new file mode 100644 index 00000000000..b52d85a8d6f --- /dev/null +++ b/src/main/runtime/agent-state-rules/codex.json @@ -0,0 +1,71 @@ +{ + "id": "codex", + "engineVersion": 1, + "profile": { "screenSource": "live" }, + "anchors": [ + { + "id": "ready_header", + "why": "The startup header with its model and directory labels is the stable ready text across versions; Codex prints permissions only in YOLO mode. Its last occurrence counts, so a header repainted after a dialog proves the dialog was answered.", + "when": { + "region": "text", + "find": { "lastOf": "openai codex" }, + "after": { "all": [{ "contains": "model:" }, { "contains": "directory:" }] } + }, + "answer": { "state": "idle" } + }, + { + "id": "header", + "why": "Codex repaints its whole screen, header included, once a startup dialog closes, and no dialog draws the header. 0.158's header has no labels, so the header alone proves an earlier dialog was answered, but not that Codex is ready.", + "when": { "region": "text", "find": { "lastOf": "openai codex (v" } }, + "answer": { "state": "live" } + }, + { + "id": "provisional_startup", + "why": "0.157 paints a provisional header (`model: loading`) while its daemon starts and discards input typed behind it, so no ready text may settle a wait until the live chat paints a footer row, each of which draws a `·`. Read from the text because 0.157 leaves its alternate screen while the daemon starts; a later model repaint rewrites only the value, never the label.", + "when": { + "region": "text", + "find": { "lastOf": "openai codex" }, + "after": { + "all": [{ "regex": "model:\\s+loading" }], + "none": [{ "regex": "model:\\s+loading[\\s\\S]*·" }] + } + }, + "answer": { "state": "hold" } + } + ], + "rules": [ + { + "id": "header_ready", + "why": "The 0.150-0.157 header, loaded and with no blocker below it, read from the live screen because Codex repaints it by cell diff, which only a grid reassembles. Quiet because the header stays painted through a turn.", + "priority": 500, + "when": { "region": "screen", "predicate": "codex-header-ready" }, + "answer": { "state": "idle", "strength": "strong", "requiresQuiet": true } + }, + { + "id": "composer_ready", + "why": "The empty composer with no busy status row above it: 0.158 dropped the header labels and a long session scrolls the header away, so this is the version-stable rest screen. Quiet because Codex keeps the composer painted mid-turn, and skipped without an output clock, where nothing could tell a running turn apart.", + "priority": 500, + "when": { "region": "screen", "predicate": "codex-composer-ready" }, + "answer": { + "state": "idle", + "strength": "strong", + "requiresQuiet": true, + "withoutClock": "skip" + } + }, + { + "id": "text_header_ready", + "why": "The ready header in the text tail, settled. Quiet because it stays in the tail through a turn; an idle Codex titles its pane with its cwd, so no title says it is at rest.", + "priority": 500, + "when": { "region": "text", "anchor": "ready_header" }, + "answer": { "state": "idle", "strength": "strong", "requiresQuiet": true } + }, + { + "id": "idle_title", + "why": "A shell auto-title names Codex from launch, over a startup dialog or a busy turn alike, while its hooks drive an explicit `Codex ready` title at rest, so a bare name counts only once quiet (#6011).", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": true } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/cursor.json b/src/main/runtime/agent-state-rules/cursor.json new file mode 100644 index 00000000000..050f98daf9f --- /dev/null +++ b/src/main/runtime/agent-state-rules/cursor.json @@ -0,0 +1,54 @@ +{ + "id": "cursor", + "engineVersion": 1, + "anchors": [ + { + "id": "approval_menu", + "why": "cursor-agent has no approval hook, so its key-bound menu is the only authority. Bounded to the last 8 lines because an answered menu stays in scrollback, and each choice must end in a selectable key because narration can repeat the menu's wording.", + "when": { + "region": "text", + "find": { "lastOf": "run this command?" }, + "withinLastLines": 8, + "lines": { + "atLeast": 2, + "includingLast": true, + "test": { + "all": [ + { + "regex": "\\((?:shift\\+tab|ctrl\\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\\u21b5\\u21e7\\u21b9\\u238b\\u23ce]{1,3})\\)\\s*$" + } + ], + "any": [ + { "contains": "run (once)" }, + { "contains": "to allowlist?" }, + { "contains": "run everything" }, + { "contains": "skip & tell the agent" } + ] + } + } + }, + "answer": { "state": "blocked", "reason": "agent-approval-prompt" } + }, + { + "id": "prompt_busy", + "why": "The banner's last occurrence skips the trust dialog's own `Cursor Agent` text; `→` is the persistent input prompt. cursor-agent emits no idle title, so a braille spinner after the banner is the only busy sign; a busy prompt still proves an earlier dialog was answered.", + "when": { + "region": "text", + "find": { "lastOf": "cursor agent" }, + "after": { "all": [{ "contains": "→" }, { "regex": "[⠁-⣿]" }] } + }, + "answer": { "state": "live" } + }, + { + "id": "prompt_idle", + "why": "The same prompt with no spinner after the banner.", + "when": { + "region": "text", + "find": { "lastOf": "cursor agent" }, + "after": { "all": [{ "contains": "→" }], "none": [{ "regex": "[⠁-⣿]" }] } + }, + "answer": { "state": "idle" } + } + ], + "rules": [] +} diff --git a/src/main/runtime/agent-state-rules/gemini.json b/src/main/runtime/agent-state-rules/gemini.json new file mode 100644 index 00000000000..45d62392baa --- /dev/null +++ b/src/main/runtime/agent-state-rules/gemini.json @@ -0,0 +1,21 @@ +{ + "id": "gemini", + "engineVersion": 1, + "anchors": [ + { + "id": "idle_title", + "why": "Gemini CLI marks rest with `◇` in its title (`✦` while working, `✋` on a permission).", + "when": { "region": "title", "match": { "contains": "◇" } }, + "answer": { "state": "idle" } + } + ], + "rules": [ + { + "id": "name_title", + "why": "Its `◇` rest title would justify asking a name-only title for quiet, but Gemini is not yet held to it: a name-only title settles once held, with no quiet asked.", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": false } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/omp.json b/src/main/runtime/agent-state-rules/omp.json new file mode 100644 index 00000000000..a0311486cb7 --- /dev/null +++ b/src/main/runtime/agent-state-rules/omp.json @@ -0,0 +1,14 @@ +{ + "id": "omp", + "engineVersion": 1, + "anchors": [], + "rules": [ + { + "id": "idle_title", + "why": "Its `π - ` rest title is Pi's title anchor (pi.json), which every pane reads. Orca writes an explicit `OMP ready` title at rest, while a shell auto-title names OMP from launch, mid-turn too, so a bare name counts only once quiet (#6011).", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": true } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/opencode.json b/src/main/runtime/agent-state-rules/opencode.json new file mode 100644 index 00000000000..47316f368c9 --- /dev/null +++ b/src/main/runtime/agent-state-rules/opencode.json @@ -0,0 +1,21 @@ +{ + "id": "opencode", + "engineVersion": 1, + "anchors": [ + { + "id": "native_title", + "why": "OpenCode's own session title (`OC | `, after an optional wrapper label or status glyph). It unblocks waits on hookless remote panes; guarded writes corroborate it. Named because presence detection reads the same marker.", + "when": { "region": "title", "match": { "predicate": "opencode-native-title" } }, + "answer": { "state": "idle" } + } + ], + "rules": [ + { + "id": "name_title", + "why": "OpenCode owns its session title, so Orca writes no `ready` title over it; its name is its only other rest sign, so it settles once held, with no quiet asked.", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": false } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/opencode2.json b/src/main/runtime/agent-state-rules/opencode2.json new file mode 100644 index 00000000000..d1d543d08fe --- /dev/null +++ b/src/main/runtime/agent-state-rules/opencode2.json @@ -0,0 +1,14 @@ +{ + "id": "opencode2", + "engineVersion": 1, + "anchors": [], + "rules": [ + { + "id": "name_title", + "why": "Its `OC |` rest title is OpenCode's title anchor (opencode.json), which every pane reads. Its name is its only other rest sign, so it settles once held, with no quiet asked.", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": false } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/pi.json b/src/main/runtime/agent-state-rules/pi.json new file mode 100644 index 00000000000..67b9eb3221a --- /dev/null +++ b/src/main/runtime/agent-state-rules/pi.json @@ -0,0 +1,21 @@ +{ + "id": "pi", + "engineVersion": 1, + "anchors": [ + { + "id": "idle_title", + "why": "Pi, and OMP after it, title the pane `π - ` at rest (`π ⠋ ` while working).", + "when": { "region": "title", "match": { "regex": "^π - " } }, + "answer": { "state": "idle" } + } + ], + "rules": [ + { + "id": "idle_title", + "why": "Orca writes an explicit `Pi ready` title at rest, while a shell auto-title names Pi from launch, mid-turn too, so a bare name counts only once quiet (#6011).", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": true } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/prime-agent.json b/src/main/runtime/agent-state-rules/prime-agent.json new file mode 100644 index 00000000000..ffbcfd076ac --- /dev/null +++ b/src/main/runtime/agent-state-rules/prime-agent.json @@ -0,0 +1,32 @@ +{ + "id": "prime-agent", + "engineVersion": 1, + "profile": { "screenSource": "trusted" }, + "anchors": [], + "rules": [ + { + "id": "composer_ready", + "why": "Prime Agent 0.9.5+ at an idle composer: a bare `>` over the `← manage` footer, optionally under the view-mode hint (`Details mode` in 0.9.8, `Collapsed mode` in 0.9.5). Both stay painted for a whole turn, so only the status row above them (`⠦ Writing · 6s`) says a turn is running; quiet because the composer also paints just before the first-launch question.", + "priority": 500, + "when": { + "region": "screen", + "rows": [ + { "none": [{ "regex": "[⠀-⣿]" }] }, + { + "optional": { "regex": "^\\S+ mode \\(ctrl\\+o to expand\\)$", "ignoreCase": true } + }, + { "regex": "^>$" }, + { "regex": "^← manage" } + ] + }, + "answer": { "state": "idle", "strength": "strong", "requiresQuiet": true } + }, + { + "id": "composer_not_shown", + "why": "A readable screen that is not the idle composer may be a picker, dialog or busy turn, which a name-only title or a quiet process cannot see.", + "priority": 100, + "when": { "region": "screen" }, + "answer": { "state": "hold" } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/unknown-pane.json b/src/main/runtime/agent-state-rules/unknown-pane.json new file mode 100644 index 00000000000..8cb0e2d3c04 --- /dev/null +++ b/src/main/runtime/agent-state-rules/unknown-pane.json @@ -0,0 +1,15 @@ +{ + "id": "unknown-pane", + "engineVersion": 1, + "profile": { "screenSource": "live" }, + "anchors": [], + "rules": [ + { + "id": "codex_header_ready", + "why": "An adopted pane can run Codex without Orca knowing it. Codex's loaded 0.150-0.157 header on the live screen, with no blocker below it, settles such a pane at once (unlike a Codex pane, which holds it to quiet).", + "priority": 500, + "when": { "region": "screen", "predicate": "codex-header-ready" }, + "answer": { "state": "idle", "strength": "strong", "requiresQuiet": false } + } + ] +} diff --git a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts index 14746104ffd..1df9ccefdad 100644 --- a/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts +++ b/src/main/runtime/antigravity-screen-readiness-transcripts.test.ts @@ -5,8 +5,10 @@ import { readRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' -import { isAntigravityComposerReadyScreen } from './antigravity-terminal-readiness' -import { describeScreenRuledAgentTranscripts } from './screen-ruled-agent-transcript-suite' +import { + describeScreenRuledAgentTranscripts, + readsIdleComposer +} from './screen-ruled-agent-transcript-suite' import { isKnownReadyPromptBody, isKnownReadyPromptPreview, @@ -43,7 +45,6 @@ describe('Antigravity 1.2.14 readiness from captured bytes', () => { describeScreenRuledAgentTranscripts({ agent: 'antigravity', foregroundProcess: 'agy', - rule: isAntigravityComposerReadyScreen, ready: READY, notReady: NOT_READY, // Why these: the line-folded text rule reads only these ready screens. @@ -93,7 +94,7 @@ describe('Antigravity 1.2.14 readiness from captured bytes', () => { for await (const { ruledScreenLines } of replayTranscript(data, 120, 40)) { submitted ||= ruledScreenLines.some((line) => line.startsWith('> Without using any tools')) answered ||= ruledScreenLines.some((line) => line.trim() === 'ok') - if (submitted && !answered && isAntigravityComposerReadyScreen(ruledScreenLines)) { + if (submitted && !answered && readsIdleComposer('antigravity', ruledScreenLines)) { readyMidTurn += 1 } } diff --git a/src/main/runtime/cline-screen-readiness-transcripts.test.ts b/src/main/runtime/cline-screen-readiness-transcripts.test.ts index e5c51be5d5c..b73cec30a4e 100644 --- a/src/main/runtime/cline-screen-readiness-transcripts.test.ts +++ b/src/main/runtime/cline-screen-readiness-transcripts.test.ts @@ -6,8 +6,10 @@ import { readRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' -import { isClineComposerReadyScreen } from './cline-terminal-readiness' -import { describeScreenRuledAgentTranscripts } from './screen-ruled-agent-transcript-suite' +import { + describeScreenRuledAgentTranscripts, + readsIdleComposer +} from './screen-ruled-agent-transcript-suite' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -38,7 +40,6 @@ describe('Cline readiness from captured bytes', () => { describeScreenRuledAgentTranscripts({ agent: 'cline', foregroundProcess: 'cline', - rule: isClineComposerReadyScreen, ready: READY, notReady: NOT_READY, // Why all: an idle Cline is quiet, so the quiet-process lane settles it. @@ -55,7 +56,7 @@ describe('Cline readiness from captured bytes', () => { // Why only quiescence can refuse it: the streaming reply has scrolled its spinner away. it('paints the same empty composer while a reply streams', async () => { const { ruledScreenLines } = await finalReplayFrame(STREAMING, 120, 40) - expect(isClineComposerReadyScreen(ruledScreenLines)).toBe(true) + expect(readsIdleComposer('cline', ruledScreenLines)).toBe(true) }) it('refuses every frame whose spinner row is still on screen', async () => { @@ -67,7 +68,7 @@ describe('Cline readiness from captured bytes', () => { )) { if (ruledScreenLines.some((line) => /[\u2800-\u28ff] Thinking/.test(line))) { spinnerFrames += 1 - expect(isClineComposerReadyScreen(ruledScreenLines)).toBe(false) + expect(readsIdleComposer('cline', ruledScreenLines)).toBe(false) } } // Presence precondition: the thinking spinner was painted above the composer. diff --git a/src/main/runtime/cline-terminal-readiness.ts b/src/main/runtime/cline-terminal-readiness.ts deleted file mode 100644 index 4088eea4ab1..00000000000 --- a/src/main/runtime/cline-terminal-readiness.ts +++ /dev/null @@ -1,29 +0,0 @@ -const CLINE_RULE_RE = /^─{8,}$/ -// Captured placeholders: startup (Act), after a turn (Act), and Plan mode. -const CLINE_EMPTY_COMPOSERS: ReadonlySet = new Set([ - '❯ what can i do for you?', - '❯ ask anything...', - '❯ plan something...' -]) -const CLINE_MODE_ROW_RE = /[○●] plan [○●] act \(tab\)$/ -const BRAILLE_SPINNER_RE = /[⠀-⣿]/ - -/** - * Cline 3.0.x's empty composer box with its Plan/Act status rows under it. - * Why it proves only that the composer is up: Cline repaints the same box while a reply streams - * and its spinner row has scrolled away, so callers must also require a quiet stream. - */ -export function isClineComposerReadyScreen(screenLines: readonly string[]): boolean { - const lines = screenLines.map((line) => line.trim().toLowerCase()) - const modeRow = lines.findLastIndex((line) => CLINE_MODE_ROW_RE.test(line)) - // Why bounded: the mode row sits above the cwd and auto-approve rows, never higher. - if (modeRow < 3 || modeRow < lines.length - 3) { - return false - } - return ( - CLINE_RULE_RE.test(lines[modeRow - 1]) && - CLINE_EMPTY_COMPOSERS.has(lines[modeRow - 2]) && - CLINE_RULE_RE.test(lines[modeRow - 3]) && - !lines.slice(0, modeRow - 3).some((line) => BRAILLE_SPINNER_RE.test(line)) - ) -} diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index 051a086eb38..8a6c6cf211e 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -8,7 +8,7 @@ import { replayTranscript, type TranscriptReplayFrame } from './agent-transcript-replay-test-harness' -import { isCodexComposerReadyScreen } from './codex-terminal-readiness' +import { isCodexComposerReadyScreen } from './agent-state-rules/codex-screen-predicates' import { detectTerminalWaitBlockedReason, isKnownReadyPromptBody, @@ -197,7 +197,7 @@ describe('Codex composer ready screen, frame by frame', () => { readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => screenLines), agent: 'codex', - readScreenDecidesReadiness: () => false, + readAgentRuleVerdict: () => null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -294,7 +294,7 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => screenLines), agent: 'codex', - readScreenDecidesReadiness: () => false, + readAgentRuleVerdict: () => null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -351,7 +351,7 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { isKnownReadyPromptBody(waitText, 'codex', () => header, record.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), agent: 'codex', - readScreenDecidesReadiness: () => false, + readAgentRuleVerdict: () => null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -380,7 +380,7 @@ describe('reading the live screen never removes quiet-lane readiness', () => { readPositiveBodyEvidence: () => isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), agent, - readScreenDecidesReadiness: () => false, + readAgentRuleVerdict: () => null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS } satisfies Omit diff --git a/src/main/runtime/cursor-approval-prompt-detection.ts b/src/main/runtime/cursor-approval-prompt-detection.ts deleted file mode 100644 index d3132eca51f..00000000000 --- a/src/main/runtime/cursor-approval-prompt-detection.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { startOfLastLines } from './terminal-wait-tail-window' - -// Why text at all: cursor-agent has no approval hook, so the key-bound menu is the only authority. -const CURSOR_APPROVAL_CHOICE_MARKERS = [ - 'run (once)', - 'to allowlist?', - 'run everything', - 'skip & tell the agent' -] -// Why bounded: an answered menu remains in scrollback; only a dialog owning the screen bottom is live. -const CURSOR_APPROVAL_TAIL_LINES = 8 - -export function findCursorApprovalPromptIndex(normalized: string): number | null { - const windowStart = startOfLastLines(normalized, CURSOR_APPROVAL_TAIL_LINES) - const tail = normalized.slice(windowStart) - if (!tail.includes('run this command?')) { - return null - } - const lines = tail.split('\n') - while (lines.length > 0 && lines.at(-1)?.trim() === '') { - lines.pop() - } - let matchedLines = 0 - let lastChoiceLine = -1 - for (let index = 0; index < lines.length; index += 1) { - if (!isCursorApprovalChoiceLine(lines[index])) { - continue - } - matchedLines += 1 - lastChoiceLine = index - } - return matchedLines >= 2 && lastChoiceLine === lines.length - 1 - ? windowStart + tail.lastIndexOf('run this command?') - : null -} - -// Why the trailing key: narration can repeat the menu wording, but it does not end in a selectable key. -const CURSOR_APPROVAL_CHOICE_KEY_RE = - /\((?:shift\+tab|ctrl\+[a-z]|esc(?: or [a-z])*|tab|enter|return|space|[a-z]|[\u21b5\u21e7\u21b9\u238b\u23ce]{1,3})\)\s*$/ - -function isCursorApprovalChoiceLine(line: string): boolean { - return ( - CURSOR_APPROVAL_CHOICE_KEY_RE.test(line) && - CURSOR_APPROVAL_CHOICE_MARKERS.some((marker) => line.includes(marker)) - ) -} diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index 4d74cc89e4a..4add11ee4f4 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -14,7 +14,7 @@ import { isKnownReadyPromptBody, isKnownReadyPromptSettled } from './terminal-wait-detection' -import { getScreenReadyRule } from './screen-ruled-agent-readiness' +import { readsTrustedScreen } from './agent-state-rules/agent-state-rules-engine' import { restoreProjectedComposerDraft } from './orca-runtime-terminal-projection' import type { RuntimeTerminalWait, @@ -55,7 +55,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith if (providerTimeoutMs < 1) { return } - const screenRule = getScreenReadyRule(agent) + const screenRule = readsTrustedScreen(agent) void withTimeout( this.readTerminal(waiter.handle, screenRule ? { screen: true } : {}, { timeoutMs: providerTimeoutMs, diff --git a/src/main/runtime/prime-agent-screen-readiness-transcripts.test.ts b/src/main/runtime/prime-agent-screen-readiness-transcripts.test.ts index e87cf6e1b65..f4645d87ed3 100644 --- a/src/main/runtime/prime-agent-screen-readiness-transcripts.test.ts +++ b/src/main/runtime/prime-agent-screen-readiness-transcripts.test.ts @@ -4,8 +4,10 @@ import { readRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' -import { isPrimeAgentComposerReadyScreen } from './prime-agent-terminal-readiness' -import { describeScreenRuledAgentTranscripts } from './screen-ruled-agent-transcript-suite' +import { + describeScreenRuledAgentTranscripts, + readsIdleComposer +} from './screen-ruled-agent-transcript-suite' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -42,7 +44,6 @@ describe('Prime Agent readiness from captured bytes', () => { describeScreenRuledAgentTranscripts({ agent: 'prime-agent', foregroundProcess: 'prime-agent', - rule: isPrimeAgentComposerReadyScreen, ready: READY, notReady: NOT_READY, // Why all: an idle Prime is quiet, so the quiet-process lane settles it. @@ -70,7 +71,7 @@ describe('Prime Agent readiness from captured bytes', () => { const screen = screenOf(ruledScreenLines) markerSeen ||= submittedMarker !== null && screen.includes(submittedMarker) questionSeen ||= screen.includes('Share agent traces') - if (markerSeen && !questionSeen && isPrimeAgentComposerReadyScreen(ruledScreenLines)) { + if (markerSeen && !questionSeen && readsIdleComposer('prime-agent', ruledScreenLines)) { readyAfterMarker += 1 } } diff --git a/src/main/runtime/prime-agent-terminal-readiness.ts b/src/main/runtime/prime-agent-terminal-readiness.ts deleted file mode 100644 index 3ca6d916f57..00000000000 --- a/src/main/runtime/prime-agent-terminal-readiness.ts +++ /dev/null @@ -1,22 +0,0 @@ -const PRIME_FOOTER_PREFIX = '← manage' -// 0.9.8 paints `Details mode`, 0.9.5 `Collapsed mode`. -const PRIME_VIEW_MODE_HINT_RE = /^\S+ mode \(ctrl\+o to expand\)$/i -const BRAILLE_SPINNER_RE = /[⠀-⣿]/ - -/** - * Prime Agent 0.9.5+ at an idle composer: a bare `>` over the `← manage` footer. - * Why the spinner veto: the footer and the caret both stay painted for a whole turn; only the - * status row Prime keeps directly above them (`⠦ Writing · 6s`) says a turn is running. - */ -export function isPrimeAgentComposerReadyScreen(screenLines: readonly string[]): boolean { - const footer = screenLines.at(-1)?.trim() ?? '' - const composer = screenLines.at(-2)?.trim() - if (!footer.startsWith(PRIME_FOOTER_PREFIX) || composer !== '>') { - return false - } - let statusIndex = screenLines.length - 3 - if (PRIME_VIEW_MODE_HINT_RE.test(screenLines[statusIndex]?.trim() ?? '')) { - statusIndex -= 1 - } - return !BRAILLE_SPINNER_RE.test(screenLines[statusIndex] ?? '') -} diff --git a/src/main/runtime/runtime-environment-identity-verification.ts b/src/main/runtime/runtime-environment-identity-verification.ts index 6fec7dbc4d8..b23f0658706 100644 --- a/src/main/runtime/runtime-environment-identity-verification.ts +++ b/src/main/runtime/runtime-environment-identity-verification.ts @@ -4,7 +4,7 @@ import { } from '../../shared/runtime-environments' import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' import { verifyRemotePairingRuntimeStatus } from '../../shared/remote-pairing-verification' -import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import type { RuntimeStatus } from '../../shared/runtime-types' export async function verifyRuntimeEnvironmentIdentity( diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index c02244c5734..ce9f4231f3d 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -9,8 +9,8 @@ import { buildTerminalWaitResult, getTerminalState } from './terminal-wait-results' -import { getScreenReadyRule } from './screen-ruled-agent-readiness' -import { hasAntigravityTerminalHeader } from './antigravity-terminal-readiness' +import { readsTrustedScreen } from './agent-state-rules/agent-state-rules-engine' +import { showsScreenProbeBanner } from './agent-state-rules/agent-state-text-anchors' import { buildTerminalWaitText } from './terminal-wait-tail-state' import { evaluateTuiIdle, @@ -27,9 +27,9 @@ import type { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-re /** * A pane with no retained bytes and no status has only its provider's screen to read, and one - * whose tail shows the Antigravity banner is probed as before screen rules. So is a clockless - * screen-ruled pane whatever its status: a re-attached pane's own model can be untrusted. A - * clocked one settles through the poll, once quiet. + * whose tail shows a rule file's `profile.screenProbeBanner` is probed as before screen rules. So + * is a clockless pane whose rules read the trusted screen, whatever its status: a re-attached + * pane's own model can be untrusted. A clocked one settles through the poll, once quiet. */ function shouldProbeVisibleScreen( paneAgent: TuiAgent | null, @@ -38,8 +38,8 @@ function shouldProbeVisibleScreen( ): boolean { return ( (record.lastAgentStatus === null && waitText.length === 0) || - hasAntigravityTerminalHeader(waitText) || - (getScreenReadyRule(paneAgent) !== null && record.lastOutputAt === null) + showsScreenProbeBanner(waitText) || + (readsTrustedScreen(paneAgent) && record.lastOutputAt === null) ) } diff --git a/src/main/runtime/screen-ruled-agent-readiness.ts b/src/main/runtime/screen-ruled-agent-readiness.ts deleted file mode 100644 index b5a4257243d..00000000000 --- a/src/main/runtime/screen-ruled-agent-readiness.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { TuiAgent } from '../../shared/tui-agent' -import { isAntigravityComposerReadyScreen } from './antigravity-terminal-readiness' -import { isClineComposerReadyScreen } from './cline-terminal-readiness' -import { isPrimeAgentComposerReadyScreen } from './prime-agent-terminal-readiness' - -type ScreenReadyRule = (screenLines: readonly string[]) => boolean - -/** - * Agents whose live screen decides readiness. Each rule reads an idle composer off the grid, - * which a folded text tail loses to cursor addressing. Why a clocked pane waits for quiet too: - * the captures paint that composer for a moment mid-turn (a submit repaint, a spinner row - * erased before its redraw) and, for Prime, just before its first-launch question. - */ -const SCREEN_READY_RULES: Partial> = { - antigravity: isAntigravityComposerReadyScreen, - cline: isClineComposerReadyScreen, - 'prime-agent': isPrimeAgentComposerReadyScreen -} - -export function getScreenReadyRule(agent: TuiAgent | null | undefined): ScreenReadyRule | null { - return agent ? (SCREEN_READY_RULES[agent] ?? null) : null -} - -/** - * The agent's screen rule applied to its live screen, or null when it has no rule or no - * trustworthy screen is readable. Why a verdict outranks every text rule: the screen is what - * the folded text was copied from, and the text cannot see a picker or dialog covering it. - */ -export function readScreenRuledVerdict( - agent: TuiAgent | null | undefined, - readScreenLines: () => readonly string[] | null -): boolean | null { - const rule = getScreenReadyRule(agent) - const screenLines = rule ? readScreenLines() : null - return rule && screenLines ? rule(screenLines) : null -} diff --git a/src/main/runtime/screen-ruled-agent-transcript-suite.ts b/src/main/runtime/screen-ruled-agent-transcript-suite.ts index 57a63107861..ce970f697d2 100644 --- a/src/main/runtime/screen-ruled-agent-transcript-suite.ts +++ b/src/main/runtime/screen-ruled-agent-transcript-suite.ts @@ -12,6 +12,7 @@ import { type TranscriptReplayResize } from './agent-transcript-replay-test-harness' import { isKnownReadyPromptBody, isQuietReadyScreenBody } from './terminal-wait-detection' +import { evaluateAgentStateRules } from './agent-state-rules/agent-state-rules-engine' import type { TuiAgent } from '../../shared/tui-agent' export type ScreenRuledFixture = { name: string; cols: number; rows: number; what: string } @@ -19,7 +20,6 @@ export type ScreenRuledFixture = { name: string; cols: number; rows: number; wha export type ScreenRuledAgentSuite = { agent: TuiAgent foregroundProcess: string - rule: (screenLines: readonly string[]) => boolean ready: readonly ScreenRuledFixture[] notReady: readonly ScreenRuledFixture[] /** Ready recordings the text rules or the quiet-process lane settle with no screen. */ @@ -45,8 +45,14 @@ function chunkCount(name: string): number { return Math.ceil(readRuntimeFixture(name).length / 64) } +/** Whether the agent's own rules read `screenLines` as an idle composer. */ +export function readsIdleComposer(agent: TuiAgent, screenLines: readonly string[]): boolean { + return evaluateAgentStateRules(agent, { readScreenLines: () => screenLines })?.state === 'idle' +} + export function describeScreenRuledAgentTranscripts(suite: ScreenRuledAgentSuite): void { - const { agent, rule } = suite + const { agent } = suite + const rule = (screenLines: readonly string[]): boolean => readsIdleComposer(agent, screenLines) const [firstReady] = suite.ready if (!firstReady) { throw new Error(`${agent}: a suite needs a ready recording`) diff --git a/src/main/runtime/startup-dialog-blocked-signals.ts b/src/main/runtime/startup-dialog-blocked-signals.ts index e964205a9cc..f07cce20343 100644 --- a/src/main/runtime/startup-dialog-blocked-signals.ts +++ b/src/main/runtime/startup-dialog-blocked-signals.ts @@ -1,6 +1,6 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' -// Why rows, from each dialog's first `·` on: codex-terminal-readiness.ts takes a `·` after the +// Why rows, from each dialog's first `·` on: codex.json's provisional_startup takes a `·` after the // startup header for the live chat's footer, so each dialog must be matched by the time that `·` // lands. Why not headings: Codex 0.157+ paints them by cell diff over its startup screen, so the // text copy can lose letters and spaces (`updat available`); these rows are fixed literals. diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index 2ce91a287f5..a467e45521f 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -20,6 +20,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({ scanAiVaultSessionsInWorker: vi.fn(), @@ -41,7 +42,8 @@ function installHost(): Promise { claimKeyId: 'key-1', resolveWorkspacePath: async () => stateDirectory, resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), - resolveEnvironment: async () => ({}) + resolveEnvironment: async () => ({}), + logger: createStructuredAgentSessionLogger() }) } diff --git a/src/main/runtime/terminal-tail-sentinel-index.test.ts b/src/main/runtime/terminal-tail-sentinel-index.test.ts index cd8bf2e68fb..b4c1c888012 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.test.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.test.ts @@ -8,7 +8,7 @@ import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' import { computeTerminalTailWaitState } from './terminal-wait-tail-state' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-detection' +import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' // The definition the incremental index must reproduce: does ANY retained line (or the diff --git a/src/main/runtime/terminal-tail-sentinel-index.ts b/src/main/runtime/terminal-tail-sentinel-index.ts index c99fd31bac7..799bf759696 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.ts @@ -1,4 +1,4 @@ -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './terminal-wait-detection' +import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' /** * Which retained tail lines match the wait-blocked sentinel, memoized per diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index f1f5aa6c81c..fb24d21c124 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -1,47 +1,31 @@ import { isQoderComposerReady } from './qoder-terminal-readiness' import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo' -import { - detectAgentStatusFromTitle, - isOpenCodeNativeTitle, - type AgentStatus -} from '../../shared/agent-detection' +import { detectAgentStatusFromTitle, type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import type { TuiAgent } from '../../shared/tui-agent' -import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness' -import { readScreenRuledVerdict } from './screen-ruled-agent-readiness' import { - findCodexHeaderIndex, - findCodexScreenReadyPromptIndex, - isCodexComposerReadyScreen, - isCodexProvisionalStartupText -} from './codex-terminal-readiness' -import { findStartupDialogBlockedSignals } from './startup-dialog-blocked-signals' -import { startOfLastNonBlankLines } from './terminal-wait-tail-window' -import { findCursorApprovalPromptIndex } from './cursor-approval-prompt-detection' + evaluateAgentStateRules, + hasQuietReadyRules, + holdsReadyTextToQuiet, + type AgentStateVerdict +} from './agent-state-rules/agent-state-rules-engine' +import { findPromptAnchorIndexes } from './agent-state-rules/agent-state-text-anchors' +import { showsIdleTitleAnchor } from './agent-state-rules/agent-state-title-anchors' +import { + findTerminalWaitBlockedSignal, + isSettledAfter, + isUnblockedAfter +} from './agent-state-rules/blocked-text-layer' +// Why agent-agnostic: Orca's own ` ready` titles, and any agent title stating rest in words. const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i -const CLAUDE_IDLE_PREFIX = '\u2733' -const GEMINI_IDLE_PREFIX = '\u25c7' -const PI_IDLE_PREFIX = '\u03c0 - ' function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null { const status = detectAgentStatusFromTitle(title) - if (status !== 'idle') { - return null - } // Why: launch titles like "Codex YOLO" contain an agent name but aren't readiness signals; terminal.wait needs explicit idle evidence. - if ( - EXPLICIT_IDLE_TITLE_RE.test(title) || - // Why: unblock hookless remote waits; guarded writes corroborate this marker. - isOpenCodeNativeTitle(title) || - title.startsWith(CLAUDE_IDLE_PREFIX) || - title.startsWith('* ') || - title.includes(GEMINI_IDLE_PREFIX) || - title.startsWith(PI_IDLE_PREFIX) - ) { - return 'idle' - } - return null + return status === 'idle' && (EXPLICIT_IDLE_TITLE_RE.test(title) || showsIdleTitleAnchor(title)) + ? 'idle' + : null } /** @@ -54,32 +38,27 @@ export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus | export function isKnownReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() - return isReadyPromptUnblocked(normalized, findKnownReadyPromptIndex(normalized)) + return isUnblockedAfter(normalized, findPromptAnchorIndexes(normalized).ready) } /** * The ready-prompt text rules for a pane about to take input. Unlike isKnownReadyPromptPreview - * (agent presence), Codex's provisional startup header does not count: 0.157 discards input typed - * behind it while its daemon starts. + * (agent presence), nothing counts while a hold anchor shows (Codex's provisional startup header): + * 0.157 discards input typed behind it while its daemon starts. */ export function isKnownReadyPromptSettled(preview: string): boolean { const normalized = preview.toLowerCase() - return isReadyPromptSettled(normalized, findKnownReadyPromptIndex(normalized)) -} - -function isReadyPromptSettled(normalized: string, readyIndex: number | null): boolean { - return ( - isReadyPromptUnblocked(normalized, readyIndex) && !isCodexProvisionalStartupText(normalized) - ) + return isSettledAfter(normalized, findPromptAnchorIndexes(normalized).ready) } /** - * Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the live emulator's - * visible grid, or null when the runtime has no trustworthy one. + * Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the screen the agent's + * rules read, or null when the runtime has no trustworthy one. * - * Why not for a clocked Codex or screen-ruled pane: its header or composer is also painted - * mid-turn, so isQuietReadyScreenBody holds it to quiescence instead. - * Why a clockless pane keeps it: quiescence needs an output clock, which a restored pane lacks. + * Why the agent's own answer is final: a screen refusal must shut the shared text lane too. + * A strong rule held to quiet counts here only on a clockless pane, which cannot measure quiet; + * on a clocked one isQuietReadyScreenBody holds it to quiescence instead, and an agent whose + * own ready text is held to quiet takes no shared text either. */ export function isKnownReadyPromptBody( waitText: string, @@ -90,77 +69,50 @@ export function isKnownReadyPromptBody( if (agent === 'qoder') { return isQoderComposerReady(readScreenLines()) } - const screenVerdict = readScreenRuledVerdict(agent, readScreenLines) - if (screenVerdict !== null) { - return screenVerdict && !hasOutputClock - } - if (agent === 'codex' && hasOutputClock) { + // Why before the rules: such an agent settles only on the quiet lane while it has a clock. + if (hasOutputClock && holdsReadyTextToQuiet(agent)) { return false } - if (isKnownReadyPromptSettled(waitText)) { - return true + const ruled = evaluateAgentStateRules(agent, { + readScreenLines, + readText: () => waitText.toLowerCase(), + hasOutputClock + }) + if (ruled !== null) { + return isStrongIdle(ruled) && (!ruled.requiresQuiet || !hasOutputClock) } - // Why the agent gate: another agent's screen can merely mention "OpenAI Codex". - if (agent !== null && agent !== 'codex') { - return false - } - const screen = readScreen(readScreenLines) - return screen !== null && isCodexScreenHeaderReady(screen) + return isKnownReadyPromptSettled(waitText) } /** - * Tier 1b body evidence: a ready screen from an agent with no title rest signal. Unlike tier 1 - * it only proves the TUI is up, so the ranking holds it to quiescence. - * Why identified panes only: a `cat`ed transcript or pager in an unknown pane can show the composer. + * Tier 1b body evidence: a ready screen or text the agent also paints mid-turn, so the ranking + * holds it to quiescence. Why identified panes only for Muse: a `cat`ed transcript or pager in an + * unknown pane can show the composer. */ export function isQuietReadyScreenBody( waitText: string, agent: TuiAgent | null, readScreenLines: () => readonly string[] | null ): boolean { - if (agent === 'codex') { - const screen = readScreen(readScreenLines) - if ( - screen !== null && - (isCodexComposerReadyScreen(screen) || isCodexScreenHeaderReady(screen)) - ) { - return true - } - // Why the provisional veto here too: a daemon start can stay quiet past the quiescence window. - const normalized = waitText.toLowerCase() - return isReadyPromptSettled(normalized, findCodexReadyPromptIndex(normalized)) - } - if (readScreenRuledVerdict(agent, readScreenLines) === true) { - return true + if (hasQuietReadyRules(agent)) { + const ruled = evaluateAgentStateRules(agent, { + readScreenLines, + readText: () => waitText.toLowerCase() + }) + return ruled !== null && isStrongIdle(ruled) && ruled.requiresQuiet } return (agent === null || agent === 'muse') && isMuseReadyPromptPreview(waitText) } -/** - * Why the screen: Codex repaints its 0.150-0.157 header by cell diff (`ESC[5;3Hdir - * ESC[5;7Hctory:`), which only a grid reassembles — the line-folded wait text reads `dirctory:`. - * Why it can only add readiness: a grid out of step with the PTY (size mismatch, resize - * mid-paint) garbles the header, so the text rule keeps every verdict it gives on its own. - */ -function isCodexScreenHeaderReady(screen: string): boolean { - return isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen)) -} - -function readScreen(readScreenLines: () => readonly string[] | null): string | null { - return readScreenLines()?.join('\n').toLowerCase() ?? null -} - -function isReadyPromptUnblocked(normalized: string, readyIndex: number | null): boolean { - if (readyIndex === null) { - return false - } - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - return blockedSignal === null || blockedSignal.index <= readyIndex +function isStrongIdle( + verdict: AgentStateVerdict +): verdict is Extract { + return verdict.state === 'idle' && verdict.strength === 'strong' } export function isMuseReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() - return isReadyPromptUnblocked(normalized, findMuseReadyPromptIndex(normalized)) + return isUnblockedAfter(normalized, findMuseReadyPromptIndex(normalized)) } export function detectTerminalWaitBlockedReason( @@ -185,44 +137,11 @@ export function findActionableTerminalWaitBlockedSignal( } // Why: a live prompt (idle OR busy) proves the startup modal was dismissed, so a mid-run Cursor lane stops reporting stale trust hits. +// Why Muse beside the rule-file anchors: Muse has not moved to agent-state-rules/ yet. function findDismissedStartupModalIndex(normalized: string): number | null { - const indexes = [ - findCodexReadyPromptIndex(normalized), - findCodexHeaderIndex(normalized), - findAntigravityReadyPromptIndex(normalized), - findCursorActivePromptIndex(normalized), - findMuseReadyPromptIndex(normalized) - ].filter((index): index is number => index !== null) - return indexes.length > 0 ? Math.max(...indexes) : null -} - -function findKnownReadyPromptIndex(normalized: string): number | null { - const indexes = [ - findCodexReadyPromptIndex(normalized), - findAntigravityReadyPromptIndex(normalized), - findCursorReadyPromptIndex(normalized) - ].filter((index): index is number => index !== null) - return indexes.length > 0 ? Math.max(...indexes) : null -} - -// Why: match the banner's last occurrence to skip the trust dialog's own "Cursor Agent" text; "→" is cursor-agent's persistent input prompt. -function findCursorActivePromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('cursor agent') - if (headerIndex === -1) { - return null - } - return normalized.includes('→', headerIndex) ? headerIndex : null -} - -// Why: cursor-agent emits no idle OSC title; infer idle from the tail (braille spinner = busy, its absence = idle). -const CURSOR_BUSY_SPINNER_RE = /[⠁-⣿]/ - -function findCursorReadyPromptIndex(normalized: string): number | null { - const activeIndex = findCursorActivePromptIndex(normalized) - if (activeIndex === null) { - return null - } - return CURSOR_BUSY_SPINNER_RE.test(normalized.slice(activeIndex)) ? null : activeIndex + const live = findPromptAnchorIndexes(normalized).live + const muse = findMuseReadyPromptIndex(normalized) + return live === null || muse === null ? (live ?? muse) : Math.max(live, muse) } // Why: Muse titles its OSC with the bare cwd and never updates it, so only the body can @@ -237,114 +156,3 @@ function findMuseReadyPromptIndex(normalized: string): number | null { ? headerIndex : null } - -function findCodexReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('openai codex') - if (headerIndex === -1) { - return null - } - const readySegment = normalized.slice(headerIndex) - // Why: Codex prints permissions only in YOLO mode; the stable ready header is OpenAI Codex + model + directory. - return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null -} - -export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = - /update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i - -// Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the -// retained tail; only a dialog owning the screen bottom is live. Real Codex dialogs (trust, hooks review, -// update, exec approval) are 4-8 lines; the slack covers a wrapped command or a longer hook list. -const LIVE_PROMPT_TAIL_LINES = 12 - -function findTerminalWaitBlockedSignal( - fullTail: string -): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - const windowStart = startOfLastNonBlankLines(fullTail, LIVE_PROMPT_TAIL_LINES) - const normalized = windowStart === 0 ? fullTail : fullTail.slice(windowStart) - // Why: one combined negative scan avoids a dozen searches when no prompt can match. - if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { - return null - } - const signal = findBlockedSignalInLiveWindow(normalized) - // Why: callers compare this index against ready-header indexes found over the full tail. - return signal === null ? null : { reason: signal.reason, index: signal.index + windowStart } -} - -function findBlockedSignalInLiveWindow( - normalized: string -): { reason: RuntimeTerminalWaitBlockedReason; index: number } | null { - const candidates = findStartupDialogBlockedSignals(normalized) - const trustIndex = Math.max( - normalized.lastIndexOf('do you trust'), - normalized.lastIndexOf('trust this'), - normalized.lastIndexOf('trusted workspace') - ) - const trustSegment = trustIndex === -1 ? '' : normalized.slice(trustIndex) - if ( - trustIndex !== -1 && - (trustSegment.includes('workspace') || - trustSegment.includes('folder') || - trustSegment.includes('directory') || - trustSegment.includes('repo')) - ) { - // Why neutral: this matcher never inspects the agent -- every TUI agent ships a workspace-trust dialog. - candidates.push({ reason: 'agent-trust-workspace', index: trustIndex }) - } - const interactivePromptIndex = Math.max( - normalized.lastIndexOf('press enter to confirm'), - normalized.lastIndexOf('press enter to continue'), - normalized.lastIndexOf('press enter to view'), - normalized.lastIndexOf('press enter to insert'), - normalized.lastIndexOf('press t to trust') - ) - const interactivePromptContext = - interactivePromptIndex === -1 - ? '' - : normalized.slice(Math.max(0, interactivePromptIndex - 600), interactivePromptIndex + 200) - // Why 'codex' only widens detection and never names the reason: the sole Codex evidence here is - // that word somewhere in 600 chars of scrollback, which an agent narrating about Codex satisfies - // on any pane -- enough to suspect a dialog, not enough to label a non-Codex user's pane. - const hasInteractiveDialogContext = - interactivePromptContext.includes('codex') || - interactivePromptContext.includes('permission') || - interactivePromptContext.includes('sandbox') || - interactivePromptContext.includes('trust') || - interactivePromptContext.includes('hook') - if (interactivePromptIndex !== -1 && hasInteractiveDialogContext) { - const contextStart = Math.max(0, interactivePromptIndex - 600) - const hasSpecificPromptInContext = candidates.some( - (candidate) => candidate.index >= contextStart && candidate.index <= interactivePromptIndex - ) - if (!hasSpecificPromptInContext) { - candidates.push({ reason: 'agent-interactive-prompt', index: interactivePromptIndex }) - } - } - const cursorApprovalIndex = findCursorApprovalPromptIndex(normalized) - if (cursorApprovalIndex !== null) { - candidates.push({ reason: 'agent-approval-prompt', index: cursorApprovalIndex }) - } - const permissionPromptIndex = Math.max( - normalized.lastIndexOf('permission required'), - normalized.lastIndexOf('requires permission') - ) - if (permissionPromptIndex !== -1) { - const permissionSegment = normalized.slice(permissionPromptIndex, permissionPromptIndex + 1_500) - const decisionCount = ['allow once', 'allow always', 'reject', 'deny'].filter((choice) => - permissionSegment.includes(choice) - ).length - if (decisionCount >= 2) { - // Why neutral: an approval dialog with named choices identifies no agent; older hosts publish - // 'codex-interactive-prompt' here and clients alias the two. Rule 1 additive member -- - // remote-wire-compatibility.md names RuntimeTerminalWaitBlockedReason as Rule 1 because no - // consumer switches exhaustively on it. - // Why alias rather than drop the old spelling: preserve the existing remote receipt value for - // mixed-version clients -- an older host still publishes codex-* on this path. - candidates.push({ reason: 'agent-interactive-prompt', index: permissionPromptIndex }) - } - } - return candidates.length > 0 - ? candidates.reduce((latest, candidate) => - candidate.index > latest.index ? candidate : latest - ) - : null -} diff --git a/src/main/runtime/terminal-wait-tail-state.ts b/src/main/runtime/terminal-wait-tail-state.ts index 8d0ce1f2e88..485451abf0c 100644 --- a/src/main/runtime/terminal-wait-tail-state.ts +++ b/src/main/runtime/terminal-wait-tail-state.ts @@ -1,10 +1,8 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import { buildTailLines } from './terminal-tail-state' import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' -import { - findActionableTerminalWaitBlockedSignal, - TERMINAL_WAIT_BLOCKED_SENTINEL_RE -} from './terminal-wait-detection' +import { findActionableTerminalWaitBlockedSignal } from './terminal-wait-detection' +import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' export function buildTerminalWaitText( lines: string[], diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index c97949607f7..43c9789af12 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -4,7 +4,10 @@ import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-tit import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { getTuiAgentRestSignal } from '../../shared/tui-agent-rest-signal' import { isKnownReadyPromptBody } from './terminal-wait-detection' -import { getScreenReadyRule, readScreenRuledVerdict } from './screen-ruled-agent-readiness' +import { + evaluateAgentStateRules, + readsTrustedScreen +} from './agent-state-rules/agent-state-rules-engine' import { evaluateTuiIdle, hasFreshDoneFirstPartyStatus, @@ -32,7 +35,7 @@ function input(overrides: Partial = {}): TuiIdleEvaluati readTailBlockedReason: () => null, readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => true, - readScreenDecidesReadiness: () => false, + readAgentRuleVerdict: () => null, agent: 'muse', firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS, @@ -209,17 +212,18 @@ describe('rest signal agrees with the lanes that can settle a wait', () => { ) const quietScreenBody = hasQuietReadyScreen(record(), agent, () => true, QUIESCENCE_MS) // Why a screen-ruled `none` is sound: its screen shuts the quiet lane whenever one is readable. - const rule = getScreenReadyRule(agent) - if (rule !== null) { + if (readsTrustedScreen(agent)) { const refused = ['> not an idle composer'] - expect(rule(refused)).toBe(false) + const ruled = (screen: readonly string[] | null) => + evaluateAgentStateRules(agent, { readScreenLines: () => screen }) + expect(ruled(refused)?.state).toBe('hold') const verdict = (screen: readonly string[] | null) => evaluateTuiIdle( input({ agent, record: record({ lastOscTitle: null }), readQuietReadyBodyEvidence: () => false, - readScreenDecidesReadiness: () => readScreenRuledVerdict(agent, () => screen) !== null + readAgentRuleVerdict: () => ruled(screen) }) ) expect(verdict(refused)).toEqual({ kind: 'pending', quietForeground: 'closed' }) @@ -270,7 +274,7 @@ describe('a DSH pane settles tui-idle on its own hook', () => { rendererTitle: undefined, readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => false, - readScreenDecidesReadiness: () => false, + readAgentRuleVerdict: () => null, readTailBlockedReason: () => null, agent: 'dsh' as const, firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() }, diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 599e478b3e0..72af78bb39e 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -16,7 +16,13 @@ import { isKnownReadyPromptBody, isQuietReadyScreenBody } from './terminal-wait-detection' -import { getScreenReadyRule, readScreenRuledVerdict } from './screen-ruled-agent-readiness' +import { + evaluateAgentStateRules, + hasQuietReadyRules, + idleTitleRequiresQuiet, + readsTrustedScreen, + type AgentStateVerdict +} from './agent-state-rules/agent-state-rules-engine' /** * Ranking the evidence that a `tui-idle` wait may settle on. @@ -30,9 +36,9 @@ import { getScreenReadyRule, readScreenRuledVerdict } from './screen-ruled-agent * 0. BLOCKED — the tail shows a prompt waiting on the user. * 1. STRONG READY — the agent states it is ready: an explicit idle marker in its own * title, or a known ready-prompt body. - * 1b. QUIET READY SCREEN — Muse and an idle Codex title no rest signal, and the screen-ruled - * agents (Antigravity, Cline, Prime Agent) can paint their idle composer mid-turn, so their - * ready-screen body is believed only once quiet. + * 1b. QUIET READY SCREEN — Muse titles no rest signal, and agents whose rules + * (agent-state-rules/) read a ready screen or text they also paint mid-turn (Codex's + * header and composer), so that body is believed only once quiet. * 2. WORKING — a fresh first-party agent status (OSC 9999) saying working/blocked/ * waiting, or a working title. The agent's own account of itself outranks anything * inferred. @@ -116,20 +122,16 @@ export function hasFreshWorkingFirstPartyStatus(status: FirstPartyAgentStatus): return isFreshNonDoneAgentStatus(status ?? undefined) } -/** Agents that paint their own explicit rest title (Claude's `✳`). Gemini's `◇` would qualify - * but is not yet held to it. */ -const NATIVE_EXPLICIT_IDLE_TITLE_AGENTS: ReadonlySet = new Set(['claude']) - /** * Whether a name-only title from `agent` must be corroborated by a quiet stream. * - * Only for agents that announce rest with an explicit title of their own: the hook-driven - * `Codex ready` / `Devin ready`, or Claude's `✳`. For them the bare name is not a rest signal — - * a shell auto-title (`claude`, `claude ~/repo`) names the agent from the moment it starts, over - * a start-up dialog or a busy turn alike. Grok, Copilot, Aider, Mimo, agy and OpenCode emit - * their NAME and nothing more at rest, so holding them to it leaves no settle signal at all: - * a real idle Grok pane repaints its banner about four times a second forever, so the stream - * never quiesces and the wait runs to timeout. + * An agent's own idle-title rule (agent-state-rules/) answers. Without one, only agents whose + * hooks drive an explicit ` ready` title are held to it: for them the bare name is not a + * rest signal, since a shell auto-title names the agent from the moment it starts, over a + * start-up dialog or a busy turn alike. Grok, Copilot, Aider, Mimo and agy emit their NAME and + * nothing more at rest, so holding them to it leaves no settle signal at all: a real idle Grok + * pane repaints its banner about four times a second forever, so the stream never quiesces and + * the wait runs to timeout. */ export function nameOnlyIdleNeedsCorroboration( agent: TuiAgent | null | undefined, @@ -138,10 +140,11 @@ export function nameOnlyIdleNeedsCorroboration( // Why the title fallback: an adopted pane carries no launch metadata, but its // name-only title is exactly the thing that names the agent. const resolved = agent ?? (title ? resolveExplicitTerminalTitleAgentType(title) : null) + if (resolved === null) { + return false + } return ( - resolved !== null && - (NATIVE_EXPLICIT_IDLE_TITLE_AGENTS.has(resolved) || - getSyntheticAgentTerminalTitle(resolved, 'done') !== null) + idleTitleRequiresQuiet(resolved) ?? getSyntheticAgentTerminalTitle(resolved, 'done') !== null ) } @@ -165,10 +168,12 @@ export function hasSustainedTitleIdle( // no local output clock, so for an agent that WILL announce rest explicitly there is no // corroboration available at all. Settling here let a busy Codex/Devin satisfy the wait // from a name-only title (#6011); hold out for tier 1/2 or the caller's timeout instead. - if (record.lastOutputAt === null) { - return false - } - return Date.now() - record.lastOutputAt >= quiescenceMs + return hasQuietOutput(record, quiescenceMs) +} + +/** Why a missing clock is not quiet: an adopted or restored pane cannot measure it. */ +function hasQuietOutput(record: TuiIdleEvidenceRecord, quiescenceMs: number): boolean { + return record.lastOutputAt !== null && Date.now() - record.lastOutputAt >= quiescenceMs } /** @@ -201,10 +206,10 @@ export type TuiIdleEvaluationInput = { * (~11us and a multi-KB string on a full tail); the title check below usually answers * first, and then none of that has to happen at all. */ readPositiveBodyEvidence: () => boolean - /** Tier 1b body evidence: a Muse, Codex or screen-ruled ready screen. Thunk, as above. */ + /** Tier 1b body evidence: a Muse ready screen, or a rule-file one held to quiet. Thunk, as above. */ readQuietReadyBodyEvidence: () => boolean - /** Whether the agent's live screen already ruled on readiness, which shuts the weak lanes. */ - readScreenDecidesReadiness: () => boolean + /** The agent's own rules' answer; any answer shuts the lanes that cannot see its screen. */ + readAgentRuleVerdict: () => AgentStateVerdict | null agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus quiescenceMs: number @@ -226,8 +231,6 @@ const READY_STRONG: TuiIdleVerdict = { kind: 'ready-strong' } const READY_WEAK: TuiIdleVerdict = { kind: 'ready-weak' } const WORKING: TuiIdleVerdict = { kind: 'working' } -const QUIET_READY_SCREEN_AGENTS: ReadonlySet = new Set(['muse', 'codex']) - /** * Tier 1b: a ready screen in the body, believed only once the stream has gone quiet. * @@ -235,9 +238,9 @@ const QUIET_READY_SCREEN_AGENTS: ReadonlySet = new Set(['muse', 'codex * the cwd (plus a thread name) and no agent name, so neither the explicit-idle nor the * sustained-title lane can fire. The ready screen proves the TUI is up; the quiescence * demand keeps a mid-turn streaming pane from satisfying, mirroring the tier-3 lane's - * positive-evidence-plus-quiet shape. Scoped to those agents, the screen-ruled ones, and - * agent-unknown panes (which read only Muse's screen): another agent's scrollback quoting them - * must not settle its wait. + * positive-evidence-plus-quiet shape. Scoped to Muse, agents with quiet ready rules, and + * agent-unknown panes (which read only Muse's screen here): another agent's scrollback quoting + * them must not settle its wait. */ export function hasQuietReadyScreen( record: TuiIdleEvidenceRecord, @@ -245,12 +248,12 @@ export function hasQuietReadyScreen( readBodyEvidence: () => boolean, quiescenceMs: number ): boolean { - if (agent && !QUIET_READY_SCREEN_AGENTS.has(agent) && !getScreenReadyRule(agent)) { + if (agent && agent !== 'muse' && !hasQuietReadyRules(agent)) { return false } // Why: same rule as the tier-3 lane — without an output clock there is no // corroboration available, so hold out instead of settling. - if (record.lastOutputAt === null || Date.now() - record.lastOutputAt < quiescenceMs) { + if (!hasQuietOutput(record, quiescenceMs)) { return false } // Why last: a streaming pane never pays for the screen projection. @@ -308,9 +311,13 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { if (input.record.lastAgentStatus === 'working') { return WORKING } - // Why: a name-only title and a quiet process cannot see the picker or prompt the screen refused. - if (input.readScreenDecidesReadiness()) { - return { kind: 'pending', quietForeground: 'closed' } + // Why here: a name-only title and a quiet process cannot see the picker or prompt the screen + // refused, and an agent's own idle-title rule replaces the sustained-title lane below. + const ruled = input.readAgentRuleVerdict() + if (ruled !== null) { + return isSettledWeakIdle(ruled, input.record, input.quiescenceMs) + ? READY_WEAK + : { kind: 'pending', quietForeground: 'closed' } } if (hasSustainedTitleIdle(input.record, input.agent, input.quiescenceMs, input.launchReadiness)) { return READY_WEAK @@ -322,6 +329,18 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { } } +function isSettledWeakIdle( + verdict: AgentStateVerdict, + record: TuiIdleEvidenceRecord, + quiescenceMs: number +): boolean { + return ( + verdict.state === 'idle' && + verdict.strength === 'weak' && + (!verdict.requiresQuiet || hasQuietOutput(record, quiescenceMs)) + ) +} + export function isTuiIdleReadyVerdict(verdict: TuiIdleVerdict): boolean { return verdict.kind === 'ready-strong' || verdict.kind === 'ready-weak' } @@ -334,20 +353,37 @@ export type TuiIdleEvidenceSource = { getPaneAgent(ptyId: string | null | undefined): TuiAgent | null getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus readScreenLines(ptyId: string | null | undefined): readonly string[] | null - /** The painted rows on the PTY's own grid, which only screen-ruled agents read. Absent, they - * have no trustworthy screen. */ + /** The painted rows on the PTY's own grid, which only agents whose rules read the trusted + * screen use. Absent, they have no trustworthy screen. */ readScreenRuledLines?(ptyId: string | null | undefined): readonly string[] | null } -// Why per agent table: every other agent keeps the screen it read before screen rules existed. +// Why per agent: every other agent keeps the live screen its rules were recorded against. +// Why read once: several lanes consult the rules, and one evaluation sees one screen. function screenReader( source: TuiIdleEvidenceSource, agent: TuiAgent | null, ptyId: string | null | undefined ): () => readonly string[] | null { - return getScreenReadyRule(agent) + let lines: readonly string[] | null | undefined + const read = readsTrustedScreen(agent) ? () => source.readScreenRuledLines?.(ptyId) ?? null : () => source.readScreenLines(ptyId) + return () => (lines === undefined ? (lines = read()) : lines) +} + +function readAgentRuleVerdict( + agent: TuiAgent | null, + record: TuiIdleEvidenceRecord, + readScreenLines: () => readonly string[] | null, + waitText: () => string +): AgentStateVerdict | null { + return evaluateAgentStateRules(agent, { + readScreenLines, + readText: () => waitText().toLowerCase(), + readTitleStatus: () => record.lastAgentStatus, + hasOutputClock: record.lastOutputAt !== null + }) } function lazyWaitText(readWaitText: () => string): () => string { @@ -370,7 +406,7 @@ export function leafTuiIdleEvidence( readPositiveBodyEvidence: () => isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readScreenDecidesReadiness: () => readScreenRuledVerdict(agent, readScreen) !== null, + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText), agent, firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), quiescenceMs: source.quiescenceMs @@ -392,7 +428,7 @@ export function ptyTuiIdleEvidence( (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readScreenDecidesReadiness: () => readScreenRuledVerdict(agent, readScreen) !== null, + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), agent, firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), quiescenceMs: source.quiescenceMs diff --git a/src/main/ssh/orcad-runtime-decommission.ts b/src/main/ssh/orcad-runtime-decommission.ts index ffb8d36954c..4eb9f335fa0 100644 --- a/src/main/ssh/orcad-runtime-decommission.ts +++ b/src/main/ssh/orcad-runtime-decommission.ts @@ -7,7 +7,10 @@ import type { OrcadManagedStopResult } from '../../shared/orcad-managed-runtime' import { removeManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import type { KnownRuntimeEnvironment, OrcadDeploymentLink } from '../../shared/runtime-environments' +import type { + KnownRuntimeEnvironment, + OrcadDeploymentLink +} from '../../shared/runtime-environments' import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' import { withStaleOrcadActivationRecoveryLock } from './orcad-activation-lock' import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts index 5ed3a8e16f5..712d0d67ae0 100644 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ b/src/main/ssh/orcad-runtime-maintenance.ts @@ -72,106 +72,114 @@ export function updateManagedOrcadEnvironment( userDataPath: string, args: LifecycleArgs & { force?: boolean } ): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const census = await collectManagedTerminalCensus( - userDataPath, - environment, - context.activationRecord - ) - const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { - signal: args.signal - }) - const result = await deployOrcad({ - ...managedOrcadSlot(context, deployment.remotePort, args.signal), - localOrcadDir, - target: context.serverTarget, - census, - force: args.force - }) - if (result.outcome === 'installed-not-activated') { - const deferral = { - outcome: 'deferred' as const, - candidateVersion: result.fullVersion, - code: result.code, - reason: result.reason, - forceable: isForceableOrcadDeferral(result.code) + return withManagedOrcadLifecycle( + userDataPath, + args.selector, + async ({ environment, deployment }) => { + const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) + const census = await collectManagedTerminalCensus( + userDataPath, + environment, + context.activationRecord + ) + const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { + signal: args.signal + }) + const result = await deployOrcad({ + ...managedOrcadSlot(context, deployment.remotePort, args.signal), + localOrcadDir, + target: context.serverTarget, + census, + force: args.force + }) + if (result.outcome === 'installed-not-activated') { + const deferral = { + outcome: 'deferred' as const, + candidateVersion: result.fullVersion, + code: result.code, + reason: result.reason, + forceable: isForceableOrcadDeferral(result.code) + } + recordManagedOrcadUpdateDeferral(environment.id, deferral) + return deferral + } + clearManagedOrcadUpdateDeferral(environment.id) + const readiness = await probeManagedOrcadReadiness( + context, + localOrcadDir, + result.fullVersion, + args.signal + ) + const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) + return { + outcome: result.outcome === 'already-active' ? 'already-current' : 'updated', + environment: redactRuntimeEnvironment(updated), + activeVersion: result.fullVersion } - recordManagedOrcadUpdateDeferral(environment.id, deferral) - return deferral } - clearManagedOrcadUpdateDeferral(environment.id) - const readiness = await probeManagedOrcadReadiness( - context, - localOrcadDir, - result.fullVersion, - args.signal - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: result.outcome === 'already-active' ? 'already-current' : 'updated', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.fullVersion - } - }) + ) } export function rollbackManagedOrcadEnvironment( userDataPath: string, args: LifecycleArgs ): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const record = context.activationRecord - const target = record.previous - if (!target) { + return withManagedOrcadLifecycle( + userDataPath, + args.selector, + async ({ environment, deployment }) => { + const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) + const record = context.activationRecord + const target = record.previous + if (!target) { + return { + outcome: 'refused', + code: 'orcad_rollback_no_target', + reason: 'This server has no previous version to roll back to.' + } + } + const census = await collectManagedTerminalCensus(userDataPath, environment, record) + // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show + // that build would reach terminals that are still running. + if (census.liveSessions !== 0) { + return { + outcome: 'refused', + code: + census.liveSessions === null + ? 'orcad_rollback_census_unavailable' + : 'orcad_rollback_terminals_running', + reason: + census.liveSessions === null + ? 'The server did not answer how many terminals it runs. Retry when it answers.' + : 'Close the terminals running on this server before rolling it back.' + } + } + const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) + const targetDir = managedOrcadInstallDir(context, target) + const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir) + const result = await rollbackOrcad({ + ...slot, + record, + census, + targetBuildHash, + targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL + }) + if (result.outcome !== 'rolled-back') { + return result + } + const readiness = await probeActiveOrcadReadiness( + { ...slot, remoteInstallDir: targetDir }, + { buildHash: targetBuildHash, fullVersion: result.target } + ) + const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) return { - outcome: 'refused', - code: 'orcad_rollback_no_target', - reason: 'This server has no previous version to roll back to.' + outcome: 'rolled-back', + environment: redactRuntimeEnvironment(updated), + activeVersion: result.target, + discarded: result.discarded } } - const census = await collectManagedTerminalCensus(userDataPath, environment, record) - // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show - // that build would reach terminals that are still running. - if (census.liveSessions !== 0) { - return { - outcome: 'refused', - code: - census.liveSessions === null - ? 'orcad_rollback_census_unavailable' - : 'orcad_rollback_terminals_running', - reason: - census.liveSessions === null - ? 'The server did not answer how many terminals it runs. Retry when it answers.' - : 'Close the terminals running on this server before rolling it back.' - } - } - const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) - const targetDir = managedOrcadInstallDir(context, target) - const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir) - const result = await rollbackOrcad({ - ...slot, - record, - census, - targetBuildHash, - targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL - }) - if (result.outcome !== 'rolled-back') { - return result - } - const readiness = await probeActiveOrcadReadiness( - { ...slot, remoteInstallDir: targetDir }, - { buildHash: targetBuildHash, fullVersion: result.target } - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: 'rolled-back', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.target, - discarded: result.discarded - } - }) + ) } /** Finishes or undoes an interrupted activation, rollback or decommission on the host. */ @@ -179,25 +187,29 @@ export function recoverManagedOrcadEnvironment( userDataPath: string, args: LifecycleArgs ): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const result = await recoverInterruptedOrcadActivation( - managedOrcadSlot(context, deployment.remotePort, args.signal) - ) - if (result.outcome !== 'recovered') { - return result + return withManagedOrcadLifecycle( + userDataPath, + args.selector, + async ({ environment, deployment }) => { + const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) + const result = await recoverInterruptedOrcadActivation( + managedOrcadSlot(context, deployment.remotePort, args.signal) + ) + if (result.outcome !== 'recovered') { + return result + } + const updated = result.readiness + ? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort) + : environment + if (result.activeVersion) { + await ensureOrcadManagedTunnel(userDataPath, environment.id) + } + return { + outcome: 'recovered', + resolution: result.resolution, + activeVersion: result.activeVersion, + environment: redactRuntimeEnvironment(updated) + } } - const updated = result.readiness - ? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort) - : environment - if (result.activeVersion) { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } - return { - outcome: 'recovered', - resolution: result.resolution, - activeVersion: result.activeVersion, - environment: redactRuntimeEnvironment(updated) - } - }) + ) } diff --git a/src/main/ssh/orcad-terminal-census-client.ts b/src/main/ssh/orcad-terminal-census-client.ts index 244589e9827..d039fa8e120 100644 --- a/src/main/ssh/orcad-terminal-census-client.ts +++ b/src/main/ssh/orcad-terminal-census-client.ts @@ -2,10 +2,8 @@ * Asks a managed orcad, through its tunnel, how many terminals its daemon runs. Every failure, * including an older host without the method, reads as an unverifiable census, never as zero. */ -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY -} from '../../shared/protocol-version' +import { ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { getPreferredPairingOffer, type KnownRuntimeEnvironment diff --git a/src/main/ssh/runtime-ssh-access-verification.test.ts b/src/main/ssh/runtime-ssh-access-verification.test.ts index c99a807dbad..d847b801a23 100644 --- a/src/main/ssh/runtime-ssh-access-verification.test.ts +++ b/src/main/ssh/runtime-ssh-access-verification.test.ts @@ -1,9 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createEnvironmentFromPairingOffer } from '../../shared/runtime-environments' -import { - ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - RUNTIME_PROTOCOL_VERSION -} from '../../shared/protocol-version' +import { RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' +import { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES } from '../../shared/electron-remote-runtime-client-capabilities' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { verifyRuntimeEnvironmentSshTunnel } from './runtime-ssh-access-verification' diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index 6c71a126ecd..8e2ca1e4abb 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -35,14 +35,6 @@ function shouldFocusEmptyEditorFromSurfaceClick( return !target.closest('.rich-markdown-editor-shell button, .rich-markdown-editor-shell input') } -function shouldReturnFocusToEditor(event: React.MouseEvent): boolean { - if (event.button !== 0) { - return false - } - const target = event.target - return target instanceof Element && Boolean(target.closest('.ProseMirror')) -} - type RichMarkdownEditorSurfaceProps = { editor: Editor | null editorFontZoomLevel: number @@ -217,11 +209,6 @@ export function RichMarkdownEditorSurface({ // Image layout must not anchor-scroll over the restored tab position. className="relative h-full overflow-auto scrollbar-editor [overflow-anchor:none]" onMouseDown={(event) => { - if (shouldReturnFocusToEditor(event)) { - // Keep the find bar open while handing keyboard focus back to the document. - searchState.searchInputRef.current?.blur() - editor?.commands.focus() - } if (!shouldFocusEmptyEditorFromSurfaceClick(event, editor)) { return } diff --git a/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts index 8126d33d601..a836372642c 100644 --- a/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts @@ -9,6 +9,7 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // Pin the last pre-v41 implementation: this contract specifically exercises status-only readers. const PRE_V41 = 'aac38d698ff75ac4c8658addab48ef5a83617619' +// Why 120 s: a cold release checkout materializes in the test body, and its 45 s extraction deadline alone exceeds the 30 s default. test('pre-v41 code opens, acknowledges and writes a current-schema database, then current code reopens it', async () => { const checkout = await materializeReleaseCheckout(PRE_V41) const baseline = await importReleaseCheckoutModule( @@ -67,4 +68,4 @@ test('pre-v41 code opens, acknowledges and writes a current-schema database, the db?.close() rmSync(directory, { recursive: true, force: true }) } -}) +}, 120_000)