fix(runtime): settle a quiet Codex composer as ready on every version (#23765)

* fix(runtime): settle a quiet Codex composer as tui-idle on every version

Codex 0.158 dropped `model:`/`directory:` from its startup header, which both
Codex readiness rules require, so `worker-start --agent codex` timed out; an
idle Codex pane after a turn also had no readiness signal once the header left
the screen.

Generalize the Muse tier-1b lane into a quiet-ready-screen lane: a Codex (or
agent-unknown) pane whose live screen shows the empty composer placeholder,
no `to interrupt)` status row, no header `loading`, and no dialog wording in
its live window, settles once the stream has been quiet for the tui-idle
quiescence window. Additive only: the tier-1 rules and the Muse rule are
unchanged. Fixtures: codex 0.150.1-0.158.0 captures at 120x40, including
chunk-timed turns.

* refactor(runtime): anchor the Codex quiet lane to the empty composer line

Move the Codex screen rules into codex-terminal-readiness.ts and the quiet-screen
body beside isKnownReadyPromptBody. The composer rule now matches only the
`› Ask Codex to do anything` line and drops its dialog markers: every Codex
dialog replaces the composer, and an answer ending "Would you like to…?" above a
live composer must not hold the lane forever. The quiet lane checks quiescence
before reading the screen. Trim the redundant startup and untimed turn fixtures.

* fix(runtime): read Codex's busy row above the composer and scope the lane to codex panes

* fix(runtime): read only Codex's live status row above the composer
This commit is contained in:
Jinwoo Hong
2026-09-29 00:39:01 -04:00
committed by GitHub
parent c30c8f9d77
commit c8af48d8a4
23 changed files with 726 additions and 120 deletions
+2 -1
View File
@@ -8,6 +8,7 @@
"cloud/**",
"resources/licenses/**",
".github/actions/cloud-sql-rollout-lease/**",
".anti-slop-plugin/**"
".anti-slop-plugin/**",
"src/main/runtime/__fixtures__/*.timing.json"
]
}
@@ -43,6 +43,11 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record<string, number> = {
'codex-0157-effort-override-embedded-warning': 4,
'codex-0157-no-daemon-effort-override': 16,
'codex-0157-plain-ready': 18,
// Same extra dim bit on the 0.157/0.158 header row (STA-8834 fixtures).
'codex-0-157-1-update-dialog': 16,
'codex-0-158-0-approval': 12,
'codex-0-158-0-timed-turn': 20,
'codex-0-158-0-trustprompt': 36,
'claude-dialog-trust-workspace-answered': 13,
// DSH-TUI's whale intro paints whole rows of 24-bit background, and every one of this
// transcript's divergences is the same shape: `visible-grid row=0`, a true-colour
@@ -0,0 +1,16 @@
{
"capturedAt": "2026-09-29T01:35:32.695Z",
"platform": "darwin",
"command": [
"codex",
"-c",
"check_for_update_on_startup=false",
"--dangerously-bypass-approvals-and-sandbox",
"-m",
"gpt-5.6-sol"
],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.150.1: launch, one short turn, settled idle after it. STA-8834.",
"exitCode": 0
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
{
"capturedAt": "2026-09-29T01:44:01.149Z",
"platform": "darwin",
"command": [
"codex",
"-c",
"check_for_update_on_startup=false",
"--dangerously-bypass-approvals-and-sandbox"
],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.155.1, default tui animations. One tool-using turn sent once the composer had been quiet 2.5s; cut 20s past the last busy frame, before exit. <name>.timing.json holds each PTY chunk as [ms since spawn, UTF-16 length] and the prompt-submit time. STA-8834.",
"exitCode": 0
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-29T01:41:43.328Z",
"platform": "darwin",
"command": ["codex", "--no-daemon", "--dangerously-bypass-approvals-and-sandbox"],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.157.1: launch with the update check on; the update dialog owns the screen. STA-8834.",
"exitCode": 0
}
@@ -0,0 +1 @@
[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l╭───────────────────────────────────────╮│ >_ OpenAI Codex (v0.157.1) ││ ││ model: loading /model to change ││ directory: loading │╰───────────────────────────────────────╯›Ask Codex to do anything?forshortcuts[0 q╭[?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l───────────╮ │ │ loading /model to change │ ~/orca-lanes/sta8834/corpus/scratch ││ permissions: YOLO mode │╰──────────────────────────────────────────────────╯[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l  Updat available · 0.157.1→0.158.0 Releasenotes:]8;;https://github.com/openai/codex/releases/latesthttps://github.com/openai/codex/releases/latest]8;; › 1. Update now (runs `npm install -g @openai/codex`)  2. Skip 3. Skip until next versionenter continue · esc skip [?2026l
@@ -0,0 +1,18 @@
{
"capturedAt": "2026-09-29T01:43:13.919Z",
"platform": "darwin",
"command": [
"codex",
"--no-daemon",
"-c",
"check_for_update_on_startup=false",
"-a",
"on-request",
"-s",
"read-only"
],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.158.0: read-only sandbox with on-request approvals; a command approval dialog owns the screen at the end. STA-8834.",
"exitCode": 0
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
{
"capturedAt": "2026-09-29T01:46:36.717Z",
"platform": "darwin",
"command": [
"codex",
"--no-daemon",
"-c",
"check_for_update_on_startup=false",
"--dangerously-bypass-approvals-and-sandbox"
],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.158.0, default tui animations. One tool-using turn sent once the composer had been quiet 2.5s; cut 20s past the last busy frame, before exit. <name>.timing.json holds each PTY chunk as [ms since spawn, UTF-16 length] and the prompt-submit time. STA-8834.",
"exitCode": 0
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
{
"capturedAt": "2026-09-29T01:32:55.701Z",
"platform": "darwin",
"command": [
"codex",
"--no-daemon",
"-c",
"check_for_update_on_startup=false",
"--dangerously-bypass-approvals-and-sandbox"
],
"cols": 120,
"rows": 40,
"note": "codex-cli 0.158.0: launch in an untrusted folder; the recording ends on the unanswered trust dialog. STA-8834.",
"exitCode": 0
}
@@ -0,0 +1 @@
[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1006h[?1003h[?25l[?2026h[?25l>_ OpenAI Codex (v0.158.0)loadingHello, world. Hello, you.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l~/orca-lanes/sta8834/corpus/scratch permissions: YOLO modeHello, world. Hello, you.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?1006l[?1015l[?1003l[?1002l[?1000l[?1007h[?2026h[?25l Folder access/Users/xxxxxx/orca-lanes/sta8834/corpus/scratch  Trustthisfolder?Codexcanread,edit,andrunfileshere,subjecttoyourpermissionsettings.Foldersettingscan run code automatically,evenwithoutamodelrequest.Continueonlyifyoutrustthesefiles.Yourtrustdecisionwillbesaved.› 1. Trust and continue 2.Quitenter continue · esc quit [?2026l
@@ -0,0 +1,56 @@
// Replays captured PTY bytes the way onPtyData does, for suites asserting a rule on every frame.
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { HeadlessEmulator } from '../daemon/headless-emulator'
import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection'
import { normalizeTerminalChunk } from './terminal-ansi-normalization'
import { appendNormalizedToTailBuffer } from './terminal-tail-buffer'
import { buildPreview } from './terminal-tail-state'
import { buildTerminalWaitText } from './terminal-wait-tail-state'
const DEFAULT_CHUNK_CHARS = 64
export type TranscriptReplayFrame = { screenLines: string[]; waitText: string }
export function readRuntimeFixture(name: string): string {
return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8')
}
/** A string is cut into fixed 64-char chunks; an array replays the recorded PTY chunks as-is. */
export async function* replayTranscript(
data: string | readonly string[],
cols: number,
rows: number
): AsyncGenerator<TranscriptReplayFrame> {
const chunks = typeof data === 'string' ? splitIntoChunks(data) : data
const emulator = new HeadlessEmulator({ cols, rows })
let lines: string[] = []
let partialLine = ''
let pendingAnsi = ''
let redrawCursor: ReturnType<typeof appendNormalizedToTailBuffer>['redrawCursor'] = null
try {
for (const chunk of chunks) {
await emulator.write(chunk)
const normalized = normalizeTerminalChunk(chunk, pendingAnsi)
pendingAnsi = normalized.pendingAnsi
const tail = appendNormalizedToTailBuffer(lines, partialLine, normalized.text, redrawCursor)
lines = tail.lines
partialLine = tail.partialLine
redrawCursor = tail.redrawCursor
yield {
screenLines: projectTerminalVisibleLines(emulator).lines,
waitText: buildTerminalWaitText(lines, partialLine, buildPreview(lines, partialLine))
}
}
} finally {
emulator.dispose()
}
}
function splitIntoChunks(data: string): string[] {
const chunks: string[] = []
for (let offset = 0; offset < data.length; offset += DEFAULT_CHUNK_CHARS) {
chunks.push(data.slice(offset, offset + DEFAULT_CHUNK_CHARS))
}
return chunks
}
@@ -1,14 +1,11 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { HeadlessEmulator } from '../daemon/headless-emulator'
import { createTranscriptPane } from './agent-transcript-pane-test-harness'
import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection'
import { normalizeTerminalChunk } from './terminal-ansi-normalization'
import { appendNormalizedToTailBuffer } from './terminal-tail-buffer'
import { buildPreview } from './terminal-tail-state'
import {
readRuntimeFixture,
replayTranscript,
type TranscriptReplayFrame
} from './agent-transcript-replay-test-harness'
import { isKnownReadyPromptBody, isKnownReadyPromptPreview } from './terminal-wait-detection'
import { buildTerminalWaitText } from './terminal-wait-tail-state'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
@@ -23,44 +20,14 @@ const EFFORT_OVERRIDE = 'codex-0157-effort-override-embedded-warning'
const CONFIG_OVERRIDE = 'codex-0157-config-override-embedded-warning'
const NO_DAEMON = 'codex-0157-no-daemon-effort-override'
const ALL_FIXTURES = [PLAIN, EFFORT_OVERRIDE, CONFIG_OVERRIDE, NO_DAEMON]
const CHUNK_CHARS = 64
function readFixture(name: string): string {
return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8')
}
type ReplayFrame = { screenLines: string[]; waitText: string }
/** Feeds the bytes the way onPtyData does: one emulator grid, one line-folded wait text. */
async function* replay(data: string, cols: number, rows: number): AsyncGenerator<ReplayFrame> {
const emulator = new HeadlessEmulator({ cols, rows })
let lines: string[] = []
let partialLine = ''
let pendingAnsi = ''
let redrawCursor: ReturnType<typeof appendNormalizedToTailBuffer>['redrawCursor'] = null
try {
for (let offset = 0; offset < data.length; offset += CHUNK_CHARS) {
const chunk = data.slice(offset, offset + CHUNK_CHARS)
await emulator.write(chunk)
const normalized = normalizeTerminalChunk(chunk, pendingAnsi)
pendingAnsi = normalized.pendingAnsi
const tail = appendNormalizedToTailBuffer(lines, partialLine, normalized.text, redrawCursor)
lines = tail.lines
partialLine = tail.partialLine
redrawCursor = tail.redrawCursor
yield {
screenLines: projectTerminalVisibleLines(emulator).lines,
waitText: buildTerminalWaitText(lines, partialLine, buildPreview(lines, partialLine))
}
}
} finally {
emulator.dispose()
}
}
async function finalFrame(name: string, cols: number, rows: number): Promise<ReplayFrame> {
let last: ReplayFrame | null = null
for await (const frame of replay(readFixture(name), cols, rows)) {
async function finalFrame(
name: string,
cols: number,
rows: number
): Promise<TranscriptReplayFrame> {
let last: TranscriptReplayFrame | null = null
for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) {
last = frame
}
if (!last) {
@@ -89,8 +56,8 @@ describe('Codex 0.157 header readiness from captured bytes', () => {
'%s: the screen never adds readiness while loading, and is ready at the final screen',
async (name) => {
let sawLoadingHeader = false
let last: ReplayFrame | null = null
for await (const frame of replay(readFixture(name), 120, 40)) {
let last: TranscriptReplayFrame | null = null
for await (const frame of replayTranscript(readRuntimeFixture(name), 120, 40)) {
if (screenShowsLoadingHeader(frame.screenLines)) {
sawLoadingHeader = true
expect(isKnownReadyPromptBody('', 'codex', () => frame.screenLines)).toBe(false)
@@ -113,7 +80,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => {
[60, 5]
])('at %ix%i the screen never takes readiness away from the text rules', (cols, rows) => {
it.each(ALL_FIXTURES)('%s', async (name) => {
for await (const frame of replay(readFixture(name), cols, rows)) {
for await (const frame of replayTranscript(readRuntimeFixture(name), cols, rows)) {
if (isKnownReadyPromptPreview(frame.waitText)) {
expect(isKnownReadyPromptBody(frame.waitText, 'codex', () => frame.screenLines)).toBe(
true
@@ -191,7 +158,7 @@ describe('Codex 0.157 header readiness from captured bytes', () => {
paneTitle: 'Terminal',
foregroundProcess: 'codex',
launchAgent: 'codex',
data: readFixture(name),
data: readRuntimeFixture(name),
size
})
}
@@ -0,0 +1,372 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TuiAgent } from '../../shared/tui-agent'
import { createTranscriptPane } from './agent-transcript-pane-test-harness'
import {
readRuntimeFixture,
replayTranscript,
type TranscriptReplayFrame
} from './agent-transcript-replay-test-harness'
import { isCodexComposerReadyScreen } from './codex-terminal-readiness'
import {
detectTerminalWaitBlockedReason,
isKnownReadyPromptBody,
isQuietReadyScreenBody
} from './terminal-wait-detection'
import {
evaluateTuiIdle,
hasQuietReadyScreen,
isTuiIdleReadyVerdict,
type TuiIdleEvaluationInput
} from './tui-idle-evidence'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
const QUIESCENCE_MS = 3000
// codex-cli recordings at 120x40 (see each .meta.json); STA-8834. Each launches, runs a turn,
// and ends idle; the timed ones also replay as plain strings here. 0.157 idles via STA-8628's.
const TIMED_FIXTURES = ['codex-0-155-1-timed-turn', 'codex-0-158-0-timed-turn']
const SETTLED_IDLE_FIXTURES = ['codex-0-150-1-turn', ...TIMED_FIXTURES]
const DIALOG_FIXTURES = [
'codex-0-157-1-update-dialog',
'codex-0-158-0-approval',
'codex-0-158-0-trustprompt'
]
const STA_8628_FIXTURES = [
'codex-0157-plain-ready',
'codex-0157-effort-override-embedded-warning',
'codex-0157-config-override-embedded-warning',
'codex-0157-no-daemon-effort-override'
]
const BUSY_STATUS_RE = /to interrupt\)/
const EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/m
const HEADER_LOADING_RE = /(?:model|directory):\s+loading|^\s*loading\s*$/m
const DIALOG_RE =
/update available|do you trust|trust this folder|would you like to run|press enter to confirm|enter continue/
function screenOf(frame: TranscriptReplayFrame): string {
return frame.screenLines.join('\n').toLowerCase()
}
async function collectFrames(
data: string | readonly string[],
cols = 120,
rows = 40
): Promise<TranscriptReplayFrame[]> {
const frames: TranscriptReplayFrame[] = []
for await (const frame of replayTranscript(data, cols, rows)) {
frames.push(frame)
}
return frames
}
describe('Codex composer ready screen, frame by frame', () => {
it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES])(
'%s: never ready while loading, mid-turn, or under a drawn dialog',
async (name) => {
const seen = { loading: 0, busy: 0, dialog: 0 }
for (const frame of await collectFrames(readRuntimeFixture(name))) {
const screen = screenOf(frame)
// Why only once the composer is gone: a half-drawn dialog keeps it for a few frames,
// which quiescence absorbs; a drawn dialog replaces it.
const kind = HEADER_LOADING_RE.test(screen)
? 'loading'
: BUSY_STATUS_RE.test(screen)
? 'busy'
: DIALOG_RE.test(screen) && !EMPTY_COMPOSER_RE.test(screen)
? 'dialog'
: null
if (kind) {
seen[kind] += 1
expect({ kind, ready: isCodexComposerReadyScreen(screenOf(frame)) }).toEqual({
kind,
ready: false
})
}
}
// Presence preconditions: each fixture exercises the states it was recorded for.
expect(seen.loading).toBeGreaterThan(0)
if (DIALOG_FIXTURES.includes(name)) {
expect(seen.dialog).toBeGreaterThan(0)
} else {
expect(seen.busy).toBeGreaterThan(0)
}
},
30_000
)
it.each([...SETTLED_IDLE_FIXTURES, ...STA_8628_FIXTURES])(
'%s: ready on the final idle screen',
async (name) => {
const frames = await collectFrames(readRuntimeFixture(name))
expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(true)
}
)
it.each(DIALOG_FIXTURES)('%s: not ready while the dialog owns the screen', async (name) => {
const frames = await collectFrames(readRuntimeFixture(name))
expect(isCodexComposerReadyScreen(screenOf(frames.at(-1)!))).toBe(false)
})
it('is not ready without a live screen', () => {
expect(isQuietReadyScreenBody('', 'codex', () => null)).toBe(false)
})
it('reads the placeholder only as the composer line, not quoted mid-line', () => {
const quoted = ['$ grep placeholder notes.txt', 'the box says › Ask Codex to do anything', '$']
expect(isQuietReadyScreenBody('', 'codex', () => quoted)).toBe(false)
})
it('reads the busy row only above the composer, not a status row quoted in the answer', () => {
const screenLines = [
'>_ OpenAI Codex (v0.158.0)',
' ~/repo',
'',
'› what does the status row look like mid-turn?',
'',
'• It reads like this:',
'',
' • Working (0s • esc to interrupt)',
'',
' The timer counts up until the turn ends, and the row',
' disappears once the answer is complete.',
'',
'› Ask Codex to do anything',
'',
' gpt-5.6-sol medium · ~/repo',
' ? for shortcuts'
]
expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true)
const composer = screenLines.indexOf('› Ask Codex to do anything')
const busy = [
...screenLines.slice(0, composer),
'• Working (3s • esc to interrupt)',
' └ Tip: start a fresh idea with /new',
'',
...screenLines.slice(composer)
]
expect(isQuietReadyScreenBody('', 'codex', () => busy)).toBe(false)
})
it("reads 0.158's turn timestamp above the composer as the end of a quoted status row", () => {
// Live 0.158 screen after a worker answered with a status row verbatim (STA-8834).
const screenLines = [
' === TASK ===',
' Reply with exactly this line and nothing else: • Working (0s • esc to interrupt) — then call worker_done.',
'• • Working (0s • esc to interrupt)',
'• Ran orca-dev orchestration send --from term_f496fcd2-a874-4fa6-a9bd-550a515ac928 --dispatch-capability dcap_PTgPpvf-B…',
' └ Sent msg_ff7be3f25d7e',
' + Show details',
'• • Working (0s • esc to interrupt)',
' 11:15 PM',
'› Ask Codex to do anything',
' GPT-6-Sol medium · ~/orca-lanes/sta8834/live3/scratch · Report task outcome',
' ? for shortcuts'
]
expect(isQuietReadyScreenBody('', 'codex', () => screenLines)).toBe(true)
})
it('settles a quiet composer under an answer asking "Would you like to proceed?"', () => {
const screenLines = [
'>_ OpenAI Codex (v0.158.0)',
' ~/repo',
'',
'› tidy the README',
'',
'• Done. Would you like to proceed with the changelog too?',
'',
'› Ask Codex to do anything',
'',
' ? for shortcuts'
]
const waitText = screenLines.join('\n')
const verdict = evaluateTuiIdle({
record: { lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null },
readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText),
readPositiveBodyEvidence: () => isKnownReadyPromptBody(waitText, 'codex', () => screenLines),
readQuietReadyBodyEvidence: () =>
isQuietReadyScreenBody(waitText, 'codex', () => screenLines),
agent: 'codex',
firstPartyStatus: null,
quiescenceMs: QUIESCENCE_MS
})
expect(verdict.kind).toBe('ready-strong')
})
})
type Timing = { promptSentAtMs: number; chunks: [number, number][] }
function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } {
const data = readRuntimeFixture(name)
const timing: Timing = JSON.parse(
readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8')
)
const chunks: string[] = []
let offset = 0
for (const [, length] of timing.chunks) {
chunks.push(data.slice(offset, offset + length))
offset += length
}
expect(offset).toBe(data.length)
return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs }
}
describe('the quiet lane over recorded chunk timing (default animations)', () => {
afterEach(() => {
vi.useRealTimers()
})
it.each(TIMED_FIXTURES)(
'%s: never settles mid-turn, and settles once the finished turn is quiet',
async (name) => {
const { chunks, times, promptAt } = readTimedFixture(name)
const frames = await collectFrames(chunks)
// Why after the replay: the emulator's write flush runs on real timers.
vi.useFakeTimers()
const lastBusy = frames.findLastIndex((frame) => BUSY_STATUS_RE.test(screenOf(frame)))
const firstTurnChunk = times.findIndex((at) => at >= promptAt)
expect(lastBusy).toBeGreaterThan(firstTurnChunk)
// The latest moment each frame stays on screen: just before the next chunk lands.
const settlesAt = (index: number, now: number): boolean => {
vi.setSystemTime(now)
return hasQuietReadyScreen(
{ lastAgentStatus: null, lastOutputAt: times[index]!, lastOscTitle: null },
'codex',
() =>
isQuietReadyScreenBody(
frames[index]!.waitText,
'codex',
() => frames[index]!.screenLines
),
QUIESCENCE_MS
)
}
let readyBodyMidTurn = 0
for (let index = firstTurnChunk; index <= lastBusy; index += 1) {
if (isCodexComposerReadyScreen(screenOf(frames[index]!))) {
readyBodyMidTurn += 1
}
expect({ index, settles: settlesAt(index, times[index + 1]! - 1) }).toEqual({
index,
settles: false
})
}
// Presence precondition: the body alone does show mid-turn, so quiescence is load-bearing.
expect(readyBodyMidTurn).toBeGreaterThan(0)
const last = frames.length - 1
// Why +1: the fake clock keeps whole milliseconds; the recorded times do not.
expect(settlesAt(last, times[last]! + QUIESCENCE_MS + 1)).toBe(true)
},
120_000
)
})
describe('never less ready than origin/main', () => {
const records = [
{ lastAgentStatus: null, lastOutputAt: 0, lastOscTitle: null },
{ lastAgentStatus: 'idle' as const, lastOutputAt: 0, lastOscTitle: 'codex' },
{ lastAgentStatus: 'working' as const, lastOutputAt: 0, lastOscTitle: '⠋ repo' }
]
describe.each([
[120, 40],
[80, 24]
])('at %ix%i', (cols, rows) => {
it.each([...SETTLED_IDLE_FIXTURES, ...DIALOG_FIXTURES, ...STA_8628_FIXTURES])(
'%s',
async (name) => {
for (const frame of await collectFrames(readRuntimeFixture(name), cols, rows)) {
for (const agent of ['codex', null] as const) {
const base = {
readTailBlockedReason: () => detectTerminalWaitBlockedReason(frame.waitText),
readPositiveBodyEvidence: () =>
isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines),
agent,
firstPartyStatus: null,
quiescenceMs: QUIESCENCE_MS
} satisfies Omit<TuiIdleEvaluationInput, 'record' | 'readQuietReadyBodyEvidence'>
for (const record of records) {
const before = evaluateTuiIdle({
...base,
record,
// Why a withheld screen: that leaves exactly main's Muse lane, the only widening.
readQuietReadyBodyEvidence: () =>
isQuietReadyScreenBody(frame.waitText, agent, () => null)
})
const after = evaluateTuiIdle({
...base,
record,
readQuietReadyBodyEvidence: () =>
isQuietReadyScreenBody(frame.waitText, agent, () => frame.screenLines)
})
if (isTuiIdleReadyVerdict(before)) {
expect(isTuiIdleReadyVerdict(after)).toBe(true)
}
}
}
}
},
60_000
)
})
})
describe('agent gate', () => {
const placeholderScreen = ['› Ask Codex to do anything', ' ? for shortcuts']
it("never reads another agent's screen, even one showing Codex's placeholder", () => {
const readScreenLines = vi.fn(() => placeholderScreen)
for (const agent of ['claude', 'muse', 'gemini'] as const) {
expect(isQuietReadyScreenBody('', agent, readScreenLines)).toBe(false)
}
expect(readScreenLines).not.toHaveBeenCalled()
expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true)
})
it('leaves an agent-unknown pane showing a cat-ed Codex transcript pending', () => {
const catted = ['$ cat session.log', '› Ask Codex to do anything', ' ? for shortcuts', '$ ']
const readScreenLines = vi.fn(() => catted)
expect(isQuietReadyScreenBody(catted.join('\n'), null, readScreenLines)).toBe(false)
expect(readScreenLines).not.toHaveBeenCalled()
expect(isQuietReadyScreenBody('', 'codex', readScreenLines)).toBe(true)
})
})
describe('through the runtime', () => {
// Why 0.158 alone: its header carries no `model:`, so only this lane settles it.
const CODEX_0158 = 'codex-0-158-0-timed-turn'
async function pane(name: string, launchAgent: TuiAgent, size?: { cols: number; rows: number }) {
return createTranscriptPane({
paneTitle: 'Terminal',
foregroundProcess: launchAgent,
launchAgent,
data: readRuntimeFixture(name),
size
})
}
// Why 8s: quiescence (3s) plus the 2s poll re-reading the grid.
it('codex 0.158: a tui-idle wait settles once the composer is quiet', async () => {
const { runtime, handle } = await pane(CODEX_0158, 'codex', { cols: 120, rows: 40 })
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 8_000 })
).resolves.toMatchObject({ condition: 'tui-idle', satisfied: true })
}, 15_000)
it('keeps a Claude pane showing the same screen pending', async () => {
const { runtime, handle } = await pane(CODEX_0158, 'claude', {
cols: 120,
rows: 40
})
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 6_000 })
).rejects.toThrow(/timeout/)
}, 15_000)
})
@@ -0,0 +1,63 @@
// 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.
const CODEX_HEADER_LINES = 6
// Why the whole line: a pager, a `cat`ed transcript, or chat can quote the placeholder mid-line.
const CODEX_EMPTY_COMPOSER_RE = /^› ask codex to do anything\s*$/
// Why not "working": reasoning summaries replace it, and a remapped key still ends this way.
const CODEX_BUSY_STATUS_MARKER = 'to interrupt)'
// Why only a tip: it is the one line Codex draws between its status row and the composer (120x40 corpus).
const CODEX_STATUS_TIP_PREFIX = '└ tip:'
// Why the header only: chat below it can mention "OpenAI Codex" or `model: loading`.
function findCodexHeader(screen: string): { index: number; text: string } | null {
const index = screen.indexOf('openai codex')
if (index === -1) {
return null
}
const boxEnd = screen.indexOf('╰', index)
const text = screen
.slice(index, boxEnd === -1 ? undefined : boxEnd)
.split('\n', CODEX_HEADER_LINES)
.join('\n')
return { index, text }
}
/** Tier 1: the 0.150-0.157 header, which only a grid reassembles (see isKnownReadyPromptBody). */
export function findCodexScreenReadyPromptIndex(screen: string): number | null {
const header = findCodexHeader(screen)
return header !== null &&
header.text.includes('model:') &&
header.text.includes('directory:') &&
!CODEX_HEADER_LOADING_RE.test(header.text)
? header.index
: null
}
/**
* 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
* `tui.terminal_title` without activity/spinner removes it.
*/
export function isCodexComposerReadyScreen(screen: string): boolean {
const lines = screen.split('\n')
const composer = lines.findLastIndex((line) => CODEX_EMPTY_COMPOSER_RE.test(line))
if (composer === -1 || hasBusyStatusRowAbove(lines, composer)) {
return false
}
const header = findCodexHeader(screen)
return header === null || !CODEX_HEADER_LOADING_RE.test(header.text)
}
// Why only the row above the composer: a finished answer (or one above 0.158's timestamp) can quote it.
function hasBusyStatusRowAbove(lines: readonly string[], composer: number): boolean {
const above = lines.slice(0, composer).filter((line) => line.trim() !== '')
const row = above.at(-1)?.trimStart().startsWith(CODEX_STATUS_TIP_PREFIX)
? above.at(-2)
: above.at(-1)
return row?.includes(CODEX_BUSY_STATUS_MARKER) ?? false
}
+26 -25
View File
@@ -8,6 +8,10 @@ import {
import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types'
import type { TuiAgent } from '../../shared/tui-agent'
import { findAntigravityReadyPromptIndex } from './antigravity-terminal-readiness'
import {
findCodexScreenReadyPromptIndex,
isCodexComposerReadyScreen
} from './codex-terminal-readiness'
import { startOfLastLines, startOfLastNonBlankLines } from './terminal-wait-tail-window'
const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i
@@ -72,12 +76,29 @@ export function isKnownReadyPromptBody(
if (agent !== null && agent !== 'codex') {
return false
}
const screenLines = readScreenLines()
if (screenLines === null) {
return false
const screen = readScreen(readScreenLines)
return screen !== null && isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen))
}
/**
* 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 codex panes only: 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)
return screen !== null && isCodexComposerReadyScreen(screen)
}
const screen = screenLines.join('\n').toLowerCase()
return isReadyPromptUnblocked(screen, findCodexScreenReadyPromptIndex(screen))
return (agent === null || agent === 'muse') && isMuseReadyPromptPreview(waitText)
}
function readScreen(readScreenLines: () => readonly string[] | null): string | null {
return readScreenLines()?.join('\n').toLowerCase() ?? null
}
function isReadyPromptUnblocked(normalized: string, readyIndex: number | null): boolean {
@@ -88,8 +109,6 @@ function isReadyPromptUnblocked(normalized: string, readyIndex: number | null):
return blockedSignal === null || blockedSignal.index <= readyIndex
}
// Why separate from isKnownReadyPromptPreview: that one settles tier 1 immediately, while
// a Muse ready screen only proves the TUI is up — the ranking holds it to quiescence.
export function isMuseReadyPromptPreview(preview: string): boolean {
const normalized = preview.toLowerCase()
return isReadyPromptUnblocked(normalized, findMuseReadyPromptIndex(normalized))
@@ -179,24 +198,6 @@ function findCodexReadyPromptIndex(normalized: string): number | null {
return readySegment.includes('model:') && readySegment.includes('directory:') ? headerIndex : null
}
const CODEX_HEADER_LOADING_RE = /(?:model|directory):\s+loading/
// Why the header box only: chat below it can mention "OpenAI Codex" or `model: loading`.
// Why `loading`: a header still loading is not ready; the screen must not add readiness early.
function findCodexScreenReadyPromptIndex(screen: string): number | null {
const headerIndex = screen.indexOf('openai codex')
if (headerIndex === -1) {
return null
}
const boxEnd = screen.indexOf('╰', headerIndex)
const header = screen.slice(headerIndex, boxEnd === -1 ? undefined : boxEnd)
return header.includes('model:') &&
header.includes('directory:') &&
!CODEX_HEADER_LOADING_RE.test(header)
? headerIndex
: null
}
export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE =
/update available|choose working directory to|codex just got an upgrade|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always|run this command\?/i
+22 -22
View File
@@ -7,8 +7,8 @@ import { isKnownReadyPromptBody } from './terminal-wait-detection'
import {
evaluateTuiIdle,
hasFreshDoneFirstPartyStatus,
hasQuietReadyScreen,
isTuiIdleReadyVerdict,
hasQuietMuseReadyPrompt,
nameOnlyIdleNeedsCorroboration,
type TuiIdleEvaluationInput,
type TuiIdleEvidenceRecord
@@ -30,7 +30,7 @@ function input(overrides: Partial<TuiIdleEvaluationInput> = {}): TuiIdleEvaluati
record: record(),
readTailBlockedReason: () => null,
readPositiveBodyEvidence: () => false,
readMuseReadyBodyEvidence: () => true,
readQuietReadyBodyEvidence: () => true,
agent: 'muse',
firstPartyStatus: null,
quiescenceMs: QUIESCENCE_MS,
@@ -38,39 +38,38 @@ function input(overrides: Partial<TuiIdleEvaluationInput> = {}): TuiIdleEvaluati
}
}
describe('hasQuietMuseReadyPrompt', () => {
describe('hasQuietReadyScreen', () => {
it('settles a Muse ready screen once the stream has gone quiet', () => {
expect(hasQuietMuseReadyPrompt(record(), 'muse', () => true, QUIESCENCE_MS)).toBe(true)
expect(hasQuietReadyScreen(record(), 'muse', () => true, QUIESCENCE_MS)).toBe(true)
})
it('refuses while the pane is still streaming', () => {
expect(
hasQuietMuseReadyPrompt(
record({ lastOutputAt: Date.now() }),
'muse',
() => true,
QUIESCENCE_MS
)
hasQuietReadyScreen(record({ lastOutputAt: Date.now() }), 'muse', () => true, QUIESCENCE_MS)
).toBe(false)
})
it('refuses without an output clock, like the tier-3 lane', () => {
expect(
hasQuietMuseReadyPrompt(record({ lastOutputAt: null }), 'muse', () => true, QUIESCENCE_MS)
hasQuietReadyScreen(record({ lastOutputAt: null }), 'muse', () => true, QUIESCENCE_MS)
).toBe(false)
})
it('refuses without a ready screen', () => {
expect(hasQuietMuseReadyPrompt(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false)
expect(hasQuietReadyScreen(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false)
})
it('covers adopted panes that carry no launch metadata', () => {
expect(hasQuietMuseReadyPrompt(record(), null, () => true, QUIESCENCE_MS)).toBe(true)
expect(hasQuietMuseReadyPrompt(record(), undefined, () => true, QUIESCENCE_MS)).toBe(true)
expect(hasQuietReadyScreen(record(), null, () => true, QUIESCENCE_MS)).toBe(true)
expect(hasQuietReadyScreen(record(), undefined, () => true, QUIESCENCE_MS)).toBe(true)
})
it('refuses another agent quoting Muse in its scrollback', () => {
expect(hasQuietMuseReadyPrompt(record(), 'codex', () => true, QUIESCENCE_MS)).toBe(false)
it('covers Codex, whose title carries no rest signal once idle', () => {
expect(hasQuietReadyScreen(record(), 'codex', () => true, QUIESCENCE_MS)).toBe(true)
})
it('refuses another agent quoting Muse or Codex in its scrollback', () => {
expect(hasQuietReadyScreen(record(), 'claude', () => true, QUIESCENCE_MS)).toBe(false)
})
})
@@ -87,7 +86,7 @@ describe('evaluateTuiIdle muse lane', () => {
})
describe('evaluateTuiIdle ranking', () => {
const noMuse = { readMuseReadyBodyEvidence: () => false }
const noMuse = { readQuietReadyBodyEvidence: () => false }
it('ranks a blocking prompt in the tail above an explicit idle title', () => {
const verdict = evaluateTuiIdle(
@@ -201,13 +200,14 @@ describe('rest signal agrees with the lanes that can settle a wait', () => {
screenRead = true
return null
})
const museBody = hasQuietMuseReadyPrompt(record(), agent, () => true, QUIESCENCE_MS)
if (museBody) {
expect(signal).toBe('ready-body')
const quietScreenBody = hasQuietReadyScreen(record(), agent, () => true, QUIESCENCE_MS)
// Why not only ready-body: Codex keeps its stronger hook-driven title beside this lane.
if (quietScreenBody) {
expect(signal).not.toBe('none')
}
// Why a screen read also counts: Qoder's ready body is its composer, read by identity.
if (signal === 'ready-body') {
expect(museBody || screenRead).toBe(true)
expect(quietScreenBody || screenRead).toBe(true)
}
if (signal !== 'none') {
return
@@ -241,7 +241,7 @@ describe('a DSH pane settles tui-idle on its own hook', () => {
record: { lastAgentStatus: null, lastOutputAt: null, lastOscTitle: '\u2726 \u{1F40B} repo' },
rendererTitle: undefined,
readPositiveBodyEvidence: () => false,
readMuseReadyBodyEvidence: () => false,
readQuietReadyBodyEvidence: () => false,
readTailBlockedReason: () => null,
agent: 'dsh' as const,
firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() },
+27 -23
View File
@@ -14,7 +14,7 @@ import {
detectExplicitIdleStatusFromTitle,
detectTerminalWaitBlockedReason,
isKnownReadyPromptBody,
isMuseReadyPromptPreview
isQuietReadyScreenBody
} from './terminal-wait-detection'
/**
@@ -29,8 +29,8 @@ 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. MUSE — Muse emits no title signal at all, so its ready-screen body stands in
* for the strong evidence, believed only once the stream has gone quiet.
* 1b. QUIET READY SCREEN — Muse and an idle Codex title no rest signal, so their
* ready-screen body stands in for the strong evidence, 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.
@@ -187,8 +187,8 @@ 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 ready screen. Thunk for the same reason as above. */
readMuseReadyBodyEvidence: () => boolean
/** Tier 1b body evidence: a Muse or Codex ready screen. Thunk for the same reason as above. */
readQuietReadyBodyEvidence: () => boolean
agent: TuiAgent | null | undefined
firstPartyStatus: FirstPartyAgentStatus
quiescenceMs: number
@@ -207,34 +207,34 @@ 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<TuiAgent> = new Set(['muse', 'codex'])
/**
* Tier 1b: a Muse ready screen in the body, believed only once the stream has gone quiet.
* Tier 1b: a ready screen in the body, believed only once the stream has gone quiet.
*
* Muse is the one agent with no title signal at all — its OSC title is the bare cwd and
* never changes — 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 codex tier-3 lane's
* positive-evidence-plus-quiet shape. Scoped to Muse and agent-unknown panes: another
* agent's scrollback quoting Muse must not settle its wait.
* Muse's OSC title is the bare cwd and never changes, and an idle Codex titles its pane with
* 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 and agent-unknown panes (which
* read only Muse's screen): another agent's scrollback quoting them must not settle its wait.
*/
export function hasQuietMuseReadyPrompt(
export function hasQuietReadyScreen(
record: TuiIdleEvidenceRecord,
agent: TuiAgent | null | undefined,
readBodyEvidence: () => boolean,
quiescenceMs: number
): boolean {
if (agent !== null && agent !== undefined && agent !== 'muse') {
return false
}
if (!readBodyEvidence()) {
if (agent && !QUIET_READY_SCREEN_AGENTS.has(agent)) {
return false
}
// Why: same rule as the tier-3 lane — without an output clock there is no
// corroboration available, so hold out instead of settling.
if (record.lastOutputAt === null) {
if (record.lastOutputAt === null || Date.now() - record.lastOutputAt < quiescenceMs) {
return false
}
return Date.now() - record.lastOutputAt >= quiescenceMs
// Why last: a streaming pane never pays for the screen projection.
return readBodyEvidence()
}
/** The one place the tiers are combined; every settle site branches only on the verdict. */
@@ -273,11 +273,13 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict {
: { kind: 'pending', quietForeground: 'closed' }
}
// Why after the veto: a first-party working account outranks inferred body evidence.
// Why before the working title: Codex can leave a stale spinner title after a turn, and a
// live spinner emits output every ~100 ms, so a spinning pane is never quiet here.
if (
hasQuietMuseReadyPrompt(
hasQuietReadyScreen(
input.record,
input.agent,
input.readMuseReadyBodyEvidence,
input.readQuietReadyBodyEvidence,
input.quiescenceMs
)
) {
@@ -328,7 +330,8 @@ export function leafTuiIdleEvidence(
rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId),
readPositiveBodyEvidence: () =>
isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)),
readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText()),
readQuietReadyBodyEvidence: () =>
isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(leaf.ptyId)),
agent,
firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId),
quiescenceMs: source.quiescenceMs
@@ -348,7 +351,8 @@ export function ptyTuiIdleEvidence(
readPositiveBodyEvidence: () =>
(agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') ||
isKnownReadyPromptBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)),
readMuseReadyBodyEvidence: () => isMuseReadyPromptPreview(waitText()),
readQuietReadyBodyEvidence: () =>
isQuietReadyScreenBody(waitText(), agent, () => source.readScreenLines(pty.ptyId)),
agent,
firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId),
quiescenceMs: source.quiescenceMs