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