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] 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