Merge remote-tracking branch 'origin/main' into brennanb2025/agent-launch-host-prompt-delivery

This commit is contained in:
Brennan Benson
2026-10-01 22:17:27 -07:00
83 changed files with 3427 additions and 843 deletions
@@ -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 }}
@@ -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<<ORCA_NATIVE_BUILD_DIRECTORIES
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
ORCA_NATIVE_BUILD_DIRECTORIES
ORCA_NATIVE_CACHE_PATHS
- name: Restore compiled native modules
id: native-cache-restore
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
uses: actions/cache@v5
with:
# Post-job saving cannot resolve nested composite step outputs.
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: ${{ steps.native-cache-scope.outputs.key }}
- 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: ${{ steps.native-cache-scope.outputs.key }}
# 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
if: inputs.native-runtime != 'none'
shell: bash
env:
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
+13
View File
@@ -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/**'
+2
View File
@@ -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:
+9 -29
View File
@@ -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:
@@ -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)
+248
View File
@@ -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<<ORCA_NATIVE_BUILD_DIRECTORIES\n([\s\S]+?)\nORCA_NATIVE_BUILD_DIRECTORIES/.exec(
result
)?.[1]
}
} finally {
rmSync(directory, { recursive: true, force: true })
}
}
describe('CI native cache ownership', () => {
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)
}
}
)
})
+2 -2
View File
@@ -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', () => {
@@ -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([])
})
})
+4 -1
View File
@@ -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()) {
@@ -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'
])
@@ -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/',
@@ -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) => {
@@ -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/',
@@ -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',
+36 -2
View File
@@ -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 = [
+23 -1
View File
@@ -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'
+32 -26
View File
@@ -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(
@@ -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')
@@ -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.
+135
View File
@@ -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.
@@ -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
@@ -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<string, unknown> {
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<string, unknown>[]) {
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([])
})
})
@@ -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<string, unknown>
): 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
}
@@ -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, unknown>): 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,
@@ -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<ReturnType<Terminal['buffer']['active']['getLine']>>
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()
}
})
})
@@ -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<Buffer['getNullCell']>
): 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<Buffer['getNullCell']>
): 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()
@@ -23,7 +23,10 @@ export function registerOrcadRuntimeMaintenanceHandlers(options: {
}): void {
ipcMain.handle(
'runtimeEnvironments:updateOrcad',
async (_event, args: { selector: string; force?: boolean }): Promise<OrcadManagedDeployResult> => {
async (
_event,
args: { selector: string; force?: boolean }
): Promise<OrcadManagedDeployResult> => {
const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), {
selector: requiredString(args?.selector, 'Server'),
force: args?.force === true
@@ -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
@@ -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<typeof setStructuredAgentSessionTabVisibility>[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<void> => {
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)
}
}
}
}
@@ -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<void> => {
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<void> => this.restore.reconcileRestartLeases()
restoreReadableSessions = (sessionIds?: readonly string[]): Promise<void> =>
@@ -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()
@@ -0,0 +1,63 @@
import type { ScreenCondition, TextTest } from './agent-state-rules-schema'
export type TextMatcher = (text: string) => boolean
type TextTerm = Extract<TextTest, { regex: string } | { contains: string }>
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)
}
}
@@ -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</.test(pattern)) {
return 'uses a backreference'
}
if (/\(\?<[=!]/.test(pattern)) {
return 'uses a lookbehind'
}
return repeatsAVariableGroup(pattern)
? 'repeats a group that can match in more than one way'
: null
}
function isRepeatingQuantifier(char: string | undefined): boolean {
return char === '*' || char === '+' || char === '{'
}
// A repeated group whose body varies, by a quantifier or an alternation: `(a+)*`, `(a?)+`,
// `(a|aa)+`. Such a body can split one input many ways, each of which a failed match retries.
function repeatsAVariableGroup(pattern: string): boolean {
const groupVaries: boolean[] = []
let inClass = false
for (let index = 0; index < pattern.length; index += 1) {
const char = pattern[index]
if (char === '\\') {
index += 1
} else if (inClass) {
inClass = char !== ']'
} else if (char === '[') {
inClass = true
} else if (char === '(') {
groupVaries.push(false)
// Why skip: the `?` of `(?:`, `(?=` or `(?<name>` 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
}
@@ -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<string, unknown>, answer: Record<string, unknown>) {
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<string, unknown> = {}): Record<string, unknown> {
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)
})
})
@@ -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<AgentStateRulesFile['id']>()
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
])
@@ -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<string, unknown>, answer: Record<string, unknown>) {
return { id: 'anchor', why: 'test', when, answer }
}
type RuleFileOverrides = { rules?: unknown[]; anchors?: unknown[] } & Record<string, unknown>
function ruleFile(overrides: RuleFileOverrides = {}): Record<string, unknown> {
return {
id: 'cline',
engineVersion: 1,
profile: { screenSource: 'trusted' },
anchors: [],
rules: [],
...overrides
}
}
const SCREEN = { region: 'screen' }
function idleRule(id: string, priority: number, rows: unknown[]): Record<string, unknown> {
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<string, unknown>) => [
{ ...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'
)
})
})
@@ -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> = () => T | null
type RegionReads = {
screen: RegionReader<readonly string[]>
/** The screen joined and lowercased, for named screen predicates. */
screenText: RegionReader<string>
text: RegionReader<string>
title: RegionReader<AgentStatus>
}
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<NamedScreenPredicate, (screen: string) => boolean> = {
'codex-header-ready': isCodexHeaderReadyScreen,
'codex-composer-ready': isCodexComposerReadyScreen
}
function readThen<T>(read: RegionReader<T>, 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<RulesKey, CompiledFile> = 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<T>(read: (() => T | null) | undefined): RegionReader<T> {
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
}
@@ -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 (`<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<TuiAgent>(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<typeof TextTestSchema>
export type ScreenCondition = z.infer<typeof ScreenConditionSchema>
export type AgentStateRuleCondition = z.infer<typeof RuleConditionSchema>
export type AgentStateRuleAnswer = z.infer<typeof RuleAnswerSchema>
export type Anchor = z.infer<typeof AnchorSchema>
export type TextAnchorCondition = z.infer<typeof TextAnchorConditionSchema>
export type TitleAnchorCondition = z.infer<typeof TitleAnchorConditionSchema>
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<typeof AgentStateRulesFileSchema>
@@ -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)
})
})
@@ -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<NamedTextAnchor, (normalized: string) => 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<TextAnchorCondition['lines']>): 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))
}
@@ -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<NamedTitlePredicate, (title: string) => 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))
}
@@ -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')
}
@@ -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" }
}
]
}
@@ -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
}
@@ -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 }
}
]
}
@@ -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" }
}
]
}
@@ -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 {
@@ -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 }
}
]
}
@@ -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": []
}
@@ -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 }
}
]
}
@@ -0,0 +1,14 @@
{
"id": "omp",
"engineVersion": 1,
"anchors": [],
"rules": [
{
"id": "idle_title",
"why": "Its `π - <session>` 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 }
}
]
}
@@ -0,0 +1,21 @@
{
"id": "opencode",
"engineVersion": 1,
"anchors": [
{
"id": "native_title",
"why": "OpenCode's own session title (`OC | <session>`, 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 }
}
]
}
@@ -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 }
}
]
}
@@ -0,0 +1,21 @@
{
"id": "pi",
"engineVersion": 1,
"anchors": [
{
"id": "idle_title",
"why": "Pi, and OMP after it, title the pane `π - <session>` at rest (`π ⠋ <session>` 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 }
}
]
}
@@ -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" }
}
]
}
@@ -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 }
}
]
}
@@ -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
}
}
@@ -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.
@@ -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<string> = 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))
)
}
@@ -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<TuiIdleEvaluationInput, 'record' | 'readQuietReadyBodyEvidence'>
@@ -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))
)
}
@@ -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,
@@ -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
}
}
@@ -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] ?? '')
}
@@ -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(
+7 -7
View File
@@ -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)
)
}
@@ -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<Record<TuiAgent, ScreenReadyRule>> = {
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
}
@@ -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`)
@@ -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.
@@ -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<StructuredAgentSessionHost> {
claimKeyId: 'key-1',
resolveWorkspacePath: async () => stateDirectory,
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
resolveEnvironment: async () => ({})
resolveEnvironment: async () => ({}),
logger: createStructuredAgentSessionLogger()
})
}
@@ -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
@@ -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
+55 -247
View File
@@ -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 `<Agent> 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<AgentStateVerdict, { state: 'idle' }> {
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
}
+2 -4
View File
@@ -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[],
+11 -7
View File
@@ -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<TuiIdleEvaluationInput> = {}): 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() },
+77 -41
View File
@@ -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<TuiAgent> = 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 `<Agent> 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<TuiAgent> = 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<TuiAgent> = 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
+4 -1
View File
@@ -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'
+121 -109
View File
@@ -72,106 +72,114 @@ export function updateManagedOrcadEnvironment(
userDataPath: string,
args: LifecycleArgs & { force?: boolean }
): Promise<OrcadManagedDeployResult> {
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<OrcadManagedRollbackResult> {
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<OrcadManagedRecoveryResult> {
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)
}
})
)
}
+2 -4
View File
@@ -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
@@ -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'
@@ -35,14 +35,6 @@ function shouldFocusEmptyEditorFromSurfaceClick(
return !target.closest('.rich-markdown-editor-shell button, .rich-markdown-editor-shell input')
}
function shouldReturnFocusToEditor(event: React.MouseEvent<HTMLDivElement>): 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
}
@@ -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)