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