From 24b97e89090a0aa8ec28a9a44bf5ea6248965a97 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:52:27 -0400 Subject: [PATCH] refactor(runtime): read Codex, Claude, OpenCode, Pi, OMP and Gemini readiness from rule files (#24375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(runtime): add a readiness census pinning every tui-idle verdict Replays every recorded agent PTY transcript frame by frame through a real runtime pane (agent-known and agent-unknown, clocked and clockless) and a synthetic evidence matrix for all 43 TuiAgents, and compares each verdict and tui-idle wait outcome to committed run-length-encoded baselines. Refs STA-9098 * test(runtime): pin the census quiet probes to literal windows A census that read TUI_IDLE_QUIESCENCE_MS would move with it; fixed 2999/3000 ms reads and a fixed 2000 ms poll step make a changed window show as changed verdicts. Refs STA-9098 * test(runtime): say which census probe writes runtime state Refs STA-9098 * test(runtime): observe the census through settled panes and caller-visible waits - Read each verdict through the runtime's own settle seam (evaluateTuiIdleForLeaf) instead of re-wiring evaluateTuiIdle/leafTuiIdleEvidence/buildTerminalWaitText, so the census is coupled to one runtime method, not to the module STA-9098 rewrites. - Let the runtime finish each chunk (one macrotask turn) before reading. The old read raced work chained on the paint, so 14 frames pinned a microtask-ordering artefact. - Record when a wait settles (@start vs @poll), not just its outcome. - Exit each pane's PTY after reading it so its emulator is freed. - Replace the hand-grouped families, literal fixture list and per-pane split flag with a directory-scanned catalog, one baseline per replayed pane, and size-balanced shards. - Run the synthetic matrix in one file; it takes about 2 s. * test(runtime): cross dialog-versus-ready-screen order with every title in the census matrix Blocked detection is position-ordered (design doc 11.5): the later of a blocker and a ready anchor wins. The matrix now paints a workspace-trust dialog after, and before, each agent's ready screen under every title, so a rule engine that loses that ordering fails per agent. * test(runtime): read the census baseline field without Reflect.get The anti-slop lint rejects Reflect.get on parsed input. * refactor(runtime): read Antigravity, Cline, Prime Agent and Cursor readiness from rule files Adds agent-state-rules/: a zod-validated JSON file per agent, one priority list of screen rules per agent (idle with strength and requiresQuiet, or hold), and text anchors that feed the shared, position-ordered blocked layer every pane reads first. The three screen-ruled agents and Cursor's approval menu and prompt move to data; the Antigravity text scan stays code as a named anchor. Their old code paths are deleted. Every other agent still runs through the existing lanes, unchanged. The readiness census baselines are untouched and pass. Refs STA-9098 * test(runtime): cover the agent state rule engine's schema, priority, rows, anchors and lanes Refs STA-9098 * fix(runtime): refuse rule patterns that repeat an optional or alternating group The load-time regex check only flagged a repeated group whose body held * + or {, so (a?)* and (a|aa)+ passed though both backtrack exponentially. A repeated group's body must now be fixed: no quantifier of any kind and no alternation. The comment states the remaining polynomial gap instead of claiming linearity. * refactor(runtime): give agent state rules and text anchors one when/answer shape Every rule and text anchor is now when (a region and what it must show) plus answer, each a discriminated union, so part (b) adds title, text and status regions and working or blocked answers as new variants instead of new fields. - Cursor's prompt is two anchors answering working and idle; the one-off workingIfAfter and followedBy fields become a general after test. - Anchor literals and the probe banner must be lowercase, since they are matched against the lowercased tail. - screenProbeBanner moves under profile, the place for non-detection facts. - why is required on every rule and anchor. - A blocked anchor must name a lastOf literal, which the prefilter keys on. * docs: point the readiness evidence docs at the agent state rule files * refactor(runtime): read Codex, Claude, OpenCode, Pi, OMP and Gemini readiness from rule files The rule engine gains the regions and answers these agents need, as closed-list entries: - rule regions `title` (the classified title status) and `text` (one of the file's idle text anchors, settled), and a `predicate` form of the screen region for named engine scans; - `withoutClock: skip` for strong quiet rules a clockless pane must not believe; - anchors (renamed from textAnchors) gain a `title` region, and `live` and `hold` answers; - `profile.screenSource` (trusted grid or live screen), and an `unknown-pane` file for panes with no known agent. Codex's header, composer and provisional-startup checks become named predicates referenced from codex.json; its ready header, header and startup hold become shared text anchors. Native idle title markers become shared title anchors; name-only title handling becomes each agent's idle-title rule. The agent-specific branches in terminal-wait-detection.ts and tui-idle-evidence.ts are deleted, and the "later live prompt cancels a blocker" rule now reads only rule-file anchors (plus Muse, which moves in part b2). No behaviour change: the readiness census baselines are untouched and pass. Refs STA-9098 * test(runtime): cover the rule engine's title, text and predicate regions and the bundled anchors Refs STA-9098 * fix(runtime): reject a rule file that repeats an anchor or rule id A text rule names its anchor by id, so a repeated id let a file pass validation and then throw while compiling. Also states that engineVersion bumps once a version ships; version 1 is still being defined. * refactor(runtime): fold the working anchor answer into live The engine treated an anchor's working and live answers identically: both mark a live prompt that cancels an earlier blocker and settles nothing. Cursor's busy prompt now answers live, so anchors have one non-settling prompt answer. Refs STA-9098 * refactor(runtime): read the shared π title anchor from pi.json alone Pi and OMP paint the same `π - ` rest title, and title anchors apply to every pane, so one copy covers both. Refs STA-9098 * refactor(runtime): key every rule file and read the trusted screen from screenSource alone readsTrustedScreen no longer also asks for a screen rule (every trusted file has one, and the schema requires screenSource where it matters), so rule-less files need no filter. A rule's match is a plain boolean, and compileTitleAnchors is module-private. Refs STA-9098 * test(runtime): pin that a clocked Codex pane takes no other agent's ready text No test failed when holdsReadyTextToQuiet was removed; this one does. Refs STA-9098 * fix(runtime): refuse uppercase contains terms in text anchors, which read the lowercased tail A text anchor's after and lines tests run on the lowercased tail, so an uppercase contains term loaded and then never matched. Build the text test schema from the literal it accepts and give anchors the lowercase one. Also drop a probe-banner early return that no bundled catalog reaches. * refactor(runtime): state Codex's provisional startup and title anchors as plain rules The provisional-startup hold becomes a lastOf anchor with an all/none test, so its TypeScript scan goes. Title anchors drop their status field (every caller already gates on an idle title), and withoutClock keeps only the value a rule can set. --- .../ipc/orcad-runtime-maintenance-handlers.ts | 5 +- .../agent-state-rule-regions.test.ts | 215 ++++++++++++++++ .../agent-state-rules-catalog.ts | 26 +- .../agent-state-rules-engine.test.ts | 34 ++- .../agent-state-rules-engine.ts | 174 +++++++++++-- .../agent-state-rules-schema.ts | 186 ++++++++++---- .../agent-state-text-anchors.test.ts | 7 +- .../agent-state-text-anchors.ts | 69 ++++-- .../agent-state-title-anchors.ts | 34 +++ .../agent-state-rules/antigravity.json | 6 +- .../agent-state-rules/blocked-text-layer.ts | 15 ++ .../runtime/agent-state-rules/claude.json | 27 ++ src/main/runtime/agent-state-rules/cline.json | 3 +- .../codex-screen-predicates.ts} | 52 ++-- src/main/runtime/agent-state-rules/codex.json | 71 ++++++ .../runtime/agent-state-rules/cursor.json | 7 +- .../runtime/agent-state-rules/gemini.json | 21 ++ src/main/runtime/agent-state-rules/omp.json | 14 ++ .../runtime/agent-state-rules/opencode.json | 21 ++ .../runtime/agent-state-rules/opencode2.json | 14 ++ src/main/runtime/agent-state-rules/pi.json | 21 ++ .../agent-state-rules/prime-agent.json | 3 +- .../agent-state-rules/unknown-pane.json | 15 ++ .../runtime/codex-quiet-ready-screen.test.ts | 2 +- ...ntime-start-tui-idle-visible-read-probe.ts | 4 +- src/main/runtime/runtime-terminal-wait.ts | 8 +- .../runtime/startup-dialog-blocked-signals.ts | 2 +- src/main/runtime/terminal-wait-detection.ts | 173 ++++--------- src/main/runtime/tui-idle-evidence.test.ts | 4 +- src/main/runtime/tui-idle-evidence.ts | 79 +++--- src/main/ssh/orcad-runtime-decommission.ts | 5 +- src/main/ssh/orcad-runtime-maintenance.ts | 230 +++++++++--------- 32 files changed, 1125 insertions(+), 422 deletions(-) create mode 100644 src/main/runtime/agent-state-rules/agent-state-rule-regions.test.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-title-anchors.ts create mode 100644 src/main/runtime/agent-state-rules/claude.json rename src/main/runtime/{codex-terminal-readiness.ts => agent-state-rules/codex-screen-predicates.ts} (52%) create mode 100644 src/main/runtime/agent-state-rules/codex.json create mode 100644 src/main/runtime/agent-state-rules/gemini.json create mode 100644 src/main/runtime/agent-state-rules/omp.json create mode 100644 src/main/runtime/agent-state-rules/opencode.json create mode 100644 src/main/runtime/agent-state-rules/opencode2.json create mode 100644 src/main/runtime/agent-state-rules/pi.json create mode 100644 src/main/runtime/agent-state-rules/unknown-pane.json diff --git a/src/main/ipc/orcad-runtime-maintenance-handlers.ts b/src/main/ipc/orcad-runtime-maintenance-handlers.ts index 1fe7c49cdda..0f16da4de07 100644 --- a/src/main/ipc/orcad-runtime-maintenance-handlers.ts +++ b/src/main/ipc/orcad-runtime-maintenance-handlers.ts @@ -23,7 +23,10 @@ export function registerOrcadRuntimeMaintenanceHandlers(options: { }): void { ipcMain.handle( 'runtimeEnvironments:updateOrcad', - async (_event, args: { selector: string; force?: boolean }): Promise => { + async ( + _event, + args: { selector: string; force?: boolean } + ): Promise => { const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), { selector: requiredString(args?.selector, 'Server'), force: args?.force === true diff --git a/src/main/runtime/agent-state-rules/agent-state-rule-regions.test.ts b/src/main/runtime/agent-state-rules/agent-state-rule-regions.test.ts new file mode 100644 index 00000000000..09b74cd7a69 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rule-regions.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from 'vitest' +import { + detectExplicitIdleStatusFromTitle, + detectTerminalWaitBlockedReason, + isKnownReadyPromptBody, + isKnownReadyPromptPreview, + isKnownReadyPromptSettled +} from '../terminal-wait-detection' +import { nameOnlyIdleNeedsCorroboration } from '../tui-idle-evidence' +import { + compileAgentRules, + evaluateCompiledRules, + readsTrustedScreen, + type AgentStateRegions +} from './agent-state-rules-engine' +import { parseAgentStateRuleFiles } from './agent-state-rules-catalog' +import { showsIdleTitleAnchor } from './agent-state-title-anchors' + +const STRONG_QUIET = { state: 'idle', strength: 'strong', requiresQuiet: true } +const TITLE = { region: 'title', status: 'idle' } + +function rule(id: string, when: Record, answer: Record) { + return { id, why: 'test', priority: 100, when, answer } +} + +const READY_ANCHOR = { + id: 'ready', + why: 'test', + when: { region: 'text', find: { lastOf: 'ready>' } }, + answer: { state: 'idle' } +} + +function file(rules: unknown[], extra: Record = {}): Record { + return { id: 'codex', engineVersion: 1, anchors: [READY_ANCHOR], rules, ...extra } +} + +function evaluate(rules: unknown[], regions: AgentStateRegions): string | null { + const [parsed] = parseAgentStateRuleFiles([file(rules, { profile: { screenSource: 'live' } })]) + return evaluateCompiledRules(compileAgentRules(parsed), regions)?.ruleId ?? null +} + +describe('region schema', () => { + const screen = { region: 'screen', predicate: 'codex-composer-ready' } + + it.each([ + [ + 'screen rows beside a predicate', + file([rule('a', { ...screen, rows: [{ contains: '>' }] }, STRONG_QUIET)], { + profile: { screenSource: 'live' } + }) + ], + ['screen rules with no screenSource', file([rule('a', screen, STRONG_QUIET)])], + [ + 'withoutClock on a weak rule', + file([ + rule( + 'a', + { region: 'title', status: 'idle' }, + { state: 'idle', strength: 'weak', requiresQuiet: true, withoutClock: 'skip' } + ) + ]) + ], + [ + 'a text rule naming no anchor of its file', + file([rule('a', { region: 'text', anchor: 'missing' }, STRONG_QUIET)]) + ], + [ + 'a text rule naming a non-idle anchor', + file([rule('a', { region: 'text', anchor: 'live' }, STRONG_QUIET)], { + anchors: [{ ...READY_ANCHOR, id: 'live', answer: { state: 'live' } }] + }) + ], + [ + 'a title anchor answering other than idle', + file([], { + anchors: [ + { + id: 't', + why: 'test', + when: { region: 'title', match: { contains: '◇' } }, + answer: { state: 'live' } + } + ] + }) + ], + [ + 'a title rule on a status other than idle', + file([rule('a', { region: 'title', status: 'working' }, STRONG_QUIET)]) + ], + ['an unknown pane id', { ...file([]), id: 'unknown' }], + ['two anchors with one id', file([], { anchors: [READY_ANCHOR, READY_ANCHOR] })], + [ + 'two rules with one id', + file([rule('a', TITLE, STRONG_QUIET), rule('a', TITLE, STRONG_QUIET)]) + ] + ])('rejects %s', (_label, candidate) => { + expect(() => parseAgentStateRuleFiles([candidate])).toThrow() + }) + + it('accepts the unknown-pane file', () => { + expect(() => parseAgentStateRuleFiles([{ ...file([]), id: 'unknown-pane' }])).not.toThrow() + }) +}) + +describe('regions', () => { + const title = rule('title', { region: 'title', status: 'idle' }, STRONG_QUIET) + const text = rule('text', { region: 'text', anchor: 'ready' }, STRONG_QUIET) + + it('skips a rule whose region the lane does not read', () => { + expect(evaluate([title], { readScreenLines: () => [] })).toBeNull() + expect(evaluate([title], { readTitleStatus: () => 'idle' })).toBe('title') + expect(evaluate([title], { readTitleStatus: () => 'working' })).toBeNull() + }) + + it('reads a named screen predicate over the lowercased screen', () => { + const composer = rule( + 'c', + { region: 'screen', predicate: 'codex-composer-ready' }, + STRONG_QUIET + ) + expect(evaluate([composer], { readScreenLines: () => ['› Ask Codex to do anything'] })).toBe( + 'c' + ) + expect( + evaluate([composer], { + readScreenLines: () => ['esc to interrupt)', '› Ask Codex to do anything'] + }) + ).toBeNull() + }) + + it('needs its text anchor settled: no blocker painted after it', () => { + const readText = (value: string) => ({ readText: () => value }) + expect(evaluate([text], readText('ready>'))).toBe('text') + expect(evaluate([text], readText('ready>\ndo you trust this folder?'))).toBeNull() + expect(evaluate([text], readText('do you trust this folder?\nready>'))).toBe('text') + }) + + it('skips a rule marked withoutClock skip only on a pane with no output clock', () => { + const skipped = rule( + 's', + { region: 'title', status: 'idle' }, + { ...STRONG_QUIET, withoutClock: 'skip' } + ) + const regions = { readTitleStatus: () => 'idle' as const } + expect(evaluate([skipped, title], { ...regions, hasOutputClock: true })).toBe('s') + expect(evaluate([skipped, title], { ...regions, hasOutputClock: false })).toBe('title') + }) +}) + +describe('the bundled Codex text anchors', () => { + const header = + '╭───╮\n│ >_ openai codex (v0.157.0) │\n│ model: gpt-5 │\n│ directory: ~/repo │\n╰───╯' + + it('settles on the loaded header, and takes the header as proof a dialog was answered', () => { + expect(isKnownReadyPromptSettled(header)).toBe(true) + expect( + detectTerminalWaitBlockedReason( + 'do you trust this folder?\n1. yes\n>_ openai codex (v0.158.0)' + ) + ).toBeNull() + }) + + it('holds a provisional header: present, but not yet taking input', () => { + const provisional = header.replace('gpt-5', 'loading') + expect(isKnownReadyPromptPreview(provisional)).toBe(true) + expect(isKnownReadyPromptSettled(provisional)).toBe(false) + expect(isKnownReadyPromptSettled(`${provisional}\n› hi\ngpt-5 · ~/repo`)).toBe(true) + }) + + it('holds its own ready text to quiet on a clocked Codex pane, and believes it clockless', () => { + expect(isKnownReadyPromptBody(header, 'codex', () => null, true)).toBe(false) + expect(isKnownReadyPromptBody(header, 'codex', () => null, false)).toBe(true) + expect(isKnownReadyPromptBody(header, 'claude', () => null, true)).toBe(true) + }) + + it("takes no other agent's ready text on a clocked Codex pane, as before the rule files", () => { + const cursorPrompt = '>_ openai codex (v0.158.0)\ncursor agent\n→' + expect(isKnownReadyPromptBody(cursorPrompt, 'codex', () => null, true)).toBe(false) + expect(isKnownReadyPromptBody(cursorPrompt, 'codex', () => null, false)).toBe(true) + expect(isKnownReadyPromptBody(cursorPrompt, 'claude', () => null, true)).toBe(true) + }) + + it('settles an unknown pane on the live-screen Codex header at once, even clocked', () => { + const screen = () => header.split('\n') + expect(isKnownReadyPromptBody('', null, screen, true)).toBe(true) + expect(isKnownReadyPromptBody('', 'claude', screen, true)).toBe(false) + }) + + it('reads the live screen for Codex, not the trusted grid', () => { + expect(readsTrustedScreen('codex')).toBe(false) + expect(readsTrustedScreen('cline')).toBe(true) + }) +}) + +describe('the bundled title anchors', () => { + it.each(['✳ Claude Code', '* Claude Code', '◇ Ready (repo)', 'π - orca', 'OC | orca'])( + 'reads %s as an explicit idle title', + (title) => { + expect(detectExplicitIdleStatusFromTitle(title)).toBe('idle') + } + ) + + it('marks an agent rest title only when an anchor matches it', () => { + expect(showsIdleTitleAnchor('✳ Claude Code')).toBe(true) + expect(showsIdleTitleAnchor('Claude Code')).toBe(false) + }) + + it('leaves a name-only title to the agent idle-title rule', () => { + expect(detectExplicitIdleStatusFromTitle('claude')).toBeNull() + expect(nameOnlyIdleNeedsCorroboration('pi')).toBe(true) + expect(nameOnlyIdleNeedsCorroboration('omp')).toBe(true) + expect(nameOnlyIdleNeedsCorroboration('gemini')).toBe(false) + expect(nameOnlyIdleNeedsCorroboration('opencode')).toBe(false) + }) +}) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts b/src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts index a5e189aa0ac..029696fc851 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts @@ -1,9 +1,16 @@ -import type { TuiAgent } from '../../../shared/tui-agent' import { AgentStateRulesFileSchema, type AgentStateRulesFile } from './agent-state-rules-schema' import antigravity from './antigravity.json' +import claude from './claude.json' import cline from './cline.json' +import codex from './codex.json' import cursor from './cursor.json' +import gemini from './gemini.json' +import omp from './omp.json' +import opencode from './opencode.json' +import opencode2 from './opencode2.json' +import pi from './pi.json' import primeAgent from './prime-agent.json' +import unknownPane from './unknown-pane.json' /** Validates bundled rule files; a malformed one throws, naming the file and the bad field. */ export function parseAgentStateRuleFiles(files: readonly unknown[]): AgentStateRulesFile[] { @@ -14,7 +21,7 @@ export function parseAgentStateRuleFiles(files: readonly unknown[]): AgentStateR } return result.data }) - const seen = new Set() + const seen = new Set() for (const file of parsed) { if (seen.has(file.id)) { throw new Error(`agent state rules: two files for ${file.id}`) @@ -27,4 +34,17 @@ export function parseAgentStateRuleFiles(files: readonly unknown[]): AgentStateR // Why imported, not read from disk: the bundler inlines them, so packaged and headless builds // carry the rules with no resource path to resolve. export const BUNDLED_AGENT_STATE_RULE_FILES: readonly AgentStateRulesFile[] = - parseAgentStateRuleFiles([antigravity, cline, cursor, primeAgent]) + parseAgentStateRuleFiles([ + antigravity, + claude, + cline, + codex, + cursor, + gemini, + omp, + opencode, + opencode2, + pi, + primeAgent, + unknownPane + ]) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts index 73c8d0ea09c..c15c60cb9d2 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts @@ -15,10 +15,17 @@ function anchor(when: Record, answer: Record) return { id: 'anchor', why: 'test', when, answer } } -type RuleFileOverrides = { rules?: unknown[]; textAnchors?: unknown[] } & Record +type RuleFileOverrides = { rules?: unknown[]; anchors?: unknown[] } & Record function ruleFile(overrides: RuleFileOverrides = {}): Record { - return { id: 'cline', engineVersion: 1, textAnchors: [], rules: [], ...overrides } + return { + id: 'cline', + engineVersion: 1, + profile: { screenSource: 'trusted' }, + anchors: [], + rules: [], + ...overrides + } } const SCREEN = { region: 'screen' } @@ -62,9 +69,9 @@ describe('agent state rules schema', () => { [ 'a codex-only blocked reason', ruleFile({ - textAnchors: [ + anchors: [ anchor( - { find: { lastOf: 'update' } }, + { region: 'text', find: { lastOf: 'update' } }, { state: 'blocked', reason: 'codex-update-prompt' } ) ] @@ -72,14 +79,16 @@ describe('agent state rules schema', () => { ], [ 'an unregistered named anchor', - ruleFile({ textAnchors: [anchor({ find: { predicate: 'nope' } }, { state: 'idle' })] }) + ruleFile({ + anchors: [anchor({ region: 'text', find: { predicate: 'nope' } }, { state: 'idle' })] + }) ], [ 'a blocked anchor the prefilter cannot key on', ruleFile({ - textAnchors: [ + anchors: [ anchor( - { find: { predicate: 'antigravity-text-composer' } }, + { region: 'text', find: { predicate: 'antigravity-text-composer' } }, { state: 'blocked', reason: 'agent-approval-prompt' } ) ] @@ -87,13 +96,18 @@ describe('agent state rules schema', () => { ], [ 'an uppercase anchor literal, which the lowercased tail never contains', - ruleFile({ textAnchors: [anchor({ find: { lastOf: 'Cursor' } }, { state: 'idle' })] }) + ruleFile({ + anchors: [anchor({ region: 'text', find: { lastOf: 'Cursor' } }, { state: 'idle' })] + }) ], [ 'an uppercase anchor contains term', ruleFile({ - textAnchors: [ - anchor({ find: { lastOf: 'x' }, after: { contains: 'Run' } }, { state: 'idle' }) + anchors: [ + anchor( + { region: 'text', find: { lastOf: 'x' }, after: { contains: 'Run' } }, + { state: 'idle' } + ) ] }) ] diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts index 62977c185aa..795d68c019e 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts @@ -1,17 +1,92 @@ +import type { AgentStatus } from '../../../shared/agent-detection' import type { TuiAgent } from '../../../shared/tui-agent' -import { compileScreenCondition, type ScreenMatcher } from './agent-state-rule-matchers' +import { compileScreenCondition } from './agent-state-rule-matchers' import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' -import type { AgentStateRuleAnswer, AgentStateRulesFile } from './agent-state-rules-schema' +import { + UNKNOWN_PANE_RULES_ID, + type AgentStateRuleAnswer, + type AgentStateRuleCondition, + type AgentStateRulesFile, + type NamedScreenPredicate +} from './agent-state-rules-schema' +import { compileTextAnchor } from './agent-state-text-anchors' +import { isSettledAfter } from './blocked-text-layer' +import { isCodexComposerReadyScreen, isCodexHeaderReadyScreen } from './codex-screen-predicates' /** What the first matching rule answered. Callers rank it among the other readiness lanes. */ export type AgentStateVerdict = { ruleId: string } & AgentStateRuleAnswer -/** The regions a rule may read, each null when no trustworthy copy exists. */ +/** + * The regions a lane lets the rules read. A region that is absent, or null because no trustworthy + * copy exists, skips the rules that read it: the lane reads only the evidence it ranks. + */ export type AgentStateRegions = { - readScreenLines: () => readonly string[] | null + readScreenLines?: () => readonly string[] | null + /** The lowercased text tail. */ + readText?: () => string + /** The status the shared title classifier gave the pane title. */ + readTitleStatus?: () => AgentStatus | null + /** False on a pane with no output clock (restored or adopted); absent reads as clocked. */ + hasOutputClock?: boolean } -type CompiledRule = { verdict: AgentStateVerdict; matches: ScreenMatcher } +type Region = AgentStateRuleCondition['region'] + +type RegionReader = () => T | null + +type RegionReads = { + screen: RegionReader + /** The screen joined and lowercased, for named screen predicates. */ + screenText: RegionReader + text: RegionReader + title: RegionReader +} + +type CompiledRule = { + verdict: AgentStateVerdict + region: Region + skipWithoutClock: boolean + /** False when its region is unreadable, which skips the rule. */ + matches: (reads: RegionReads) => boolean +} + +const NAMED_SCREEN_PREDICATES: Record boolean> = { + 'codex-header-ready': isCodexHeaderReadyScreen, + 'codex-composer-ready': isCodexComposerReadyScreen +} + +function readThen(read: RegionReader, test: (value: T) => boolean): boolean { + const value = read() + return value !== null && test(value) +} + +function compileCondition( + when: AgentStateRuleCondition, + file: AgentStateRulesFile +): CompiledRule['matches'] { + switch (when.region) { + case 'screen': { + if (when.predicate) { + const predicate = NAMED_SCREEN_PREDICATES[when.predicate] + return (reads) => readThen(reads.screenText, predicate) + } + const matches = compileScreenCondition(when) + return (reads) => readThen(reads.screen, matches) + } + case 'title': + return (reads) => readThen(reads.title, (status) => status === when.status) + case 'text': { + const anchor = file.anchors.find((candidate) => candidate.id === when.anchor) + // Why unreachable: the schema requires the anchor to be one of this file's text anchors. + if (anchor?.when.region !== 'text') { + throw new Error(`agent state rules ${file.id}: no text anchor ${when.anchor}`) + } + const find = compileTextAnchor(anchor.when, anchor.answer) + return (reads) => + readThen(reads.text, (text) => isSettledAfter(text, find(text)?.index ?? null)) + } + } +} export function compileAgentRules(file: AgentStateRulesFile): CompiledRule[] { // Why stable: equal priorities keep file order. @@ -19,36 +94,95 @@ export function compileAgentRules(file: AgentStateRulesFile): CompiledRule[] { .toSorted((left, right) => right.priority - left.priority) .map((rule) => ({ verdict: { ruleId: rule.id, ...rule.answer }, - matches: compileScreenCondition(rule.when) + region: rule.when.region, + skipWithoutClock: rule.answer.state === 'idle' && rule.answer.withoutClock === 'skip', + matches: compileCondition(rule.when, file) })) } -const RULES_BY_AGENT: ReadonlyMap = new Map( - BUNDLED_AGENT_STATE_RULE_FILES.filter((file) => file.rules.length > 0).map((file) => [ +type RulesKey = TuiAgent | typeof UNKNOWN_PANE_RULES_ID + +type CompiledFile = { rules: CompiledRule[]; readsTrustedScreen: boolean } + +const FILES_BY_KEY: ReadonlyMap = new Map( + BUNDLED_AGENT_STATE_RULE_FILES.map((file) => [ file.id, - compileAgentRules(file) + { + rules: compileAgentRules(file), + readsTrustedScreen: file.profile?.screenSource === 'trusted' + } ]) ) +// Why the unknown-pane file for a null agent: an adopted pane can still run a known agent. +function compiledFileFor(agent: TuiAgent | null | undefined): CompiledFile | undefined { + return FILES_BY_KEY.get(agent ?? UNKNOWN_PANE_RULES_ID) +} + /** - * Whether the agent's own rules read its screen. Why it changes which screen is read: those rules - * were recorded against the PTY's trusted grid, while every other agent keeps the live screen. + * Whether the agent's rules read the PTY's trusted grid. Why it changes which screen is read, and + * how a clockless wait probes: those rules were recorded against that grid, while every other + * agent keeps the live screen. */ -export function hasScreenRules(agent: TuiAgent | null | undefined): boolean { - return agent ? RULES_BY_AGENT.has(agent) : false +export function readsTrustedScreen(agent: TuiAgent | null | undefined): boolean { + return compiledFileFor(agent)?.readsTrustedScreen ?? false +} + +function someRule( + agent: TuiAgent | null | undefined, + test: (rule: CompiledRule) => boolean +): boolean { + return compiledFileFor(agent)?.rules.some(test) ?? false +} + +function isStrongQuietIdle(verdict: AgentStateVerdict): boolean { + return verdict.state === 'idle' && verdict.strength === 'strong' && verdict.requiresQuiet +} + +/** Whether the agent has a ready sign it also paints mid-turn, which the quiet lane must read. */ +export function hasQuietReadyRules(agent: TuiAgent | null | undefined): boolean { + return someRule(agent, (rule) => isStrongQuietIdle(rule.verdict)) +} + +/** + * Whether the agent's own ready text is held to quiet. Why it shuts the shared text lane on a + * clocked pane: that lane settles any rule file's ready text at once, which would skip the quiet. + */ +export function holdsReadyTextToQuiet(agent: TuiAgent | null | undefined): boolean { + return someRule(agent, (rule) => rule.region === 'text' && isStrongQuietIdle(rule.verdict)) +} + +/** Whether the agent's own idle-title rule waits for quiet; null when it has none. */ +export function idleTitleRequiresQuiet(agent: TuiAgent | null | undefined): boolean | null { + const verdict = compiledFileFor(agent)?.rules.find((rule) => rule.region === 'title')?.verdict + return verdict?.state === 'idle' ? verdict.requiresQuiet : null +} + +function memoize(read: (() => T | null) | undefined): RegionReader { + if (!read) { + return () => null + } + let value: T | null | undefined + return () => (value === undefined ? (value = read()) : value) } export function evaluateCompiledRules( rules: readonly CompiledRule[], regions: AgentStateRegions ): AgentStateVerdict | null { - // Why no answer rather than a refusal: with no trusted grid the caller's text lanes decide. - const screenLines = rules.length > 0 ? regions.readScreenLines() : null - if (screenLines === null) { - return null + const screen = memoize(regions.readScreenLines) + const reads: RegionReads = { + screen, + screenText: memoize(() => screen()?.join('\n').toLowerCase() ?? null), + text: memoize(regions.readText), + title: memoize(regions.readTitleStatus) } + // Why no answer rather than a refusal: with no readable region the caller's other lanes decide. for (const rule of rules) { - if (rule.matches(screenLines)) { + if (rule.skipWithoutClock && regions.hasOutputClock === false) { + continue + } + if (rule.matches(reads)) { return rule.verdict } } @@ -60,6 +194,6 @@ export function evaluateAgentStateRules( agent: TuiAgent | null | undefined, regions: AgentStateRegions ): AgentStateVerdict | null { - const rules = agent ? RULES_BY_AGENT.get(agent) : undefined - return rules ? evaluateCompiledRules(rules, regions) : null + const file = compiledFileFor(agent) + return file ? evaluateCompiledRules(file.rules, regions) : null } diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts index f0cb579fe1e..de2dbae4cc5 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts @@ -5,10 +5,11 @@ import { isTuiAgent } from '../../../shared/tui-agent-config' import { findUnsafePatternReason } from './agent-state-rule-pattern-safety' /** - * One file per agent (`.json` beside this schema). Every object is strict, so a misspelled - * field rejects the file instead of silently dropping a condition. Every rule and anchor is - * `when` (a region and what it must show) plus `answer`; adding a region, predicate or - * answer bumps `engineVersion`. + * One file per agent (`.json` beside this schema), plus `unknown-pane.json` for panes whose + * agent Orca does not know. Every object is strict, so a misspelled field rejects the file instead + * of silently dropping a condition. Every rule and anchor is `when` (a region and what it must + * show) plus `answer`. Once a version ships, adding a region, predicate or answer bumps + * `engineVersion`; until then version 1 is still being defined. */ const AGENT_STATE_RULES_ENGINE_VERSION = 1 @@ -56,17 +57,21 @@ const RowSchema = z.union([TextTestSchema, z.object({ optional: TextTestSchema } /** The evidence behind a rule, since JSON carries no comments; it ships beside the pattern it explains. */ const Why = z.string().min(1).max(600) +const NAMED_SCREEN_PREDICATES = ['codex-header-ready', 'codex-composer-ready'] as const + /** - * The trusted screen grid. `rows` are consecutive trimmed rows, top-down; the block ends at the - * bottom-most row, among the last `endsWithinBottom`, that passes the final test, and a row above - * the screen reads as empty. With no `rows`, the condition holds whenever the screen is readable. + * The screen `profile.screenSource` names. `rows` are consecutive trimmed rows, top-down; the + * block ends at the bottom-most row, among the last `endsWithinBottom`, that passes the final + * test, and a row above the screen reads as empty. Or `predicate`: a named engine scan over the + * lowercased screen, for a shape rows cannot state. With neither, it holds whenever readable. */ const ScreenConditionSchema = z .object({ region: z.literal('screen'), rows: z.array(RowSchema).min(1).max(MAX_ROWS).optional(), endsWithinBottom: z.number().int().min(1).max(MAX_ROWS).optional(), - noneAbove: TextTestSchema.optional() + noneAbove: TextTestSchema.optional(), + predicate: z.enum(NAMED_SCREEN_PREDICATES).optional() }) .strict() .refine( @@ -77,9 +82,24 @@ const ScreenConditionSchema = z (screen) => !('optional' in (screen.rows?.at(-1) ?? {})), 'the last row cannot be optional' ) + .refine((screen) => !(screen.rows && screen.predicate), 'rows or a predicate, not both') -// Why one region: title, text and status regions arrive with the agents that need them. -const RuleConditionSchema = z.discriminatedUnion('region', [ScreenConditionSchema]) +/** The pane title's status, as the shared title classifier read it (`lastAgentStatus`). */ +const TitleConditionSchema = z + .object({ region: z.literal('title'), status: z.enum(['idle']) }) + .strict() + +/** + * The text tail: this file's `anchor` (an idle text anchor) is found and settled, meaning no + * blocker was painted after it and no hold anchor shows anywhere. + */ +const TextConditionSchema = z.object({ region: z.literal('text'), anchor: Literal }).strict() + +const RuleConditionSchema = z.discriminatedUnion('region', [ + ScreenConditionSchema, + TitleConditionSchema, + TextConditionSchema +]) const RuleAnswerSchema = z.discriminatedUnion('state', [ z @@ -88,9 +108,16 @@ const RuleAnswerSchema = z.discriminatedUnion('state', [ /** Strong settles a wait at once; weak only on the poll, once nothing stronger spoke. */ strength: z.enum(['strong', 'weak']), /** Believed only after the output clock has been quiet (agents paint this mid-turn too). */ - requiresQuiet: z.boolean() + requiresQuiet: z.boolean(), + /** On a pane with no output clock (restored or adopted), quiet cannot be measured: a strong + * quiet rule is believed at once there, unless it says `skip` (then it does not apply). */ + withoutClock: z.literal('skip').optional() }) - .strict(), + .strict() + .refine( + (answer) => !answer.withoutClock || (answer.requiresQuiet && answer.strength === 'strong'), + 'withoutClock is for strong rules that require quiet' + ), // Why hold: the agent's own evidence was readable and said "not ready", which must also shut // the weak lanes (a name-only title or quiet process cannot see what the screen refused). z.object({ state: z.literal('hold') }).strict() @@ -108,6 +135,7 @@ const AgentStateRuleSchema = z .strict() const NAMED_TEXT_ANCHORS = ['antigravity-text-composer'] as const +const NAMED_TITLE_PREDICATES = ['opencode-native-title'] as const const AGENT_BLOCKED_REASONS = [ 'agent-update-prompt', @@ -119,72 +147,136 @@ const AGENT_BLOCKED_REASONS = [ ] as const satisfies readonly RuntimeTerminalWaitBlockedReason[] /** - * A position in the lowercased text tail. Anchors are not part of the agent's priority list: they - * read every pane whatever agent it runs (a tail can show another agent's dialog, and an adopted - * pane has no known agent), and the latest one in the text wins. A blocked anchor reads the - * blocked layer's live window; an idle or working one is a live prompt, which cancels an earlier - * blocker, and only an idle one settles a wait. + * A position in the lowercased text tail. Text anchors read every pane whatever agent it runs (a + * tail can show another agent's dialog, and an adopted pane has no known agent), and the latest one + * in the text wins. A blocked anchor reads the blocked layer's live window. An idle or live one is + * a live prompt, which cancels an earlier blocker; only an idle one settles a wait. A hold anchor, + * found anywhere, stops every text anchor from settling one (the agent is up, not ready). */ -const TextAnchorSchema = z +const TextAnchorConditionSchema = z + .object({ + region: z.literal('text'), + /** Where the anchor starts: a literal's last occurrence, or a named engine scan. */ + find: z.union([ + z.object({ lastOf: TailLiteral }).strict(), + z.object({ predicate: z.enum(NAMED_TEXT_ANCHORS) }).strict() + ]), + /** Reads only the last N lines of its input. */ + withinLastLines: z.number().int().min(1).max(64).optional(), + /** The text from the anchor to the end must pass this. */ + after: TailTextTestSchema.optional(), + /** Over the lines read, trailing blanks dropped: at least `atLeast` pass, the last one too + * when `includingLast`. */ + lines: z + .object({ + atLeast: z.number().int().min(1).max(MAX_ROWS), + includingLast: z.boolean(), + test: TailTextTestSchema + }) + .strict() + .optional() + }) + .strict() + +/** + * A title the shared classifier already calls idle, marked as an agent's own rest title. Like a + * text anchor it is read whatever agent the pane runs: an adopted pane has no known agent, and a + * pane can run another agent than it launched. `match` is a test, or a named engine predicate + * shared with other title readers. + */ +const TitleAnchorConditionSchema = z + .object({ + region: z.literal('title'), + match: z.union([ + TextTestSchema, + z.object({ predicate: z.enum(NAMED_TITLE_PREDICATES) }).strict() + ]) + }) + .strict() + +const AnchorSchema = z .object({ id: Literal, why: Why, - when: z - .object({ - /** Where the anchor starts: a literal's last occurrence, or a named engine scan. */ - find: z.union([ - z.object({ lastOf: TailLiteral }).strict(), - z.object({ predicate: z.enum(NAMED_TEXT_ANCHORS) }).strict() - ]), - /** Reads only the last N lines of its input. */ - withinLastLines: z.number().int().min(1).max(64).optional(), - /** The text from the anchor to the end must pass this. */ - after: TailTextTestSchema.optional(), - /** Over the lines read, trailing blanks dropped: at least `atLeast` pass, the last one too - * when `includingLast`. */ - lines: z - .object({ - atLeast: z.number().int().min(1).max(MAX_ROWS), - includingLast: z.boolean(), - test: TailTextTestSchema - }) - .strict() - .optional() - }) - .strict(), + when: z.discriminatedUnion('region', [TextAnchorConditionSchema, TitleAnchorConditionSchema]), answer: z.discriminatedUnion('state', [ z.object({ state: z.literal('blocked'), reason: z.enum(AGENT_BLOCKED_REASONS) }).strict(), z.object({ state: z.literal('idle') }).strict(), - z.object({ state: z.literal('working') }).strict() + z.object({ state: z.literal('live') }).strict(), + z.object({ state: z.literal('hold') }).strict() ]) }) .strict() .refine( - (anchor) => anchor.answer.state !== 'blocked' || 'lastOf' in anchor.when.find, + (anchor) => + anchor.answer.state !== 'blocked' || + (anchor.when.region === 'text' && 'lastOf' in anchor.when.find), "a blocked anchor needs find.lastOf: the blocked layer's prefilter keys on it" ) + .refine( + (anchor) => anchor.when.region !== 'title' || anchor.answer.state === 'idle', + 'a title anchor answers idle' + ) /** Facts about the agent that are not detection rules. */ const ProfileSchema = z .object({ /** Text whose presence in a pane's tail makes a tui-idle wait read its visible screen once. */ - screenProbeBanner: TailLiteral.optional() + screenProbeBanner: TailLiteral.optional(), + /** The screen the rules read: the PTY's own grid, trusted only while the PTY still has that + * size, or the live emulator's. Rules keep the source they were recorded against. */ + screenSource: z.enum(['trusted', 'live']).optional() }) .strict() +/** The panes no agent file covers: no launch record, and no recognised foreground process. */ +export const UNKNOWN_PANE_RULES_ID = 'unknown-pane' + +function hasUniqueIds(entries: readonly { id: string }[]): boolean { + return new Set(entries.map((entry) => entry.id)).size === entries.length +} + export const AgentStateRulesFileSchema = z .object({ - id: z.custom(isTuiAgent, 'not a known agent'), + id: z.union([ + z.literal(UNKNOWN_PANE_RULES_ID), + z.custom(isTuiAgent, 'not a known agent') + ]), engineVersion: z.literal(AGENT_STATE_RULES_ENGINE_VERSION), profile: ProfileSchema.optional(), - textAnchors: z.array(TextAnchorSchema).max(MAX_RULES), + anchors: z.array(AnchorSchema).max(MAX_RULES), rules: z.array(AgentStateRuleSchema).max(MAX_RULES) }) .strict() + .refine( + (file) => + !file.rules.some((rule) => rule.when.region === 'screen') || + file.profile?.screenSource !== undefined, + 'a file with screen rules names its profile.screenSource' + ) + .refine((file) => { + const idleTextAnchors = new Set( + file.anchors + .filter((anchor) => anchor.when.region === 'text' && anchor.answer.state === 'idle') + .map((anchor) => anchor.id) + ) + return file.rules.every( + (rule) => rule.when.region !== 'text' || idleTextAnchors.has(rule.when.anchor) + ) + }, "a text rule names one of this file's idle text anchors") + .refine( + (file) => hasUniqueIds(file.anchors) && hasUniqueIds(file.rules), + 'anchor ids, and rule ids, are unique within the file' + ) export type TextTest = z.infer export type ScreenCondition = z.infer +export type AgentStateRuleCondition = z.infer export type AgentStateRuleAnswer = z.infer -export type TextAnchor = z.infer +export type Anchor = z.infer +export type TextAnchorCondition = z.infer +export type TitleAnchorCondition = z.infer export type NamedTextAnchor = (typeof NAMED_TEXT_ANCHORS)[number] +export type NamedScreenPredicate = (typeof NAMED_SCREEN_PREDICATES)[number] +export type NamedTitlePredicate = (typeof NAMED_TITLE_PREDICATES)[number] export type AgentStateRulesFile = z.infer diff --git a/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts b/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts index 2338d5103b8..97fda216dc6 100644 --- a/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts +++ b/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts @@ -4,9 +4,9 @@ import { compileTextAnchors, findPromptAnchorIndexes } from './agent-state-text- import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './blocked-text-layer' import { detectTerminalWaitBlockedReason } from '../terminal-wait-detection' -function anchorsOf(textAnchors: unknown[]) { +function anchorsOf(anchors: unknown[]) { return compileTextAnchors( - parseAgentStateRuleFiles([{ id: 'cursor', engineVersion: 1, textAnchors, rules: [] }]) + parseAgentStateRuleFiles([{ id: 'cursor', engineVersion: 1, anchors, rules: [] }]) ) } @@ -16,6 +16,7 @@ describe('blocked anchors', () => { id: 'menu', why: 'test', when: { + region: 'text', find: { lastOf: 'run it?' }, withinLastLines: 4, lines: { atLeast: 2, includingLast: true, test: { regex: '\\([a-z]\\)$' } } @@ -47,7 +48,7 @@ describe('prompt anchors', () => { { id: 'prompt', why: 'test', - when: { find: { lastOf: 'banner' }, after: { contains: '→' } }, + when: { region: 'text', find: { lastOf: 'banner' }, after: { contains: '→' } }, answer: { state: 'idle' } } ]).prompts diff --git a/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts b/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts index 7aa3c9f3224..5de562de05a 100644 --- a/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts +++ b/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts @@ -2,18 +2,25 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../../shared/runtime-t import { startOfLastLines } from '../terminal-wait-tail-window' import { compileTextTest, type TextMatcher } from './agent-state-rule-matchers' import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' -import type { AgentStateRulesFile, NamedTextAnchor, TextAnchor } from './agent-state-rules-schema' +import type { + Anchor, + AgentStateRulesFile, + NamedTextAnchor, + TextAnchorCondition +} from './agent-state-rules-schema' import { findAntigravityComposerIndex } from './antigravity-text-composer' export type BlockedTextSignal = { reason: RuntimeTerminalWaitBlockedReason; index: number } -type TextAnchorHit = { answer: TextAnchor['answer']; index: number } +type TextAnchorHit = { answer: Anchor['answer']; index: number } + +export type TextAnchorFinder = (normalized: string) => TextAnchorHit | null const NAMED_TEXT_ANCHOR_FINDERS: Record number | null> = { 'antigravity-text-composer': findAntigravityComposerIndex } -function compileFind(find: TextAnchor['when']['find']): (text: string) => number | null { +function compileFind(find: TextAnchorCondition['find']): (text: string) => number | null { if ('predicate' in find) { return NAMED_TEXT_ANCHOR_FINDERS[find.predicate] } @@ -23,7 +30,7 @@ function compileFind(find: TextAnchor['when']['find']): (text: string) => number } } -function compileLineCount(lines: NonNullable): TextMatcher { +function compileLineCount(lines: NonNullable): TextMatcher { const test = compileTextTest(lines.test) return (text) => { const rows = text.split('\n') @@ -36,11 +43,14 @@ function compileLineCount(lines: NonNullable): Text } } -function compileTextAnchor(anchor: TextAnchor): (text: string) => TextAnchorHit | null { - const { withinLastLines } = anchor.when - const find = compileFind(anchor.when.find) - const after = anchor.when.after ? compileTextTest(anchor.when.after) : null - const lines = anchor.when.lines ? compileLineCount(anchor.when.lines) : null +export function compileTextAnchor( + when: TextAnchorCondition, + answer: Anchor['answer'] +): TextAnchorFinder { + const { withinLastLines } = when + const find = compileFind(when.find) + const after = when.after ? compileTextTest(when.after) : null + const lines = when.lines ? compileLineCount(when.lines) : null return (text) => { const start = withinLastLines ? startOfLastLines(text, withinLastLines) : 0 const region = text.slice(start) @@ -48,23 +58,39 @@ function compileTextAnchor(anchor: TextAnchor): (text: string) => TextAnchorHit if (index === null || (after && !after(region.slice(index))) || (lines && !lines(region))) { return null } - return { answer: anchor.answer, index: start + index } + return { answer, index: start + index } } } +type TextAnchor = { when: TextAnchorCondition; answer: Anchor['answer'] } + +function textAnchorsOf(files: readonly AgentStateRulesFile[]): TextAnchor[] { + return files.flatMap((file) => + file.anchors.flatMap(({ when, answer }) => (when.region === 'text' ? [{ when, answer }] : [])) + ) +} + +function compileEach(anchors: readonly TextAnchor[]): TextAnchorFinder[] { + return anchors.map(({ when, answer }) => compileTextAnchor(when, answer)) +} + export function compileTextAnchors(files: readonly AgentStateRulesFile[]): { - blocked: ((window: string) => TextAnchorHit | null)[] - prompts: ((normalized: string) => TextAnchorHit | null)[] + blocked: TextAnchorFinder[] + prompts: TextAnchorFinder[] + holds: TextAnchorFinder[] blockedLiterals: string[] screenProbeBanners: string[] } { - const anchors = files.flatMap((file) => file.textAnchors) + const anchors = textAnchorsOf(files) const blocked = anchors.filter((anchor) => anchor.answer.state === 'blocked') return { - blocked: blocked.map(compileTextAnchor), - prompts: anchors.filter((anchor) => anchor.answer.state !== 'blocked').map(compileTextAnchor), - blockedLiterals: blocked.flatMap((anchor) => - 'lastOf' in anchor.when.find ? [anchor.when.find.lastOf] : [] + blocked: compileEach(blocked), + prompts: compileEach( + anchors.filter(({ answer }) => answer.state !== 'blocked' && answer.state !== 'hold') + ), + holds: compileEach(anchors.filter(({ answer }) => answer.state === 'hold')), + blockedLiterals: blocked.flatMap(({ when }) => + 'lastOf' in when.find ? [when.find.lastOf] : [] ), screenProbeBanners: files.flatMap((file) => file.profile?.screenProbeBanner ?? []) } @@ -84,8 +110,8 @@ export function findBlockedAnchorSignals(window: string): BlockedTextSignal[] { } /** - * The latest live prompt (`live`, idle or working: it proves an earlier startup dialog was - * answered) and the latest idle one (`ready`) that any rule file's anchors find in the text tail. + * The latest live prompt (`live`: an idle or live anchor, which proves an earlier startup + * dialog was answered) and the latest idle one (`ready`) that any rule file's anchors find. */ export function findPromptAnchorIndexes(normalized: string): { live: number | null @@ -106,6 +132,11 @@ export function findPromptAnchorIndexes(normalized: string): { return { live, ready } } +/** Whether any rule file's hold anchor shows: an agent is up but not yet taking input. */ +export function showsHoldAnchor(normalized: string): boolean { + return TEXT_ANCHORS.holds.some((find) => find(normalized) !== null) +} + export function showsScreenProbeBanner(text: string): boolean { const normalized = text.toLowerCase() return TEXT_ANCHORS.screenProbeBanners.some((banner) => normalized.includes(banner)) diff --git a/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts b/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts new file mode 100644 index 00000000000..67fc6063408 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts @@ -0,0 +1,34 @@ +import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' +import { compileTextTest } from './agent-state-rule-matchers' +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import type { + AgentStateRulesFile, + NamedTitlePredicate, + TitleAnchorCondition +} from './agent-state-rules-schema' + +// Why shared code: the same marker also proves OpenCode presence, so both must read it alike. +const NAMED_TITLE_PREDICATES: Record boolean> = { + 'opencode-native-title': isOpenCodeNativeTitle +} + +type TitleAnchorMatcher = (title: string) => boolean + +function compileTitleAnchor(when: TitleAnchorCondition): TitleAnchorMatcher { + return 'predicate' in when.match + ? NAMED_TITLE_PREDICATES[when.match.predicate] + : compileTextTest(when.match) +} + +function compileTitleAnchors(files: readonly AgentStateRulesFile[]): TitleAnchorMatcher[] { + return files.flatMap((file) => + file.anchors.flatMap(({ when }) => (when.region === 'title' ? [compileTitleAnchor(when)] : [])) + ) +} + +const TITLE_ANCHORS = compileTitleAnchors(BUNDLED_AGENT_STATE_RULE_FILES) + +/** Whether any rule file's title anchor marks an idle-classified `title` as an agent's own rest title. */ +export function showsIdleTitleAnchor(title: string): boolean { + return TITLE_ANCHORS.some((matches) => matches(title)) +} diff --git a/src/main/runtime/agent-state-rules/antigravity.json b/src/main/runtime/agent-state-rules/antigravity.json index 5f21364de77..8d178f2bfcc 100644 --- a/src/main/runtime/agent-state-rules/antigravity.json +++ b/src/main/runtime/agent-state-rules/antigravity.json @@ -1,12 +1,12 @@ { "id": "antigravity", "engineVersion": 1, - "profile": { "screenProbeBanner": "antigravity cli" }, - "textAnchors": [ + "profile": { "screenProbeBanner": "antigravity cli", "screenSource": "trusted" }, + "anchors": [ { "id": "text_composer", "why": "Without a trusted grid, the folded text tail is all there is; a trailing caret there also belongs to trust, sign-in and model menus, which the named scan rules out.", - "when": { "find": { "predicate": "antigravity-text-composer" } }, + "when": { "region": "text", "find": { "predicate": "antigravity-text-composer" } }, "answer": { "state": "idle" } } ], diff --git a/src/main/runtime/agent-state-rules/blocked-text-layer.ts b/src/main/runtime/agent-state-rules/blocked-text-layer.ts index 4ea80d1eaf6..9e8d486bb35 100644 --- a/src/main/runtime/agent-state-rules/blocked-text-layer.ts +++ b/src/main/runtime/agent-state-rules/blocked-text-layer.ts @@ -4,6 +4,7 @@ import { startOfLastNonBlankLines } from '../terminal-wait-tail-window' import { BLOCKED_ANCHOR_LITERALS, findBlockedAnchorSignals, + showsHoldAnchor, type BlockedTextSignal } from './agent-state-text-anchors' @@ -39,6 +40,20 @@ export function findTerminalWaitBlockedSignal(fullTail: string): BlockedTextSign return signal === null ? null : { reason: signal.reason, index: signal.index + windowStart } } +/** Whether a ready sign at `readyIndex` still owns the text: no blocker was painted after it. */ +export function isUnblockedAfter(normalized: string, readyIndex: number | null): boolean { + if (readyIndex === null) { + return false + } + const blockedSignal = findTerminalWaitBlockedSignal(normalized) + return blockedSignal === null || blockedSignal.index <= readyIndex +} + +/** Unblocked, and no hold anchor says the agent behind it is still starting: input would land. */ +export function isSettledAfter(normalized: string, readyIndex: number | null): boolean { + return isUnblockedAfter(normalized, readyIndex) && !showsHoldAnchor(normalized) +} + function findBlockedSignalInLiveWindow(normalized: string): BlockedTextSignal | null { const candidates = findStartupDialogBlockedSignals(normalized) const trustIndex = Math.max( diff --git a/src/main/runtime/agent-state-rules/claude.json b/src/main/runtime/agent-state-rules/claude.json new file mode 100644 index 00000000000..61eaa1de037 --- /dev/null +++ b/src/main/runtime/agent-state-rules/claude.json @@ -0,0 +1,27 @@ +{ + "id": "claude", + "engineVersion": 1, + "anchors": [ + { + "id": "idle_title_glyph", + "why": "Claude Code prefixes its title with `✳` at rest; a working title carries a spinner instead.", + "when": { "region": "title", "match": { "regex": "^✳" } }, + "answer": { "state": "idle" } + }, + { + "id": "idle_title_ascii", + "why": "Claude Code's ASCII rest prefix, `* ` (`. ` while working).", + "when": { "region": "title", "match": { "regex": "^\\* " } }, + "answer": { "state": "idle" } + } + ], + "rules": [ + { + "id": "idle_title", + "why": "Claude announces rest with its own `✳` title, while a shell auto-title (`claude`, `claude ~/repo`) names it from launch, over the workspace-trust dialog or a busy turn alike, so a bare name counts only once quiet (#6011).", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": true } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/cline.json b/src/main/runtime/agent-state-rules/cline.json index 51c3013e692..8604db7dac5 100644 --- a/src/main/runtime/agent-state-rules/cline.json +++ b/src/main/runtime/agent-state-rules/cline.json @@ -1,7 +1,8 @@ { "id": "cline", "engineVersion": 1, - "textAnchors": [], + "profile": { "screenSource": "trusted" }, + "anchors": [], "rules": [ { "id": "composer_ready", diff --git a/src/main/runtime/codex-terminal-readiness.ts b/src/main/runtime/agent-state-rules/codex-screen-predicates.ts similarity index 52% rename from src/main/runtime/codex-terminal-readiness.ts rename to src/main/runtime/agent-state-rules/codex-screen-predicates.ts index 7ce60fa9933..46c4837681c 100644 --- a/src/main/runtime/codex-terminal-readiness.ts +++ b/src/main/runtime/agent-state-rules/codex-screen-predicates.ts @@ -1,3 +1,5 @@ +import { isUnblockedAfter } from './blocked-text-layer' + // Why both shapes: 0.150-0.157 paint `model: loading` in a box, 0.158 a bare `loading` under the title. const CODEX_HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m // Why a line cap: 0.158 draws no box, so nothing else ends its header before the chat. @@ -23,45 +25,29 @@ function findCodexHeader(screen: string): { index: number; text: string } | null return { index, text } } -/** The 0.150-0.157 header, which only a grid reassembles (see isCodexScreenHeaderReady). */ -export function findCodexScreenReadyPromptIndex(screen: string): number | null { +/** + * The named screen predicate `codex-header-ready`: the 0.150-0.157 header, loaded, with no blocker + * painted below it. Why the screen: Codex repaints that header by cell diff (`ESC[5;3Hdir + * ESC[5;7Hctory:`), which only a grid reassembles; the line-folded wait text reads `dirctory:`. + */ +export function isCodexHeaderReadyScreen(screen: string): boolean { const header = findCodexHeader(screen) - return header !== null && + return ( + header !== null && header.text.includes('model:') && header.text.includes('directory:') && - !CODEX_HEADER_LOADING_RE.test(header.text) - ? header.index - : null -} - -// Why the text copy: 0.157 leaves its alternate screen while it starts its daemon, so the live -// screen shows no header then, while the text copy keeps the provisional one until the live chat -// paints its footer after it (a later model repaint rewrites only the value, never the label). -// Why `·`: every live footer row draws one (status row, `← for agents · ?`, `⚠ N warning · f2`); -// startup dialogs draw one too, which is why startup-dialog-blocked-signals.ts matches them first. -export function isCodexProvisionalStartupText(normalized: string): boolean { - const headerIndex = normalized.lastIndexOf('openai codex') - if (headerIndex === -1) { - return false - } - const loading = /model:\s+loading/.exec(normalized.slice(headerIndex)) - return loading !== null && !normalized.includes('·', headerIndex + loading.index) -} - -// Why: Codex repaints its whole screen, header included, once a startup dialog closes, and the -// dialog never draws the header; 0.158's header has no labels, so the header alone marks it answered. -export function findCodexHeaderIndex(normalized: string): number | null { - const index = normalized.lastIndexOf('openai codex (v') - return index === -1 ? null : index + !CODEX_HEADER_LOADING_RE.test(header.text) && + isUnblockedAfter(screen, header.index) + ) } /** - * Tier 1b, codex panes only: the empty composer with no busy status row just above it and no - * header load. Codex 0.158 dropped `model:`/`directory:`, and a long session scrolls the header - * away, so this is its only version-stable rest body. No dialog check: every Codex dialog - * replaces the composer, while an answer ending "Would you like to…?" must not block the lane. - * The mid-turn guard is the caller's quiescence, fed by the ~100 ms title spinner and status - * timer; `tui.animations=false` (set by a screen reader), `tui.effects.progress=false`, or a + * The named screen predicate `codex-composer-ready`: the empty composer with no busy status row + * just above it and no header load. Codex 0.158 dropped `model:`/`directory:`, and a long session + * scrolls the header away, so this is its only version-stable rest body. No dialog check: every + * Codex dialog replaces the composer, while an answer ending "Would you like to…?" must not block + * the lane. The mid-turn guard is quiescence, fed by the ~100 ms title spinner and status timer; + * `tui.animations=false` (set by a screen reader), `tui.effects.progress=false`, or a * `tui.terminal_title` without activity/spinner removes it. */ export function isCodexComposerReadyScreen(screen: string): boolean { diff --git a/src/main/runtime/agent-state-rules/codex.json b/src/main/runtime/agent-state-rules/codex.json new file mode 100644 index 00000000000..b52d85a8d6f --- /dev/null +++ b/src/main/runtime/agent-state-rules/codex.json @@ -0,0 +1,71 @@ +{ + "id": "codex", + "engineVersion": 1, + "profile": { "screenSource": "live" }, + "anchors": [ + { + "id": "ready_header", + "why": "The startup header with its model and directory labels is the stable ready text across versions; Codex prints permissions only in YOLO mode. Its last occurrence counts, so a header repainted after a dialog proves the dialog was answered.", + "when": { + "region": "text", + "find": { "lastOf": "openai codex" }, + "after": { "all": [{ "contains": "model:" }, { "contains": "directory:" }] } + }, + "answer": { "state": "idle" } + }, + { + "id": "header", + "why": "Codex repaints its whole screen, header included, once a startup dialog closes, and no dialog draws the header. 0.158's header has no labels, so the header alone proves an earlier dialog was answered, but not that Codex is ready.", + "when": { "region": "text", "find": { "lastOf": "openai codex (v" } }, + "answer": { "state": "live" } + }, + { + "id": "provisional_startup", + "why": "0.157 paints a provisional header (`model: loading`) while its daemon starts and discards input typed behind it, so no ready text may settle a wait until the live chat paints a footer row, each of which draws a `·`. Read from the text because 0.157 leaves its alternate screen while the daemon starts; a later model repaint rewrites only the value, never the label.", + "when": { + "region": "text", + "find": { "lastOf": "openai codex" }, + "after": { + "all": [{ "regex": "model:\\s+loading" }], + "none": [{ "regex": "model:\\s+loading[\\s\\S]*·" }] + } + }, + "answer": { "state": "hold" } + } + ], + "rules": [ + { + "id": "header_ready", + "why": "The 0.150-0.157 header, loaded and with no blocker below it, read from the live screen because Codex repaints it by cell diff, which only a grid reassembles. Quiet because the header stays painted through a turn.", + "priority": 500, + "when": { "region": "screen", "predicate": "codex-header-ready" }, + "answer": { "state": "idle", "strength": "strong", "requiresQuiet": true } + }, + { + "id": "composer_ready", + "why": "The empty composer with no busy status row above it: 0.158 dropped the header labels and a long session scrolls the header away, so this is the version-stable rest screen. Quiet because Codex keeps the composer painted mid-turn, and skipped without an output clock, where nothing could tell a running turn apart.", + "priority": 500, + "when": { "region": "screen", "predicate": "codex-composer-ready" }, + "answer": { + "state": "idle", + "strength": "strong", + "requiresQuiet": true, + "withoutClock": "skip" + } + }, + { + "id": "text_header_ready", + "why": "The ready header in the text tail, settled. Quiet because it stays in the tail through a turn; an idle Codex titles its pane with its cwd, so no title says it is at rest.", + "priority": 500, + "when": { "region": "text", "anchor": "ready_header" }, + "answer": { "state": "idle", "strength": "strong", "requiresQuiet": true } + }, + { + "id": "idle_title", + "why": "A shell auto-title names Codex from launch, over a startup dialog or a busy turn alike, while its hooks drive an explicit `Codex ready` title at rest, so a bare name counts only once quiet (#6011).", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": true } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/cursor.json b/src/main/runtime/agent-state-rules/cursor.json index 5cce30d46f6..050f98daf9f 100644 --- a/src/main/runtime/agent-state-rules/cursor.json +++ b/src/main/runtime/agent-state-rules/cursor.json @@ -1,11 +1,12 @@ { "id": "cursor", "engineVersion": 1, - "textAnchors": [ + "anchors": [ { "id": "approval_menu", "why": "cursor-agent has no approval hook, so its key-bound menu is the only authority. Bounded to the last 8 lines because an answered menu stays in scrollback, and each choice must end in a selectable key because narration can repeat the menu's wording.", "when": { + "region": "text", "find": { "lastOf": "run this command?" }, "withinLastLines": 8, "lines": { @@ -32,15 +33,17 @@ "id": "prompt_busy", "why": "The banner's last occurrence skips the trust dialog's own `Cursor Agent` text; `→` is the persistent input prompt. cursor-agent emits no idle title, so a braille spinner after the banner is the only busy sign; a busy prompt still proves an earlier dialog was answered.", "when": { + "region": "text", "find": { "lastOf": "cursor agent" }, "after": { "all": [{ "contains": "→" }, { "regex": "[⠁-⣿]" }] } }, - "answer": { "state": "working" } + "answer": { "state": "live" } }, { "id": "prompt_idle", "why": "The same prompt with no spinner after the banner.", "when": { + "region": "text", "find": { "lastOf": "cursor agent" }, "after": { "all": [{ "contains": "→" }], "none": [{ "regex": "[⠁-⣿]" }] } }, diff --git a/src/main/runtime/agent-state-rules/gemini.json b/src/main/runtime/agent-state-rules/gemini.json new file mode 100644 index 00000000000..45d62392baa --- /dev/null +++ b/src/main/runtime/agent-state-rules/gemini.json @@ -0,0 +1,21 @@ +{ + "id": "gemini", + "engineVersion": 1, + "anchors": [ + { + "id": "idle_title", + "why": "Gemini CLI marks rest with `◇` in its title (`✦` while working, `✋` on a permission).", + "when": { "region": "title", "match": { "contains": "◇" } }, + "answer": { "state": "idle" } + } + ], + "rules": [ + { + "id": "name_title", + "why": "Its `◇` rest title would justify asking a name-only title for quiet, but Gemini is not yet held to it: a name-only title settles once held, with no quiet asked.", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": false } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/omp.json b/src/main/runtime/agent-state-rules/omp.json new file mode 100644 index 00000000000..a0311486cb7 --- /dev/null +++ b/src/main/runtime/agent-state-rules/omp.json @@ -0,0 +1,14 @@ +{ + "id": "omp", + "engineVersion": 1, + "anchors": [], + "rules": [ + { + "id": "idle_title", + "why": "Its `π - ` rest title is Pi's title anchor (pi.json), which every pane reads. Orca writes an explicit `OMP ready` title at rest, while a shell auto-title names OMP from launch, mid-turn too, so a bare name counts only once quiet (#6011).", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": true } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/opencode.json b/src/main/runtime/agent-state-rules/opencode.json new file mode 100644 index 00000000000..47316f368c9 --- /dev/null +++ b/src/main/runtime/agent-state-rules/opencode.json @@ -0,0 +1,21 @@ +{ + "id": "opencode", + "engineVersion": 1, + "anchors": [ + { + "id": "native_title", + "why": "OpenCode's own session title (`OC | `, after an optional wrapper label or status glyph). It unblocks waits on hookless remote panes; guarded writes corroborate it. Named because presence detection reads the same marker.", + "when": { "region": "title", "match": { "predicate": "opencode-native-title" } }, + "answer": { "state": "idle" } + } + ], + "rules": [ + { + "id": "name_title", + "why": "OpenCode owns its session title, so Orca writes no `ready` title over it; its name is its only other rest sign, so it settles once held, with no quiet asked.", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": false } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/opencode2.json b/src/main/runtime/agent-state-rules/opencode2.json new file mode 100644 index 00000000000..d1d543d08fe --- /dev/null +++ b/src/main/runtime/agent-state-rules/opencode2.json @@ -0,0 +1,14 @@ +{ + "id": "opencode2", + "engineVersion": 1, + "anchors": [], + "rules": [ + { + "id": "name_title", + "why": "Its `OC |` rest title is OpenCode's title anchor (opencode.json), which every pane reads. Its name is its only other rest sign, so it settles once held, with no quiet asked.", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": false } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/pi.json b/src/main/runtime/agent-state-rules/pi.json new file mode 100644 index 00000000000..67b9eb3221a --- /dev/null +++ b/src/main/runtime/agent-state-rules/pi.json @@ -0,0 +1,21 @@ +{ + "id": "pi", + "engineVersion": 1, + "anchors": [ + { + "id": "idle_title", + "why": "Pi, and OMP after it, title the pane `π - ` at rest (`π ⠋ ` while working).", + "when": { "region": "title", "match": { "regex": "^π - " } }, + "answer": { "state": "idle" } + } + ], + "rules": [ + { + "id": "idle_title", + "why": "Orca writes an explicit `Pi ready` title at rest, while a shell auto-title names Pi from launch, mid-turn too, so a bare name counts only once quiet (#6011).", + "priority": 100, + "when": { "region": "title", "status": "idle" }, + "answer": { "state": "idle", "strength": "weak", "requiresQuiet": true } + } + ] +} diff --git a/src/main/runtime/agent-state-rules/prime-agent.json b/src/main/runtime/agent-state-rules/prime-agent.json index c38dfd4cc3f..ffbcfd076ac 100644 --- a/src/main/runtime/agent-state-rules/prime-agent.json +++ b/src/main/runtime/agent-state-rules/prime-agent.json @@ -1,7 +1,8 @@ { "id": "prime-agent", "engineVersion": 1, - "textAnchors": [], + "profile": { "screenSource": "trusted" }, + "anchors": [], "rules": [ { "id": "composer_ready", diff --git a/src/main/runtime/agent-state-rules/unknown-pane.json b/src/main/runtime/agent-state-rules/unknown-pane.json new file mode 100644 index 00000000000..8cb0e2d3c04 --- /dev/null +++ b/src/main/runtime/agent-state-rules/unknown-pane.json @@ -0,0 +1,15 @@ +{ + "id": "unknown-pane", + "engineVersion": 1, + "profile": { "screenSource": "live" }, + "anchors": [], + "rules": [ + { + "id": "codex_header_ready", + "why": "An adopted pane can run Codex without Orca knowing it. Codex's loaded 0.150-0.157 header on the live screen, with no blocker below it, settles such a pane at once (unlike a Codex pane, which holds it to quiet).", + "priority": 500, + "when": { "region": "screen", "predicate": "codex-header-ready" }, + "answer": { "state": "idle", "strength": "strong", "requiresQuiet": false } + } + ] +} diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index 8a412841ae0..8a6c6cf211e 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -8,7 +8,7 @@ import { replayTranscript, type TranscriptReplayFrame } from './agent-transcript-replay-test-harness' -import { isCodexComposerReadyScreen } from './codex-terminal-readiness' +import { isCodexComposerReadyScreen } from './agent-state-rules/codex-screen-predicates' import { detectTerminalWaitBlockedReason, isKnownReadyPromptBody, diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index 930ff72e6f0..4add11ee4f4 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -14,7 +14,7 @@ import { isKnownReadyPromptBody, isKnownReadyPromptSettled } from './terminal-wait-detection' -import { hasScreenRules } from './agent-state-rules/agent-state-rules-engine' +import { readsTrustedScreen } from './agent-state-rules/agent-state-rules-engine' import { restoreProjectedComposerDraft } from './orca-runtime-terminal-projection' import type { RuntimeTerminalWait, @@ -55,7 +55,7 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith if (providerTimeoutMs < 1) { return } - const screenRule = hasScreenRules(agent) + const screenRule = readsTrustedScreen(agent) void withTimeout( this.readTerminal(waiter.handle, screenRule ? { screen: true } : {}, { timeoutMs: providerTimeoutMs, diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index 80c665ebaec..9087d8ad7db 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -9,7 +9,7 @@ import { buildTerminalWaitResult, getTerminalState } from './terminal-wait-results' -import { hasScreenRules } from './agent-state-rules/agent-state-rules-engine' +import { readsTrustedScreen } from './agent-state-rules/agent-state-rules-engine' import { showsScreenProbeBanner } from './agent-state-rules/agent-state-text-anchors' import { buildTerminalWaitText } from './terminal-wait-tail-state' import { @@ -28,8 +28,8 @@ import type { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-re /** * A pane with no retained bytes and no status has only its provider's screen to read, and one * whose tail shows a rule file's `profile.screenProbeBanner` is probed as before screen rules. So - * is a clockless pane with screen rules whatever its status: a re-attached pane's own model can be - * untrusted. A clocked one settles through the poll, once quiet. + * is a clockless pane whose rules read the trusted screen, whatever its status: a re-attached + * pane's own model can be untrusted. A clocked one settles through the poll, once quiet. */ function shouldProbeVisibleScreen( paneAgent: TuiAgent | null, @@ -39,7 +39,7 @@ function shouldProbeVisibleScreen( return ( (record.lastAgentStatus === null && waitText.length === 0) || showsScreenProbeBanner(waitText) || - (hasScreenRules(paneAgent) && record.lastOutputAt === null) + (readsTrustedScreen(paneAgent) && record.lastOutputAt === null) ) } diff --git a/src/main/runtime/startup-dialog-blocked-signals.ts b/src/main/runtime/startup-dialog-blocked-signals.ts index e964205a9cc..f07cce20343 100644 --- a/src/main/runtime/startup-dialog-blocked-signals.ts +++ b/src/main/runtime/startup-dialog-blocked-signals.ts @@ -1,6 +1,6 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' -// Why rows, from each dialog's first `·` on: codex-terminal-readiness.ts takes a `·` after the +// Why rows, from each dialog's first `·` on: codex.json's provisional_startup takes a `·` after the // startup header for the live chat's footer, so each dialog must be matched by the time that `·` // lands. Why not headings: Codex 0.157+ paints them by cell diff over its startup screen, so the // text copy can lose letters and spaces (`updat available`); these rows are fixed literals. diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index 436e6cb0dbf..fb24d21c124 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -1,48 +1,31 @@ import { isQoderComposerReady } from './qoder-terminal-readiness' import { memoizeTitleClassification } from '../../shared/terminal-title-classification-memo' -import { - detectAgentStatusFromTitle, - isOpenCodeNativeTitle, - type AgentStatus -} from '../../shared/agent-detection' +import { detectAgentStatusFromTitle, type AgentStatus } from '../../shared/agent-detection' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import type { TuiAgent } from '../../shared/tui-agent' import { evaluateAgentStateRules, + hasQuietReadyRules, + holdsReadyTextToQuiet, type AgentStateVerdict } from './agent-state-rules/agent-state-rules-engine' import { findPromptAnchorIndexes } from './agent-state-rules/agent-state-text-anchors' -import { findTerminalWaitBlockedSignal } from './agent-state-rules/blocked-text-layer' +import { showsIdleTitleAnchor } from './agent-state-rules/agent-state-title-anchors' import { - findCodexHeaderIndex, - findCodexScreenReadyPromptIndex, - isCodexComposerReadyScreen, - isCodexProvisionalStartupText -} from './codex-terminal-readiness' + findTerminalWaitBlockedSignal, + isSettledAfter, + isUnblockedAfter +} from './agent-state-rules/blocked-text-layer' +// Why agent-agnostic: Orca's own ` ready` titles, and any agent title stating rest in words. const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i -const CLAUDE_IDLE_PREFIX = '\u2733' -const GEMINI_IDLE_PREFIX = '\u25c7' -const PI_IDLE_PREFIX = '\u03c0 - ' function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null { const status = detectAgentStatusFromTitle(title) - if (status !== 'idle') { - return null - } // Why: launch titles like "Codex YOLO" contain an agent name but aren't readiness signals; terminal.wait needs explicit idle evidence. - if ( - EXPLICIT_IDLE_TITLE_RE.test(title) || - // Why: unblock hookless remote waits; guarded writes corroborate this marker. - isOpenCodeNativeTitle(title) || - title.startsWith(CLAUDE_IDLE_PREFIX) || - title.startsWith('* ') || - title.includes(GEMINI_IDLE_PREFIX) || - title.startsWith(PI_IDLE_PREFIX) - ) { - return 'idle' - } - return null + return status === 'idle' && (EXPLICIT_IDLE_TITLE_RE.test(title) || showsIdleTitleAnchor(title)) + ? 'idle' + : null } /** @@ -55,32 +38,27 @@ export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus | export function isKnownReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() - return isReadyPromptUnblocked(normalized, findKnownReadyPromptIndex(normalized)) + return isUnblockedAfter(normalized, findPromptAnchorIndexes(normalized).ready) } /** * The ready-prompt text rules for a pane about to take input. Unlike isKnownReadyPromptPreview - * (agent presence), Codex's provisional startup header does not count: 0.157 discards input typed - * behind it while its daemon starts. + * (agent presence), nothing counts while a hold anchor shows (Codex's provisional startup header): + * 0.157 discards input typed behind it while its daemon starts. */ export function isKnownReadyPromptSettled(preview: string): boolean { const normalized = preview.toLowerCase() - return isReadyPromptSettled(normalized, findKnownReadyPromptIndex(normalized)) -} - -function isReadyPromptSettled(normalized: string, readyIndex: number | null): boolean { - return ( - isReadyPromptUnblocked(normalized, readyIndex) && !isCodexProvisionalStartupText(normalized) - ) + return isSettledAfter(normalized, findPromptAnchorIndexes(normalized).ready) } /** - * Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the live emulator's - * visible grid, or null when the runtime has no trustworthy one. + * Tier 1 body evidence for every tui-idle site. `readScreenLines` yields the screen the agent's + * rules read, or null when the runtime has no trustworthy one. * - * Why not for a clocked Codex or screen-ruled pane: its header or composer is also painted - * mid-turn, so isQuietReadyScreenBody holds it to quiescence instead. - * Why a clockless pane keeps it: quiescence needs an output clock, which a restored pane lacks. + * Why the agent's own answer is final: a screen refusal must shut the shared text lane too. + * A strong rule held to quiet counts here only on a clockless pane, which cannot measure quiet; + * on a clocked one isQuietReadyScreenBody holds it to quiescence instead, and an agent whose + * own ready text is held to quiet takes no shared text either. */ export function isKnownReadyPromptBody( waitText: string, @@ -91,49 +69,37 @@ export function isKnownReadyPromptBody( if (agent === 'qoder') { return isQoderComposerReady(readScreenLines()) } - const ruled = evaluateAgentStateRules(agent, { readScreenLines }) + // Why before the rules: such an agent settles only on the quiet lane while it has a clock. + if (hasOutputClock && holdsReadyTextToQuiet(agent)) { + return false + } + const ruled = evaluateAgentStateRules(agent, { + readScreenLines, + readText: () => waitText.toLowerCase(), + hasOutputClock + }) if (ruled !== null) { return isStrongIdle(ruled) && (!ruled.requiresQuiet || !hasOutputClock) } - if (agent === 'codex' && hasOutputClock) { - return false - } - if (isKnownReadyPromptSettled(waitText)) { - return true - } - // Why the agent gate: another agent's screen can merely mention "OpenAI Codex". - if (agent !== null && agent !== 'codex') { - return false - } - const screen = readScreen(readScreenLines) - return screen !== null && isCodexScreenHeaderReady(screen) + return isKnownReadyPromptSettled(waitText) } /** - * Tier 1b body evidence: a ready screen from an agent with no title rest signal. Unlike tier 1 - * it only proves the TUI is up, so the ranking holds it to quiescence. - * Why identified panes only: a `cat`ed transcript or pager in an unknown pane can show the composer. + * Tier 1b body evidence: a ready screen or text the agent also paints mid-turn, so the ranking + * holds it to quiescence. Why identified panes only for Muse: a `cat`ed transcript or pager in an + * unknown pane can show the composer. */ export function isQuietReadyScreenBody( waitText: string, agent: TuiAgent | null, readScreenLines: () => readonly string[] | null ): boolean { - if (agent === 'codex') { - const screen = readScreen(readScreenLines) - if ( - screen !== null && - (isCodexComposerReadyScreen(screen) || isCodexScreenHeaderReady(screen)) - ) { - return true - } - // Why the provisional veto here too: a daemon start can stay quiet past the quiescence window. - const normalized = waitText.toLowerCase() - return isReadyPromptSettled(normalized, findCodexReadyPromptIndex(normalized)) - } - const ruled = evaluateAgentStateRules(agent, { readScreenLines }) - if (ruled !== null && isStrongIdle(ruled) && ruled.requiresQuiet) { - return true + if (hasQuietReadyRules(agent)) { + const ruled = evaluateAgentStateRules(agent, { + readScreenLines, + readText: () => waitText.toLowerCase() + }) + return ruled !== null && isStrongIdle(ruled) && ruled.requiresQuiet } return (agent === null || agent === 'muse') && isMuseReadyPromptPreview(waitText) } @@ -144,31 +110,9 @@ function isStrongIdle( return verdict.state === 'idle' && verdict.strength === 'strong' } -/** - * Why the screen: Codex repaints its 0.150-0.157 header by cell diff (`ESC[5;3Hdir - * ESC[5;7Hctory:`), which only a grid reassembles — the line-folded wait text reads `dirctory:`. - * Why it can only add readiness: a grid out of step with the PTY (size mismatch, resize - * mid-paint) garbles the header, so the text rule keeps every verdict it gives on its own. - */ -function isCodexScreenHeaderReady(screen: string): boolean { - return isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen)) -} - -function readScreen(readScreenLines: () => readonly string[] | null): string | null { - return readScreenLines()?.join('\n').toLowerCase() ?? null -} - -function isReadyPromptUnblocked(normalized: string, readyIndex: number | null): boolean { - if (readyIndex === null) { - return false - } - const blockedSignal = findTerminalWaitBlockedSignal(normalized) - return blockedSignal === null || blockedSignal.index <= readyIndex -} - export function isMuseReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() - return isReadyPromptUnblocked(normalized, findMuseReadyPromptIndex(normalized)) + return isUnblockedAfter(normalized, findMuseReadyPromptIndex(normalized)) } export function detectTerminalWaitBlockedReason( @@ -193,26 +137,11 @@ export function findActionableTerminalWaitBlockedSignal( } // Why: a live prompt (idle OR busy) proves the startup modal was dismissed, so a mid-run Cursor lane stops reporting stale trust hits. -// Why rule-file anchors beside Codex and Muse: those two have not moved to agent-state-rules/ yet. +// Why Muse beside the rule-file anchors: Muse has not moved to agent-state-rules/ yet. function findDismissedStartupModalIndex(normalized: string): number | null { - return latestIndex([ - findCodexReadyPromptIndex(normalized), - findCodexHeaderIndex(normalized), - findPromptAnchorIndexes(normalized).live, - findMuseReadyPromptIndex(normalized) - ]) -} - -function findKnownReadyPromptIndex(normalized: string): number | null { - return latestIndex([ - findCodexReadyPromptIndex(normalized), - findPromptAnchorIndexes(normalized).ready - ]) -} - -function latestIndex(indexes: readonly (number | null)[]): number | null { - const found = indexes.filter((index): index is number => index !== null) - return found.length > 0 ? Math.max(...found) : null + const live = findPromptAnchorIndexes(normalized).live + const muse = findMuseReadyPromptIndex(normalized) + return live === null || muse === null ? (live ?? muse) : Math.max(live, muse) } // Why: Muse titles its OSC with the bare cwd and never updates it, so only the body can @@ -227,13 +156,3 @@ function findMuseReadyPromptIndex(normalized: string): number | null { ? headerIndex : null } - -function findCodexReadyPromptIndex(normalized: string): number | null { - const headerIndex = normalized.lastIndexOf('openai codex') - if (headerIndex === -1) { - return null - } - const readySegment = normalized.slice(headerIndex) - // Why: Codex prints permissions only in YOLO mode; the stable ready header is OpenAI Codex + model + directory. - return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null -} diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 4eadef30c16..43c9789af12 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -6,7 +6,7 @@ import { getTuiAgentRestSignal } from '../../shared/tui-agent-rest-signal' import { isKnownReadyPromptBody } from './terminal-wait-detection' import { evaluateAgentStateRules, - hasScreenRules + readsTrustedScreen } from './agent-state-rules/agent-state-rules-engine' import { evaluateTuiIdle, @@ -212,7 +212,7 @@ describe('rest signal agrees with the lanes that can settle a wait', () => { ) const quietScreenBody = hasQuietReadyScreen(record(), agent, () => true, QUIESCENCE_MS) // Why a screen-ruled `none` is sound: its screen shuts the quiet lane whenever one is readable. - if (hasScreenRules(agent)) { + if (readsTrustedScreen(agent)) { const refused = ['> not an idle composer'] const ruled = (screen: readonly string[] | null) => evaluateAgentStateRules(agent, { readScreenLines: () => screen }) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index dc3edc7d641..1a9fa52bad4 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -18,7 +18,9 @@ import { } from './terminal-wait-detection' import { evaluateAgentStateRules, - hasScreenRules, + hasQuietReadyRules, + idleTitleRequiresQuiet, + readsTrustedScreen, type AgentStateVerdict } from './agent-state-rules/agent-state-rules-engine' @@ -34,9 +36,9 @@ import { * 0. BLOCKED — the tail shows a prompt waiting on the user. * 1. STRONG READY — the agent states it is ready: an explicit idle marker in its own * title, or a known ready-prompt body. - * 1b. QUIET READY SCREEN — Muse and an idle Codex title no rest signal, and agents whose - * rules (agent-state-rules/) read an idle composer they also paint mid-turn, so their - * ready-screen body is believed only once quiet. + * 1b. QUIET READY SCREEN — Muse titles no rest signal, and agents whose rules + * (agent-state-rules/) read a ready screen or text they also paint mid-turn (Codex's + * header and composer), so that body is believed only once quiet. * 2. WORKING — a fresh first-party agent status (OSC 9999) saying working/blocked/ * waiting, or a working title. The agent's own account of itself outranks anything * inferred. @@ -120,20 +122,16 @@ export function hasFreshWorkingFirstPartyStatus(status: FirstPartyAgentStatus): return isFreshNonDoneAgentStatus(status ?? undefined) } -/** Agents that paint their own explicit rest title (Claude's `✳`). Gemini's `◇` would qualify - * but is not yet held to it. */ -const NATIVE_EXPLICIT_IDLE_TITLE_AGENTS: ReadonlySet = new Set(['claude']) - /** * Whether a name-only title from `agent` must be corroborated by a quiet stream. * - * Only for agents that announce rest with an explicit title of their own: the hook-driven - * `Codex ready` / `Devin ready`, or Claude's `✳`. For them the bare name is not a rest signal — - * a shell auto-title (`claude`, `claude ~/repo`) names the agent from the moment it starts, over - * a start-up dialog or a busy turn alike. Grok, Copilot, Aider, Mimo, agy and OpenCode emit - * their NAME and nothing more at rest, so holding them to it leaves no settle signal at all: - * a real idle Grok pane repaints its banner about four times a second forever, so the stream - * never quiesces and the wait runs to timeout. + * An agent's own idle-title rule (agent-state-rules/) answers. Without one, only agents whose + * hooks drive an explicit ` ready` title are held to it: for them the bare name is not a + * rest signal, since a shell auto-title names the agent from the moment it starts, over a + * start-up dialog or a busy turn alike. Grok, Copilot, Aider, Mimo and agy emit their NAME and + * nothing more at rest, so holding them to it leaves no settle signal at all: a real idle Grok + * pane repaints its banner about four times a second forever, so the stream never quiesces and + * the wait runs to timeout. */ export function nameOnlyIdleNeedsCorroboration( agent: TuiAgent | null | undefined, @@ -142,10 +140,11 @@ export function nameOnlyIdleNeedsCorroboration( // Why the title fallback: an adopted pane carries no launch metadata, but its // name-only title is exactly the thing that names the agent. const resolved = agent ?? (title ? resolveExplicitTerminalTitleAgentType(title) : null) + if (resolved === null) { + return false + } return ( - resolved !== null && - (NATIVE_EXPLICIT_IDLE_TITLE_AGENTS.has(resolved) || - getSyntheticAgentTerminalTitle(resolved, 'done') !== null) + idleTitleRequiresQuiet(resolved) ?? getSyntheticAgentTerminalTitle(resolved, 'done') !== null ) } @@ -204,7 +203,7 @@ export type TuiIdleEvaluationInput = { * (~11us and a multi-KB string on a full tail); the title check below usually answers * first, and then none of that has to happen at all. */ readPositiveBodyEvidence: () => boolean - /** Tier 1b body evidence: a Muse, Codex or screen-ruled ready screen. Thunk, as above. */ + /** Tier 1b body evidence: a Muse ready screen, or a rule-file one held to quiet. Thunk, as above. */ readQuietReadyBodyEvidence: () => boolean /** The agent's own rules' answer; any answer shuts the lanes that cannot see its screen. */ readAgentRuleVerdict: () => AgentStateVerdict | null @@ -226,8 +225,6 @@ const READY_STRONG: TuiIdleVerdict = { kind: 'ready-strong' } const READY_WEAK: TuiIdleVerdict = { kind: 'ready-weak' } const WORKING: TuiIdleVerdict = { kind: 'working' } -const QUIET_READY_SCREEN_AGENTS: ReadonlySet = new Set(['muse', 'codex']) - /** * Tier 1b: a ready screen in the body, believed only once the stream has gone quiet. * @@ -235,9 +232,9 @@ const QUIET_READY_SCREEN_AGENTS: ReadonlySet = new Set(['muse', 'codex * the cwd (plus a thread name) and no agent name, so neither the explicit-idle nor the * sustained-title lane can fire. The ready screen proves the TUI is up; the quiescence * demand keeps a mid-turn streaming pane from satisfying, mirroring the tier-3 lane's - * positive-evidence-plus-quiet shape. Scoped to those agents, the screen-ruled ones, and - * agent-unknown panes (which read only Muse's screen): another agent's scrollback quoting them - * must not settle its wait. + * positive-evidence-plus-quiet shape. Scoped to Muse, agents with quiet ready rules, and + * agent-unknown panes (which read only Muse's screen here): another agent's scrollback quoting + * them must not settle its wait. */ export function hasQuietReadyScreen( record: TuiIdleEvidenceRecord, @@ -245,7 +242,7 @@ export function hasQuietReadyScreen( readBodyEvidence: () => boolean, quiescenceMs: number ): boolean { - if (agent && !QUIET_READY_SCREEN_AGENTS.has(agent) && !hasScreenRules(agent)) { + if (agent && agent !== 'muse' && !hasQuietReadyRules(agent)) { return false } // Why: same rule as the tier-3 lane — without an output clock there is no @@ -308,7 +305,8 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { if (input.record.lastAgentStatus === 'working') { return WORKING } - // Why: a name-only title and a quiet process cannot see the picker or prompt the screen refused. + // Why here: a name-only title and a quiet process cannot see the picker or prompt the screen + // refused, and an agent's own idle-title rule replaces the sustained-title lane below. const ruled = input.readAgentRuleVerdict() if (ruled !== null) { return isSettledWeakIdle(ruled, input.record, input.quiescenceMs) @@ -349,20 +347,37 @@ export type TuiIdleEvidenceSource = { getPaneAgent(ptyId: string | null | undefined): TuiAgent | null getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus readScreenLines(ptyId: string | null | undefined): readonly string[] | null - /** The painted rows on the PTY's own grid, which only agents with screen rules read. Absent, - * they have no trustworthy screen. */ + /** The painted rows on the PTY's own grid, which only agents whose rules read the trusted + * screen use. Absent, they have no trustworthy screen. */ readScreenRuledLines?(ptyId: string | null | undefined): readonly string[] | null } -// Why per agent table: every other agent keeps the screen it read before screen rules existed. +// Why per agent: every other agent keeps the live screen its rules were recorded against. +// Why read once: several lanes consult the rules, and one evaluation sees one screen. function screenReader( source: TuiIdleEvidenceSource, agent: TuiAgent | null, ptyId: string | null | undefined ): () => readonly string[] | null { - return hasScreenRules(agent) + let lines: readonly string[] | null | undefined + const read = readsTrustedScreen(agent) ? () => source.readScreenRuledLines?.(ptyId) ?? null : () => source.readScreenLines(ptyId) + return () => (lines === undefined ? (lines = read()) : lines) +} + +function readAgentRuleVerdict( + agent: TuiAgent | null, + record: TuiIdleEvidenceRecord, + readScreenLines: () => readonly string[] | null, + waitText: () => string +): AgentStateVerdict | null { + return evaluateAgentStateRules(agent, { + readScreenLines, + readText: () => waitText().toLowerCase(), + readTitleStatus: () => record.lastAgentStatus, + hasOutputClock: record.lastOutputAt !== null + }) } function lazyWaitText(readWaitText: () => string): () => string { @@ -385,7 +400,7 @@ export function leafTuiIdleEvidence( readPositiveBodyEvidence: () => isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readAgentRuleVerdict: () => evaluateAgentStateRules(agent, { readScreenLines: readScreen }), + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText), agent, firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), quiescenceMs: source.quiescenceMs @@ -407,7 +422,7 @@ export function ptyTuiIdleEvidence( (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readAgentRuleVerdict: () => evaluateAgentStateRules(agent, { readScreenLines: readScreen }), + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), agent, firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), quiescenceMs: source.quiescenceMs diff --git a/src/main/ssh/orcad-runtime-decommission.ts b/src/main/ssh/orcad-runtime-decommission.ts index ffb8d36954c..4eb9f335fa0 100644 --- a/src/main/ssh/orcad-runtime-decommission.ts +++ b/src/main/ssh/orcad-runtime-decommission.ts @@ -7,7 +7,10 @@ import type { OrcadManagedStopResult } from '../../shared/orcad-managed-runtime' import { removeManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' -import type { KnownRuntimeEnvironment, OrcadDeploymentLink } from '../../shared/runtime-environments' +import type { + KnownRuntimeEnvironment, + OrcadDeploymentLink +} from '../../shared/runtime-environments' import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery' import { withStaleOrcadActivationRecoveryLock } from './orcad-activation-lock' import { readOrcadActivationTransaction } from './orcad-activation-transaction-store' diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts index 5ed3a8e16f5..712d0d67ae0 100644 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ b/src/main/ssh/orcad-runtime-maintenance.ts @@ -72,106 +72,114 @@ export function updateManagedOrcadEnvironment( userDataPath: string, args: LifecycleArgs & { force?: boolean } ): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const census = await collectManagedTerminalCensus( - userDataPath, - environment, - context.activationRecord - ) - const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { - signal: args.signal - }) - const result = await deployOrcad({ - ...managedOrcadSlot(context, deployment.remotePort, args.signal), - localOrcadDir, - target: context.serverTarget, - census, - force: args.force - }) - if (result.outcome === 'installed-not-activated') { - const deferral = { - outcome: 'deferred' as const, - candidateVersion: result.fullVersion, - code: result.code, - reason: result.reason, - forceable: isForceableOrcadDeferral(result.code) + return withManagedOrcadLifecycle( + userDataPath, + args.selector, + async ({ environment, deployment }) => { + const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) + const census = await collectManagedTerminalCensus( + userDataPath, + environment, + context.activationRecord + ) + const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, { + signal: args.signal + }) + const result = await deployOrcad({ + ...managedOrcadSlot(context, deployment.remotePort, args.signal), + localOrcadDir, + target: context.serverTarget, + census, + force: args.force + }) + if (result.outcome === 'installed-not-activated') { + const deferral = { + outcome: 'deferred' as const, + candidateVersion: result.fullVersion, + code: result.code, + reason: result.reason, + forceable: isForceableOrcadDeferral(result.code) + } + recordManagedOrcadUpdateDeferral(environment.id, deferral) + return deferral + } + clearManagedOrcadUpdateDeferral(environment.id) + const readiness = await probeManagedOrcadReadiness( + context, + localOrcadDir, + result.fullVersion, + args.signal + ) + const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) + return { + outcome: result.outcome === 'already-active' ? 'already-current' : 'updated', + environment: redactRuntimeEnvironment(updated), + activeVersion: result.fullVersion } - recordManagedOrcadUpdateDeferral(environment.id, deferral) - return deferral } - clearManagedOrcadUpdateDeferral(environment.id) - const readiness = await probeManagedOrcadReadiness( - context, - localOrcadDir, - result.fullVersion, - args.signal - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: result.outcome === 'already-active' ? 'already-current' : 'updated', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.fullVersion - } - }) + ) } export function rollbackManagedOrcadEnvironment( userDataPath: string, args: LifecycleArgs ): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const record = context.activationRecord - const target = record.previous - if (!target) { + return withManagedOrcadLifecycle( + userDataPath, + args.selector, + async ({ environment, deployment }) => { + const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) + const record = context.activationRecord + const target = record.previous + if (!target) { + return { + outcome: 'refused', + code: 'orcad_rollback_no_target', + reason: 'This server has no previous version to roll back to.' + } + } + const census = await collectManagedTerminalCensus(userDataPath, environment, record) + // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show + // that build would reach terminals that are still running. + if (census.liveSessions !== 0) { + return { + outcome: 'refused', + code: + census.liveSessions === null + ? 'orcad_rollback_census_unavailable' + : 'orcad_rollback_terminals_running', + reason: + census.liveSessions === null + ? 'The server did not answer how many terminals it runs. Retry when it answers.' + : 'Close the terminals running on this server before rolling it back.' + } + } + const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) + const targetDir = managedOrcadInstallDir(context, target) + const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir) + const result = await rollbackOrcad({ + ...slot, + record, + census, + targetBuildHash, + targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL + }) + if (result.outcome !== 'rolled-back') { + return result + } + const readiness = await probeActiveOrcadReadiness( + { ...slot, remoteInstallDir: targetDir }, + { buildHash: targetBuildHash, fullVersion: result.target } + ) + const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) return { - outcome: 'refused', - code: 'orcad_rollback_no_target', - reason: 'This server has no previous version to roll back to.' + outcome: 'rolled-back', + environment: redactRuntimeEnvironment(updated), + activeVersion: result.target, + discarded: result.discarded } } - const census = await collectManagedTerminalCensus(userDataPath, environment, record) - // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show - // that build would reach terminals that are still running. - if (census.liveSessions !== 0) { - return { - outcome: 'refused', - code: - census.liveSessions === null - ? 'orcad_rollback_census_unavailable' - : 'orcad_rollback_terminals_running', - reason: - census.liveSessions === null - ? 'The server did not answer how many terminals it runs. Retry when it answers.' - : 'Close the terminals running on this server before rolling it back.' - } - } - const slot = managedOrcadSlot(context, deployment.remotePort, args.signal) - const targetDir = managedOrcadInstallDir(context, target) - const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir) - const result = await rollbackOrcad({ - ...slot, - record, - census, - targetBuildHash, - targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL - }) - if (result.outcome !== 'rolled-back') { - return result - } - const readiness = await probeActiveOrcadReadiness( - { ...slot, remoteInstallDir: targetDir }, - { buildHash: targetBuildHash, fullVersion: result.target } - ) - const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort) - return { - outcome: 'rolled-back', - environment: redactRuntimeEnvironment(updated), - activeVersion: result.target, - discarded: result.discarded - } - }) + ) } /** Finishes or undoes an interrupted activation, rollback or decommission on the host. */ @@ -179,25 +187,29 @@ export function recoverManagedOrcadEnvironment( userDataPath: string, args: LifecycleArgs ): Promise { - return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => { - const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) - const result = await recoverInterruptedOrcadActivation( - managedOrcadSlot(context, deployment.remotePort, args.signal) - ) - if (result.outcome !== 'recovered') { - return result + return withManagedOrcadLifecycle( + userDataPath, + args.selector, + async ({ environment, deployment }) => { + const context = await resolveLinkedOrcadContext(environment, deployment, args.signal) + const result = await recoverInterruptedOrcadActivation( + managedOrcadSlot(context, deployment.remotePort, args.signal) + ) + if (result.outcome !== 'recovered') { + return result + } + const updated = result.readiness + ? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort) + : environment + if (result.activeVersion) { + await ensureOrcadManagedTunnel(userDataPath, environment.id) + } + return { + outcome: 'recovered', + resolution: result.resolution, + activeVersion: result.activeVersion, + environment: redactRuntimeEnvironment(updated) + } } - const updated = result.readiness - ? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort) - : environment - if (result.activeVersion) { - await ensureOrcadManagedTunnel(userDataPath, environment.id) - } - return { - outcome: 'recovered', - resolution: result.resolution, - activeVersion: result.activeVersion, - environment: redactRuntimeEnvironment(updated) - } - }) + ) }