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) - } - }) + ) }