From 22db157cbfd8de8b359d69ffd948c6d435a2da20 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 10:19:21 -0700 Subject: [PATCH 01/11] docs(plan): Codex sees a native draft before writing into it (#800, #1313) Co-Authored-By: Claude Opus 5.5 --- .../2026-09-25-codex-composer-occupancy.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/plans/2026-09-25-codex-composer-occupancy.md diff --git a/docs/plans/2026-09-25-codex-composer-occupancy.md b/docs/plans/2026-09-25-codex-composer-occupancy.md new file mode 100644 index 000000000..6823b7f0d --- /dev/null +++ b/docs/plans/2026-09-25-codex-composer-occupancy.md @@ -0,0 +1,21 @@ +# Codex: see a native draft before writing into it (#800, #1313) + +## Evidence +codex-headless `testing/fixtures/composer-0157/idle-draft-ctrlc.json` is a raw PTY recording of codex-cli 0.157.0. +- **Empty composer:** `› Ask Codex to do anything`, with the placeholder painted dim, then a blank row, the status row and a hint row (`← for agents · ? for shortcuts`). +- **Typed draft:** `› please review the draft`, in plain cells, with the hint gone. + +Today: +- **#1313:** `isCodexNativeComposerEmpty` (text-only) cannot call the empty 0.157 composer empty: the placeholder is text, and the hint row sits below the status row. So every browser-pocket restart (`requireEmptyNativeComposer`) is refused. +- **#800:** nothing reads a native draft. `awaitReadyForPrompt` is ready whenever `›` and ` · ` are on screen, so a normal delivery pastes after the human's draft and submits both. Readiness latches after startup, so `nativeDraft` stays `unknown`. + +## Change +This bumps codex-headless to `1ac3c50`, which includes #53 (proxy chunks) and #54 (`CodexHeadless.getComposerState()`: `empty`, `drafted` or `unknown`, from the live buffer's cell attributes and Codex's own empty hint). +- **Delivery:** `awaitReadyForPrompt` answers `occupied` (`human-draft`) when the composer reads `drafted`. The delivery then refuses with `retry-after-resolve` and writes nothing. `unknown` keeps today's behaviour, so a frame we cannot read never blocks a prompt. +- **Empty-composer requirement:** consent when the package says `empty`, or when the old text check proves a bare `›`. Anything else still refuses. +- **Readiness:** once the composer latch is set, each screen frame that reads `drafted` publishes `composer-occupied`, and one that reads `empty` publishes `ready`. `unknown` publishes nothing, so a turn in progress does not flap. This makes `nativeDraft` report `occupied` and the pane show "draft in agent composer — clear it to send", as it does for Claude. + +## Tests (red on main, driven by the recorded 0.157 bytes through a real CodexHeadless) +- A restart request into the recorded idle composer is delivered (#1313). +- A normal delivery into the recorded draft is refused as occupied, with no write (#800). +- The session publishes `composer-occupied` on the draft frame, and `ready` again after Ctrl+C. From fa18d128e51dfa81a0f0fc44d9d3a981f0f0ace0 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 10:25:12 -0700 Subject: [PATCH 02/11] fix(codex): see a native draft before writing into it; restart into an empty 0.157 composer (#800, #1313) Bumps codex-headless to 1ac3c50 (#53 base64 proxy chunks, #54 getComposerState). A normal delivery into a human's draft is refused as occupied instead of appending to it; a draft publishes composer-occupied and its clearing publishes ready; a restart request consents on Codex's own empty-composer reading, which the text check could never give on 0.157. Tests replay the recorded 0.157 bytes through a real CodexHeadless. Co-Authored-By: Claude Opus 5.5 --- packages/codex-headless | 2 +- .../codexSession.nativeComposer.test.ts | 78 +++++++++++++++++++ src/providers/codex/runtime/codexSession.ts | 46 +++++++++++ src/providers/codex/runtime/promptDelivery.ts | 8 +- 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 src/providers/codex/runtime/codexSession.nativeComposer.test.ts diff --git a/packages/codex-headless b/packages/codex-headless index ae0e935d2..1ac3c50ec 160000 --- a/packages/codex-headless +++ b/packages/codex-headless @@ -1 +1 @@ -Subproject commit ae0e935d2aac0b42389824b99f9c821753012e58 +Subproject commit 1ac3c50ec87f31a08ab417fd0828efd1288d78e9 diff --git a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts new file mode 100644 index 000000000..f39865390 --- /dev/null +++ b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('node-pty', () => ({ spawn: vi.fn() })) + +import { CodexHeadless } from 'codex-headless' +import { CodexSession } from './codexSession.js' +import { deliverCodexPrompt } from './promptDelivery.js' + +// #800 / #1313, driven by a raw PTY recording of codex-cli 0.157.0 (idle, +// a typed draft, Ctrl+C), replayed through a REAL CodexHeadless so the cell +// attributes the decision rests on come from xterm's own parse of Codex's +// bytes. Recorded in codex-headless; see the fixture's `source`. +type Recording = { cols: number; rows: number; events: Array<{ t: number; dir: string; label?: string; data?: string }> } +const recording = JSON.parse(readFileSync(join(import.meta.dirname, + '../../../../packages/codex-headless/testing/fixtures/composer-0157/idle-draft-ctrlc.json'), 'utf8')) as Recording +const at = (label: string) => recording.events.find(event => event.label === label)!.t + +const headlesses: CodexHeadless[] = [] +afterEach(() => { headlesses.splice(0) }) + +async function sessionAt(until: number): Promise<{ session: CodexSession; headless: CodexHeadless }> { + const listeners = new Set<(data: string) => void>() + const pty = { + pid: 1, process: 'codex', cols: recording.cols, rows: recording.rows, handleFlowControl: false, + write: vi.fn(), resize: vi.fn(), clear: vi.fn(), pause: vi.fn(), resume: vi.fn(), kill: vi.fn(), + onData: (listener: (data: string) => void) => { listeners.add(listener); return { dispose: () => listeners.delete(listener) } }, + onExit: () => ({ dispose: () => undefined }), + } + const headless = new CodexHeadless({ pty: pty as never, cwd: '/tmp', cols: recording.cols, rows: recording.rows, snapshotIntervalMs: 1 }) + headlesses.push(headless) + const terminal = (headless as unknown as { terminal: { attach(): void; snapshotComposerCells(): unknown } }).terminal + terminal.attach() + for (const event of recording.events) { + if (event.dir === 'out' && event.t < until) for (const listener of listeners) listener(event.data!) + } + const deadline = Date.now() + 2000 + while (terminal.snapshotComposerCells() === null && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 5)) + const session = new CodexSession() + ;(session as unknown as { headless: unknown }).headless = headless + return { session, headless } +} + +describe('Codex native composer (0.157 recording)', () => { + it('delivers a restart request into the recorded empty composer (#1313)', async () => { + const { session, headless } = await sessionAt(at('type-draft')) + expect(headless.getScreen()).toContain('› Ask Codex to do anything') + const write = vi.fn(() => true) + await expect(deliverCodexPrompt({ session, sessionId: 'agent', prompt: 'Restart the server', write, requireEmptyNativeComposer: true } as never)) + .resolves.toMatchObject({ ok: true }) + expect(write).toHaveBeenCalledExactlyOnceWith('\x1b[200~Restart the server\x1b[201~\r') + }) + + it('refuses to write a prompt after the recorded human draft (#800)', async () => { + const { session, headless } = await sessionAt(at('ctrl-c-1')) + expect(headless.getScreen()).toContain('› please review the draft') + const write = vi.fn(() => true) + const result = await deliverCodexPrompt({ session, sessionId: 'agent', prompt: 'Status?', write } as never) + expect(result).toMatchObject({ ok: false, stage: 'before-write', disposition: 'retry-after-resolve', promptWritten: false }) + expect(write).not.toHaveBeenCalled() + }) + + it('publishes the draft as composer-occupied, and ready again once it is cleared (#800)', async () => { + const readiness: Array<{ ready: boolean; reason?: string }> = [] + const drafted = await sessionAt(at('ctrl-c-1')) + drafted.session.on('input-readiness', state => readiness.push(state)) + ;(drafted.session as unknown as { composerReady: boolean }).composerReady = true + ;(drafted.session as unknown as { publishNativeComposer(): void }).publishNativeComposer() + expect(readiness.at(-1)).toEqual({ ready: false, reason: 'composer-occupied' }) + + const cleared = await sessionAt(at('ctrl-c-2')) + ;(drafted.session as unknown as { headless: unknown }).headless = cleared.headless + ;(drafted.session as unknown as { publishNativeComposer(): void }).publishNativeComposer() + expect(readiness.at(-1)).toEqual({ ready: true, reason: 'ready' }) + }) +}) diff --git a/src/providers/codex/runtime/codexSession.ts b/src/providers/codex/runtime/codexSession.ts index 70e6253db..4d042e0c4 100644 --- a/src/providers/codex/runtime/codexSession.ts +++ b/src/providers/codex/runtime/codexSession.ts @@ -236,6 +236,8 @@ export class CodexSession extends EventEmitter { private tldrHooks: PrivateCodexTldrHooks | null = null private exited = false private composerReady = false + /** Whether `composer-occupied` is the last readiness published (#800). */ + private nativeDraftPublished = false private readonly cwd: string private readonly cols: number @@ -329,6 +331,7 @@ export class CodexSession extends EventEmitter { } this.exited = false this.composerReady = false + this.nativeDraftPublished = false this.emit('input-readiness', { ready: false, reason: this.resumeSessionId ? 'replaying-history' : 'provider-not-ready', @@ -554,6 +557,7 @@ export class CodexSession extends EventEmitter { // Forward screen snapshots. this.headless.on('screen', snap => { this.markComposerReady(snap.plain) + this.publishNativeComposer() this.emit('screen', { plain: snap.plain, markdown: snap.markdown, @@ -642,6 +646,7 @@ export class CodexSession extends EventEmitter { this.headless.on('exit', ({ exitCode, signal }) => { this.exited = true this.composerReady = false + this.nativeDraftPublished = false this.emit('input-readiness', { ready: false, reason: 'provider-not-ready' }) this.emit('exit', { exitCode, signal }) }) @@ -869,6 +874,15 @@ export class CodexSession extends EventEmitter { const screen = this.headless?.getScreen() ?? '' if (isCodexReadyForPromptScreen(screen)) { this.markComposerReady(screen) + // #800: the screen check above passes for ANY `›` row, including a + // human's draft, and a paste would then append to that draft and + // submit both. Only the package's attribute-aware `drafted` blocks; + // `unknown` keeps today's behaviour so a frame we cannot read never + // stalls a prompt (the Claude gate once latched occupied for 186 s). + if (this.nativeComposerState() === 'drafted') { + resolve({ kind: 'occupied', reason: 'human-draft' }) + return + } resolve({ kind: 'ready', waitedMs: Date.now() - startedAt }) return } @@ -943,6 +957,38 @@ export class CodexSession extends EventEmitter { return null } + /** + * The native composer as codex-headless reads it from the live buffer and + * its cell attributes (#800, #1313): `empty` only with Codex's own + * empty-composer hint, `drafted` for plain typed cells, an attachment or the + * queue hint, `unknown` for everything else. `unknown` is neither ready nor + * occupied; callers must not map it to either. + */ + nativeComposerState(): 'empty' | 'drafted' | 'unknown' { + const headless = this.headless as { getComposerState?: () => 'empty' | 'drafted' | 'unknown' } | null + return headless?.getComposerState?.() ?? 'unknown' + } + + /** + * Publish a native draft as `composer-occupied`, and its clearing as + * `ready` (#800). Before this, readiness latched once at startup, so a + * draft typed into the TUI was invisible: the pane offered to send, and a + * send appended to the draft. Only after the startup latch (an unpainted + * composer is still `provider-not-ready`), and `unknown` publishes nothing, + * so a running turn or a frame we cannot read never flaps the pane. + */ + private publishNativeComposer(): void { + if (!this.composerReady || this.exited) return + const state = this.nativeComposerState() + if (state === 'drafted' && !this.nativeDraftPublished) { + this.nativeDraftPublished = true + this.emit('input-readiness', { ready: false, reason: 'composer-occupied' }) + } else if (state === 'empty' && this.nativeDraftPublished) { + this.nativeDraftPublished = false + this.emit('input-readiness', { ready: true, reason: 'ready' }) + } + } + private markComposerReady(screen: string): void { if (this.composerReady || this.exited || !isCodexReadyForPromptScreen(screen)) return // WHY this latches instead of mirroring every screen: the composer diff --git a/src/providers/codex/runtime/promptDelivery.ts b/src/providers/codex/runtime/promptDelivery.ts index 505766dab..3914e3a62 100644 --- a/src/providers/codex/runtime/promptDelivery.ts +++ b/src/providers/codex/runtime/promptDelivery.ts @@ -65,7 +65,13 @@ export async function deliverCodexPrompt( // draft. Keep the stricter rule provider-owned and immediately before the // reserved write. Plain text cannot distinguish dim placeholder content // from the same words typed by a human; ambiguity is a refusal, not consent. - if (io.requireEmptyNativeComposer && !isCodexNativeComposerEmpty(io.session.snapshotScreen?.() ?? '')) { + // #1313: the text check alone could never call Codex 0.157's empty composer + // empty (a dim placeholder under which Codex adds a hint row), so every + // browser-pocket restart was refused. The package's attribute-aware + // reading says `empty` only with Codex's own empty-composer hint; either + // proof is consent, anything else still refuses. + const nativeEmpty = (io.session as { nativeComposerState?: () => string }).nativeComposerState?.() === 'empty' + if (io.requireEmptyNativeComposer && !nativeEmpty && !isCodexNativeComposerEmpty(io.session.snapshotScreen?.() ?? '')) { return { ok: false, stage: 'before-write', code: 'not-ready', retrySafe: true, disposition: 'retry-after-resolve', promptWritten: false, enterWritten: false, From f6fce7365c5003085c70bd65c90d4044ef3738b6 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 10:38:20 -0700 Subject: [PATCH 03/11] fix(codex): an unclassifiable composer is never ready (steering q40) A draft longer than the package's composer bound read `unknown`, the screen check still passed, and a delivery pasted into the human's draft. Ready now needs a proof of empty (the package's reading or the old bare-marker text proof); text that nothing proves empty is refused at once as a human's to resolve, and readiness withdraws ready as provider-not-ready without latching occupied. Tests use steering's 13-row shape. Co-Authored-By: Claude Opus 5.5 --- .../codexSession.nativeComposer.test.ts | 41 +++++++++++-- src/providers/codex/runtime/codexSession.ts | 59 +++++++++++++------ 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts index f39865390..7e8afe566 100644 --- a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts +++ b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts @@ -21,7 +21,11 @@ const at = (label: string) => recording.events.find(event => event.label === lab const headlesses: CodexHeadless[] = [] afterEach(() => { headlesses.splice(0) }) -async function sessionAt(until: number): Promise<{ session: CodexSession; headless: CodexHeadless }> { +async function sessionWith(bytes: string[]): Promise<{ session: CodexSession; headless: CodexHeadless }> { + return sessionAt(Number.POSITIVE_INFINITY, bytes) +} + +async function sessionAt(until: number, bytes?: string[]): Promise<{ session: CodexSession; headless: CodexHeadless }> { const listeners = new Set<(data: string) => void>() const pty = { pid: 1, process: 'codex', cols: recording.cols, rows: recording.rows, handleFlowControl: false, @@ -33,9 +37,8 @@ async function sessionAt(until: number): Promise<{ session: CodexSession; headle headlesses.push(headless) const terminal = (headless as unknown as { terminal: { attach(): void; snapshotComposerCells(): unknown } }).terminal terminal.attach() - for (const event of recording.events) { - if (event.dir === 'out' && event.t < until) for (const listener of listeners) listener(event.data!) - } + const chunks = bytes ?? recording.events.filter(event => event.dir === 'out' && event.t < until).map(event => event.data!) + for (const chunk of chunks) for (const listener of listeners) listener(chunk) const deadline = Date.now() + 2000 while (terminal.snapshotComposerCells() === null && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 5)) const session = new CodexSession() @@ -75,4 +78,34 @@ describe('Codex native composer (0.157 recording)', () => { ;(drafted.session as unknown as { publishNativeComposer(): void }).publishNativeComposer() expect(readiness.at(-1)).toEqual({ ready: true, reason: 'ready' }) }) + + // Steering q40: a draft longer than the package's 12-row composer bound + // reads `unknown`, while the legacy screen check still sees `›` over a + // status row. `unknown` must not be ready: the paste would land in the + // human's draft. + const longDraft = ['\x1b[2J\x1b[H› ', ...Array.from({ length: 12 }, () => ' real draft'), '', ' GPT-6-Sol high fast · ~/p'].join('\r\n') + + it('does not write into a draft the composer reading cannot classify', async () => { + const { session, headless } = await sessionWith([longDraft]) + expect(headless.getComposerState()).toBe('unknown') + const write = vi.fn(() => true) + expect(await deliverCodexPrompt({ session, sessionId: 'agent', prompt: 'Status?', write } as never)) + .toMatchObject({ ok: false, stage: 'before-write', disposition: 'retry-after-resolve', promptWritten: false }) + expect(write).not.toHaveBeenCalled() + }) + + it('withdraws ready when the composer becomes unclassifiable, without latching occupied', async () => { + const readiness: Array<{ ready: boolean; reason?: string }> = [] + const idle = await sessionAt(at('type-draft')) + const session = idle.session as unknown as { composerReady: boolean; headless: unknown; publishNativeComposer(): void; on: CodexSession['on'] } + idle.session.on('input-readiness', state => readiness.push(state)) + session.composerReady = true + session.publishNativeComposer() + session.headless = (await sessionWith([longDraft])).headless + session.publishNativeComposer() + expect(readiness.at(-1)).toEqual({ ready: false, reason: 'provider-not-ready' }) + session.headless = idle.headless + session.publishNativeComposer() + expect(readiness.at(-1)).toEqual({ ready: true, reason: 'ready' }) + }) }) diff --git a/src/providers/codex/runtime/codexSession.ts b/src/providers/codex/runtime/codexSession.ts index 4d042e0c4..e5c95c27d 100644 --- a/src/providers/codex/runtime/codexSession.ts +++ b/src/providers/codex/runtime/codexSession.ts @@ -34,7 +34,7 @@ import type { PromptGateState, PromptReadinessOutcome, } from '@shared/types/session.js' -import { isCodexReadyForPromptScreen } from '@providers/codex/runtime/codexReadyForPrompt.js' +import { isCodexNativeComposerEmpty, isCodexReadyForPromptScreen } from '@providers/codex/runtime/codexReadyForPrompt.js' import { addCodexBuiltInMcpLaunchConfig } from '@providers/shared/runtime/builtInMcpLaunch.js' import { addCodexUserMcpLaunchConfig, type CodexShellPolicyStyle } from '@providers/shared/runtime/userMcpLaunch.js' import type { ResolvedUserMcpServer } from '@shared/userMcp/types.js' @@ -236,8 +236,9 @@ export class CodexSession extends EventEmitter { private tldrHooks: PrivateCodexTldrHooks | null = null private exited = false private composerReady = false - /** Whether `composer-occupied` is the last readiness published (#800). */ - private nativeDraftPublished = false + /** The native-composer readiness last published after the startup latch + * (#800, q40). `ready` is what markComposerReady itself publishes. */ + private nativeComposerPublished: 'ready' | 'occupied' | 'unverified' = 'ready' private readonly cwd: string private readonly cols: number @@ -331,7 +332,7 @@ export class CodexSession extends EventEmitter { } this.exited = false this.composerReady = false - this.nativeDraftPublished = false + this.nativeComposerPublished = 'ready' this.emit('input-readiness', { ready: false, reason: this.resumeSessionId ? 'replaying-history' : 'provider-not-ready', @@ -646,7 +647,7 @@ export class CodexSession extends EventEmitter { this.headless.on('exit', ({ exitCode, signal }) => { this.exited = true this.composerReady = false - this.nativeDraftPublished = false + this.nativeComposerPublished = 'ready' this.emit('input-readiness', { ready: false, reason: 'provider-not-ready' }) this.emit('exit', { exitCode, signal }) }) @@ -879,11 +880,29 @@ export class CodexSession extends EventEmitter { // submit both. Only the package's attribute-aware `drafted` blocks; // `unknown` keeps today's behaviour so a frame we cannot read never // stalls a prompt (the Claude gate once latched occupied for 186 s). - if (this.nativeComposerState() === 'drafted') { + const composer = this.nativeComposerState() + if (composer === 'drafted') { resolve({ kind: 'occupied', reason: 'human-draft' }) return } - resolve({ kind: 'ready', waitedMs: Date.now() - startedAt }) + // `unknown` is NOT ready (steering q40): a draft longer than the + // package's composer bound reads `unknown` while this screen check + // still passes, and the paste landed in the human's draft. Ready + // needs a proof of empty: the package's reading (Codex's own + // empty hint), or the old text proof of a bare `›` above the + // status row, which is the only proof 0.149.1 and narrow 0.157 + // panes (no hint row) can give. + if (composer === 'empty' || isCodexNativeComposerEmpty(screen)) { + resolve({ kind: 'ready', waitedMs: Date.now() - startedAt }) + return + } + // Text in the composer that nothing proves is a placeholder: refuse + // AT ONCE as a human's to resolve (retry-after-resolve). Polling to + // the deadline instead held the delivery reservation for 15 s and + // answered retry-same-session, the wrong instruction for a draft. + // Only this write gate says occupied; readiness publication says + // provider-not-ready for the same frame, so nothing latches. + resolve({ kind: 'occupied', reason: 'human-draft' }) return } if (Date.now() >= deadlineAt) { @@ -974,19 +993,25 @@ export class CodexSession extends EventEmitter { * `ready` (#800). Before this, readiness latched once at startup, so a * draft typed into the TUI was invisible: the pane offered to send, and a * send appended to the draft. Only after the startup latch (an unpainted - * composer is still `provider-not-ready`), and `unknown` publishes nothing, - * so a running turn or a frame we cannot read never flaps the pane. + * composer is still `provider-not-ready`). */ private publishNativeComposer(): void { if (!this.composerReady || this.exited) return - const state = this.nativeComposerState() - if (state === 'drafted' && !this.nativeDraftPublished) { - this.nativeDraftPublished = true - this.emit('input-readiness', { ready: false, reason: 'composer-occupied' }) - } else if (state === 'empty' && this.nativeDraftPublished) { - this.nativeDraftPublished = false - this.emit('input-readiness', { ready: true, reason: 'ready' }) - } + // Same rule as the write gate: `drafted` is occupied, a proof of empty is + // ready, and anything else withdraws ready without claiming a draft + // (steering q40), so a pane never offers to send into a composer we + // cannot read, and never latches occupied either. + const composer = this.nativeComposerState() + const next = composer === 'drafted' + ? 'occupied' + : composer === 'empty' || isCodexNativeComposerEmpty(this.headless?.getScreen() ?? '') + ? 'ready' + : 'unverified' + if (next === this.nativeComposerPublished) return + this.nativeComposerPublished = next + this.emit('input-readiness', next === 'ready' + ? { ready: true, reason: 'ready' } + : { ready: false, reason: next === 'occupied' ? 'composer-occupied' : 'provider-not-ready' }) } private markComposerReady(screen: string): void { From e8490f29ec13438a76ece7773d6748a016148dfe Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 15:36:50 -0700 Subject: [PATCH 04/11] =?UTF-8?q?fix(codex):=20#1319=20review=20A=20?= =?UTF-8?q?=E2=80=94=20a=20text-only=20proof=20of=20empty=20must=20hold=20?= =?UTF-8?q?on=20two=20polls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plain screen can lag PTY bytes still being parsed, which is exactly when the cell reading says `unknown`; one stale bare-marker read consented and a normal delivery pasted into a draft the human had just started. The text proof now has to hold on two consecutive polls. Main merged in. Co-Authored-By: Claude Opus 5.5 --- .../codexSession.nativeComposer.test.ts | 21 +++++++++ src/providers/codex/runtime/codexSession.ts | 44 ++++++++++++++----- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts index 7e8afe566..6edfc47e6 100644 --- a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts +++ b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts @@ -108,4 +108,25 @@ describe('Codex native composer (0.157 recording)', () => { session.publishNativeComposer() expect(readiness.at(-1)).toEqual({ ready: true, reason: 'ready' }) }) + + // #1319 review A2: the text-only proof of empty reads the plain screen, + // which can lag PTY bytes still being parsed (the cell reading is then + // `unknown`). A single stale bare-marker read must not consent: a human who + // just started typing would get the prompt pasted into the draft. + it('does not consent on one stale bare-marker read', async () => { + const session = new CodexSession() + const screens = ['› \n\n gpt-5.6-sol high · /tmp/x', '› half-typed human draft\n\n gpt-5.6-sol high · /tmp/x'] + let reads = 0 + ;(session as unknown as { headless: unknown }).headless = { + getScreen: () => screens[Math.min(reads++, 1)]!, + getComposerState: () => 'unknown', + getConditionSnapshot: () => ({ provider: 'codex', conditions: {}, ts: Date.now() }), + } + const write = vi.fn(() => true) + // A normal delivery: its only check is the readiness gate, so a stale + // read there is the whole decision. + const result = await deliverCodexPrompt({ session, sessionId: 'agent', prompt: 'Status?', write } as never) + expect(result).toMatchObject({ ok: false, promptWritten: false }) + expect(write).not.toHaveBeenCalled() + }) }) diff --git a/src/providers/codex/runtime/codexSession.ts b/src/providers/codex/runtime/codexSession.ts index e5c95c27d..24f0a87e8 100644 --- a/src/providers/codex/runtime/codexSession.ts +++ b/src/providers/codex/runtime/codexSession.ts @@ -830,6 +830,9 @@ export class CodexSession extends EventEmitter { if (this.exited) return { kind: 'terminal', reason: 'exited' } return await new Promise(resolve => { + // Consecutive polls on which only the TEXT proved the composer empty + // (#1319 review A2); see the use below. + let textOnlyEmptyPolls = 0 const tick = (): void => { if (this.exited) { resolve({ kind: 'terminal', reason: 'exited' }) @@ -877,9 +880,9 @@ export class CodexSession extends EventEmitter { this.markComposerReady(screen) // #800: the screen check above passes for ANY `›` row, including a // human's draft, and a paste would then append to that draft and - // submit both. Only the package's attribute-aware `drafted` blocks; - // `unknown` keeps today's behaviour so a frame we cannot read never - // stalls a prompt (the Claude gate once latched occupied for 186 s). + // submit both. The package's attribute-aware reading decides: + // `drafted` is occupied, `empty` is ready, and `unknown` needs the + // text proof below. const composer = this.nativeComposerState() if (composer === 'drafted') { resolve({ kind: 'occupied', reason: 'human-draft' }) @@ -892,18 +895,35 @@ export class CodexSession extends EventEmitter { // empty hint), or the old text proof of a bare `›` above the // status row, which is the only proof 0.149.1 and narrow 0.157 // panes (no hint row) can give. - if (composer === 'empty' || isCodexNativeComposerEmpty(screen)) { + if (composer === 'empty') { resolve({ kind: 'ready', waitedMs: Date.now() - startedAt }) return } - // Text in the composer that nothing proves is a placeholder: refuse - // AT ONCE as a human's to resolve (retry-after-resolve). Polling to - // the deadline instead held the delivery reservation for 15 s and - // answered retry-same-session, the wrong instruction for a draft. - // Only this write gate says occupied; readiness publication says - // provider-not-ready for the same frame, so nothing latches. - resolve({ kind: 'occupied', reason: 'human-draft' }) - return + // The text proof reads the plain screen, which can lag PTY bytes + // still being parsed; that is exactly when the cell reading says + // `unknown` (#1319 review A2). A human who had just started typing + // would then get the prompt pasted into the draft. So a text-only + // proof must hold on two polls in a row (one poll interval apart, + // time for the pending bytes to parse) before it consents. + if (isCodexNativeComposerEmpty(screen)) { + textOnlyEmptyPolls += 1 + if (textOnlyEmptyPolls >= 2) { + resolve({ kind: 'ready', waitedMs: Date.now() - startedAt }) + return + } + } else { + // Text in the composer that nothing proves is a placeholder: + // refuse AT ONCE as a human's to resolve (retry-after-resolve). + // Polling to the deadline instead held the delivery reservation + // for 15 s and answered retry-same-session, the wrong instruction + // for a draft. Only this write gate says occupied; readiness + // publication says provider-not-ready for the same frame, so + // nothing latches. + resolve({ kind: 'occupied', reason: 'human-draft' }) + return + } + } else { + textOnlyEmptyPolls = 0 } if (Date.now() >= deadlineAt) { resolve({ From 4b8e972970fa816c01035704ed5e10705dbb1fbe Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 16:46:03 -0700 Subject: [PATCH 05/11] =?UTF-8?q?fix(codex):=20#1319=20review=20=E2=80=94?= =?UTF-8?q?=20the=20pane's=20own=20Enter=20never=20writes=20into=20a=20nat?= =?UTF-8?q?ive=20draft;=20Ctrl+C=20clears=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review C: a Codex submit is one raw PTY write, so while composer-occupied, Enter in Agent Code's composer pasted the prompt after the native draft and Codex submitted both. Submit now refuses while occupied (every provider), keeping the draft, and Ctrl+C reaches the agent, since that is how 0.157 clears a draft. Review B: publication is pinned through the real screen listener, and the restart's final empty check has its own test. The orchestration hold and the nativeDraft tool text now mention a native draft. Co-Authored-By: Claude Opus 5.5 --- src/mcp/runtime/createBuiltInMcpServer.ts | 2 +- .../codexSession.launchOrdering.test.ts | 43 ++++++++++++++++++- .../codexSession.nativeComposer.test.ts | 14 ++++++ .../src/workspace/control/conditions.ts | 2 +- .../useComposerKeybinds.tab.renderer.test.tsx | 28 ++++++++++++ .../tile-tree/TileLeaf/useComposerKeybinds.ts | 15 ++++++- 6 files changed, 100 insertions(+), 4 deletions(-) diff --git a/src/mcp/runtime/createBuiltInMcpServer.ts b/src/mcp/runtime/createBuiltInMcpServer.ts index 54cc9a2c9..9cd4fb5d8 100644 --- a/src/mcp/runtime/createBuiltInMcpServer.ts +++ b/src/mcp/runtime/createBuiltInMcpServer.ts @@ -1353,7 +1353,7 @@ function orchestrationCreateAgentCallKey( // mid-turn answers not-ready too, and for it the prompt lands after // the current turn, not after a startup — a parent reading // "starting" would misjudge how long it waits. - message: `The agent cannot take a prompt yet (${delivery.message}), so the prompt is waiting: it will be delivered as soon as the agent can accept it — after it finishes starting, after a dialog is answered, or after its current turn if it is busy. Do not send it again. orchestration_wait_agents treats the agent as working until the prompt lands and it answers; orchestration_read_agent shows promptSubmitted once it lands. Sending another prompt to this agent before then REPLACES this one.`, + message: `The agent cannot take a prompt yet (${delivery.message}), so the prompt is waiting: it will be delivered as soon as the agent can accept it — after it finishes starting, after a dialog is answered, after its current turn if it is busy, or after someone clears a draft left in the agent's own composer (that one waits for a human). Do not send it again. orchestration_wait_agents treats the agent as working until the prompt lands and it answers; orchestration_read_agent shows promptSubmitted once it lands. Sending another prompt to this agent before then REPLACES this one.`, }) } if (!delivery.ok) { diff --git a/src/providers/codex/runtime/codexSession.launchOrdering.test.ts b/src/providers/codex/runtime/codexSession.launchOrdering.test.ts index 4b98ea507..007069992 100644 --- a/src/providers/codex/runtime/codexSession.launchOrdering.test.ts +++ b/src/providers/codex/runtime/codexSession.launchOrdering.test.ts @@ -19,6 +19,11 @@ const launch = vi.hoisted(() => ({ // event ladder via `recordTrustInEvents`. trustCalls: [] as string[], recordTrustInEvents: false, + // #1319 review B: the fake keeps its listeners and a composer reading, so + // a test can drive a real 'screen' event through a started session. + listeners: new Map void>(), + composer: 'unknown' as 'empty' | 'drafted' | 'unknown', + screen: '', })) vi.mock('node-pty', () => ({ @@ -45,7 +50,13 @@ vi.mock('codex-headless', () => { launch.headlessOptions = options } - on(): this { return this } + on(event: string, listener: (payload: unknown) => void): this { + launch.listeners.set(event, listener) + return this + } + getComposerState() { return launch.composer } + getScreen() { return launch.screen } + getConditionSnapshot() { return { provider: 'codex', conditions: {}, ts: 0 } } async start() { launch.events.push('headless:start') return { sessionsDir: '/recorded/codex/sessions' } @@ -151,6 +162,9 @@ beforeEach(() => { }) launch.profileBarrier = Promise.resolve() launch.releaseProfile = () => undefined + launch.listeners = new Map() + launch.composer = 'unknown' + launch.screen = '' }) describe('CodexSession resume launch attestation ordering', () => { @@ -473,3 +487,30 @@ describe('CodexSession resume launch attestation ordering', () => { } }) }) + +// #1319 review B: publication must run from the headless 'screen' listener +// itself; a test calling publishNativeComposer directly passed with the +// listener's call deleted. +describe('CodexSession native composer publication', () => { + it('publishes a native draft as composer-occupied from the screen event, and ready once cleared', async () => { + const session = new CodexSession({ binary: 'recorded-codex', cwd: '/recorded/worktree', useProxy: false }) + const readiness: Array<{ ready: boolean; reason?: string }> = [] + session.on('input-readiness', state => readiness.push(state)) + await session.start() + const screen = launch.listeners.get('screen')! + expect(screen).toBeDefined() + launch.composer = 'empty' + launch.screen = '› Ask Codex to do anything\n\n gpt · /recorded/worktree\n ? for shortcuts' + screen({ plain: launch.screen }) + expect(readiness.at(-1)).toEqual({ ready: true, reason: 'ready' }) + launch.composer = 'drafted' + launch.screen = '› please review the draft\n\n gpt · /recorded/worktree' + screen({ plain: launch.screen }) + expect(readiness.at(-1)).toEqual({ ready: false, reason: 'composer-occupied' }) + launch.composer = 'empty' + launch.screen = '› Ask Codex to do anything\n\n gpt · /recorded/worktree\n ? for shortcuts' + screen({ plain: launch.screen }) + expect(readiness.at(-1)).toEqual({ ready: true, reason: 'ready' }) + await session.stop() + }) +}) diff --git a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts index 6edfc47e6..1b46560da 100644 --- a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts +++ b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts @@ -129,4 +129,18 @@ describe('Codex native composer (0.157 recording)', () => { expect(result).toMatchObject({ ok: false, promptWritten: false }) expect(write).not.toHaveBeenCalled() }) + + // #1319 review B: the restart's final empty check must refuse on its own, + // even when readiness has just said ready. + it('refuses a restart when the final check reads a draft after readiness said ready', async () => { + const session = { + awaitReadyForPrompt: async () => ({ kind: 'ready', waitedMs: 0 }), + nativeComposerState: () => 'drafted', + snapshotScreen: () => '› typed just now\n\n gpt-5.6-sol high · /tmp/x', + } + const write = vi.fn(() => true) + const result = await deliverCodexPrompt({ session, sessionId: 'agent', prompt: 'Restart the server', write, requireEmptyNativeComposer: true } as never) + expect(result).toMatchObject({ ok: false, promptWritten: false }) + expect(write).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/workspace/control/conditions.ts b/src/renderer/src/workspace/control/conditions.ts index 0b78b89a6..46aceebe0 100644 --- a/src/renderer/src/workspace/control/conditions.ts +++ b/src/renderer/src/workspace/control/conditions.ts @@ -16,7 +16,7 @@ export function conditionControlCapabilities() { } return [ defineCapability({ id: 'agents.inputInspect', title: 'Inspect provider draft uncertainty', execution: 'window', effect: 'read', target: { kind: 'session', field: 'sessionId' }, - description: 'Inspect backend readiness and whether the full native terminal draft is known. Currently nativeDraft.state is unknown: neither xterm accessibility input nor an empty Agent Code draft proves the provider composer is empty. Prefer agents.prompt with its provider-owned delivery checks. Before computer paste/Return, establish the full native composer through the actual UI; do not clear or submit uncertain existing text. Reads never wake or type.', + description: 'Inspect backend readiness and whether the full native terminal draft is known. nativeDraft.state is occupied when the provider reports a draft in its own composer (Claude, and Codex since #800); otherwise it is unknown: neither xterm accessibility input nor an empty Agent Code draft proves the provider composer is empty. Prefer agents.prompt with its provider-owned delivery checks. Before computer paste/Return, establish the full native composer through the actual UI; do not clear or submit uncertain existing text. Reads never wake or type.', input: conditionTargetInput, output: nativeInputOutput, handler: async input => nativeInputOutput.parse(await invoke('sessions.inputInspect', input)), }), defineCapability({ diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx index 92ea67f89..8d1650a6e 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx @@ -190,3 +190,31 @@ describe('composer Escape while the provider composer is occupied', () => { expect(ready.send).toHaveBeenCalledWith('\x1b') }) }) + +// #1319 review C: a Codex submit is one raw PTY write, not a gated delivery, +// so while the agent's own composer held a draft (composer-occupied), Enter +// here pasted the prompt after that draft and Codex submitted both. +describe('composer submit while the agent composer is occupied', () => { + it('keeps the draft and writes nothing on Enter', async () => { + const { hook, send, setInputText, showPaneToast, feed } = setup({ + provider: 'codex', + input: 'Status?', + runtime: { inputReady: false, inputReadinessReason: 'composer-occupied' }, + }) + await act(async () => { await hook.result.current.onKeyDown(keyEvent('Enter')) }) + expect(send).not.toHaveBeenCalled() + expect(feed.calls.filter(call => call.method === 'sendInput')).toHaveLength(0) + expect(setInputText).not.toHaveBeenCalled() + expect(showPaneToast).toHaveBeenCalledWith(SESSION, expect.stringContaining('Clear')) + }) + + // The 0.157 recording clears a draft with Ctrl+C; it must reach the agent. + it('lets Ctrl+C through to clear the agent\'s draft', async () => { + const { hook, send } = setup({ + provider: 'codex', + runtime: { inputReady: false, inputReadinessReason: 'composer-occupied' }, + }) + await act(async () => { await hook.result.current.onKeyDown(keyEvent('c', { ctrlKey: true })) }) + expect(send).toHaveBeenCalledWith('\x03') + }) +}) diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts index 9d3dec7b1..8fd66e5f7 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts @@ -161,6 +161,15 @@ export function useComposerKeybinds({ workspace.showPaneToast(sessionId, 'Wait for the provider switch to finish before sending a prompt') return } + // The agent's own composer holds text (#1319 review C). Claude's submit + // goes through the delivery gate, which refuses this, but Codex's is one + // raw PTY write: sending now pasted the prompt after the native draft and + // Codex submitted both. Refuse for every provider, keep this draft, and + // say how to clear the other one. + if (composerOccupied && (input.trim().length > 0 || runtime.draftImages.length > 0)) { + workspace.showPaneToast(sessionId, "Clear the draft in the agent's own composer first (Esc or Ctrl+C clears it)") + return + } const draftImages = runtime.draftImages // A send already in flight owns the draft (#1181). The key handler and the // in-flight ref normally stop a second submit, but the ref is per mount: @@ -705,7 +714,11 @@ export function useComposerKeybinds({ } if (e.ctrlKey && e.key.toLowerCase() === 'c') { e.preventDefault() - if (!backendReady) { + // Composer-occupied is the one not-ready state where Ctrl+C must reach + // the agent (#1319 review C): it is how Codex 0.157 clears a native + // draft (recorded: ctrl-c-1 restores the placeholder), and during a + // turn with a queued draft it is the interrupt. + if (!backendReady && !composerOccupied) { blockBackendWrite() return } From 9878c35dfac2ac7b54d3e46c652f7f4e7d8da688 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 16:57:51 -0700 Subject: [PATCH 06/11] =?UTF-8?q?fix(codex):=20#1319=20review=20round=202?= =?UTF-8?q?=20=E2=80=94=20text=20proofs=20read=20a=20parsed=20frame;=20Ctr?= =?UTF-8?q?l+C=20keeps=20our=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2: the text proof of an empty composer read the plain screen, which shows the previous paint while PTY bytes are still being parsed, and two polls do not prove the parser caught up. The write gate, readiness publication and the restart's final check now read the settled screen (codex-headless#55), null while anything is unparsed; null waits or refuses, never consents. B: Ctrl+C while the agent's composer is occupied clears the AGENT's draft only; it no longer deletes the follow-up typed in Agent Code. Pins the image-only occupied refusal too. Co-Authored-By: Claude Opus 5.5 --- packages/codex-headless | 2 +- .../codexSession.launchOrdering.test.ts | 1 + .../codexSession.nativeComposer.test.ts | 24 ++++++++++ .../runtime/codexSession.readiness.test.ts | 2 + src/providers/codex/runtime/codexSession.ts | 45 ++++++++++++++----- src/providers/codex/runtime/promptDelivery.ts | 6 ++- .../useComposerKeybinds.tab.renderer.test.tsx | 21 ++++++++- .../tile-tree/TileLeaf/useComposerKeybinds.ts | 7 ++- 8 files changed, 92 insertions(+), 16 deletions(-) diff --git a/packages/codex-headless b/packages/codex-headless index 1ac3c50ec..5db3b938c 160000 --- a/packages/codex-headless +++ b/packages/codex-headless @@ -1 +1 @@ -Subproject commit 1ac3c50ec87f31a08ab417fd0828efd1288d78e9 +Subproject commit 5db3b938c7b4391465d9e3379a93cb87e107e475 diff --git a/src/providers/codex/runtime/codexSession.launchOrdering.test.ts b/src/providers/codex/runtime/codexSession.launchOrdering.test.ts index 007069992..f465cb917 100644 --- a/src/providers/codex/runtime/codexSession.launchOrdering.test.ts +++ b/src/providers/codex/runtime/codexSession.launchOrdering.test.ts @@ -56,6 +56,7 @@ vi.mock('codex-headless', () => { } getComposerState() { return launch.composer } getScreen() { return launch.screen } + getSettledScreen() { return launch.screen } getConditionSnapshot() { return { provider: 'codex', conditions: {}, ts: 0 } } async start() { launch.events.push('headless:start') diff --git a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts index 1b46560da..b2793ef25 100644 --- a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts +++ b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts @@ -119,6 +119,7 @@ describe('Codex native composer (0.157 recording)', () => { let reads = 0 ;(session as unknown as { headless: unknown }).headless = { getScreen: () => screens[Math.min(reads++, 1)]!, + getSettledScreen: () => screens[Math.min(reads, 1)]!, getComposerState: () => 'unknown', getConditionSnapshot: () => ({ provider: 'codex', conditions: {}, ts: Date.now() }), } @@ -130,6 +131,29 @@ describe('Codex native composer (0.157 recording)', () => { expect(write).not.toHaveBeenCalled() }) + // #1319 review round 2 A2: the parser can stay behind for longer than any + // number of polls (synchronized output), and the plain screen then keeps + // showing the bare marker from before the human's keystrokes. Only a + // parsed frame may prove empty; with none, nothing is written. + it('never consents on a bare marker that no parsed frame confirms', async () => { + const session = new CodexSession() + ;(session as unknown as { headless: unknown }).headless = { + getScreen: () => '› \n\n gpt-5.6-sol high · /tmp/x', + getSettledScreen: () => null, + getComposerState: () => 'unknown', + getConditionSnapshot: () => ({ provider: 'codex', conditions: {}, ts: Date.now() }), + } + const readiness = await session.awaitReadyForPrompt({ timeoutMs: 400 } as never) + expect(readiness.kind).not.toBe('ready') + const write = vi.fn(() => true) + const restart = await deliverCodexPrompt({ + session: { awaitReadyForPrompt: async () => ({ kind: 'ready', waitedMs: 0 }), nativeComposerState: () => 'unknown', settledScreen: () => null, snapshotScreen: () => '› \n\n gpt-5.6-sol high · /tmp/x' }, + sessionId: 'agent', prompt: 'Restart the server', write, requireEmptyNativeComposer: true, + } as never) + expect(restart).toMatchObject({ ok: false, promptWritten: false }) + expect(write).not.toHaveBeenCalled() + }) + // #1319 review B: the restart's final empty check must refuse on its own, // even when readiness has just said ready. it('refuses a restart when the final check reads a draft after readiness said ready', async () => { diff --git a/src/providers/codex/runtime/codexSession.readiness.test.ts b/src/providers/codex/runtime/codexSession.readiness.test.ts index ba775c849..ce28c4cff 100644 --- a/src/providers/codex/runtime/codexSession.readiness.test.ts +++ b/src/providers/codex/runtime/codexSession.readiness.test.ts @@ -25,6 +25,7 @@ function installHeadless( ): void { ;(session as unknown as { headless: unknown }).headless = { getScreen: () => screen, + getSettledScreen: () => screen, getConditionSnapshot: () => ({ provider: 'codex', conditions, ts: Date.now() }), } } @@ -226,6 +227,7 @@ describe('CodexSession prompt readiness lifecycle', () => { let screen = 'Do you trust the contents of this directory?' ;(session as unknown as { headless: unknown }).headless = { getScreen: () => screen, + getSettledScreen: () => screen, getConditionSnapshot: () => ({ provider: 'codex', conditions, ts: Date.now() }), } diff --git a/src/providers/codex/runtime/codexSession.ts b/src/providers/codex/runtime/codexSession.ts index 24f0a87e8..4a56545d9 100644 --- a/src/providers/codex/runtime/codexSession.ts +++ b/src/providers/codex/runtime/codexSession.ts @@ -899,17 +899,28 @@ export class CodexSession extends EventEmitter { resolve({ kind: 'ready', waitedMs: Date.now() - startedAt }) return } - // The text proof reads the plain screen, which can lag PTY bytes - // still being parsed; that is exactly when the cell reading says - // `unknown` (#1319 review A2). A human who had just started typing - // would then get the prompt pasted into the draft. So a text-only - // proof must hold on two polls in a row (one poll interval apart, - // time for the pending bytes to parse) before it consents. + // The text proof must come from a PARSED frame (#1319 review A2). + // `screen` above is the plain buffer as it stands, which shows the + // previous paint while PTY bytes are still queued in the parser; + // that is exactly when the cell reading says `unknown`. A human + // who had just started typing would then get the prompt pasted + // into the draft. Round 1 required two polls in a row, but elapsed + // time proves nothing: the parser can stay behind for a long time + // under synchronized output (HeadlessTerminal's known issue). So + // the proof reads the settled screen, which is null while anything + // is unparsed; null neither consents nor refuses, it waits. The + // two-poll rule stays as a second guard against a frame Codex + // repaints between polls. if (isCodexNativeComposerEmpty(screen)) { - textOnlyEmptyPolls += 1 - if (textOnlyEmptyPolls >= 2) { - resolve({ kind: 'ready', waitedMs: Date.now() - startedAt }) - return + const settled = this.settledScreen() + if (settled !== null && isCodexNativeComposerEmpty(settled)) { + textOnlyEmptyPolls += 1 + if (textOnlyEmptyPolls >= 2) { + resolve({ kind: 'ready', waitedMs: Date.now() - startedAt }) + return + } + } else { + textOnlyEmptyPolls = 0 } } else { // Text in the composer that nothing proves is a placeholder: @@ -1024,7 +1035,7 @@ export class CodexSession extends EventEmitter { const composer = this.nativeComposerState() const next = composer === 'drafted' ? 'occupied' - : composer === 'empty' || isCodexNativeComposerEmpty(this.headless?.getScreen() ?? '') + : composer === 'empty' || isCodexNativeComposerEmpty(this.settledScreen() ?? '') ? 'ready' : 'unverified' if (next === this.nativeComposerPublished) return @@ -1052,6 +1063,18 @@ export class CodexSession extends EventEmitter { return this.headless?.getScreen() ?? '' } + /** + * The plain screen from a fully parsed frame, or null while PTY bytes are + * still being parsed (#1319 review A2). Every TEXT proof that the native + * composer is empty reads this, never `snapshotScreen()`, which can show + * the paint from before the human's latest keystrokes. A headless without + * the method (an older package) gives null, so the text proof fails closed. + */ + settledScreen(): string | null { + const headless = this.headless as { getSettledScreen?: () => string | null } | null + return headless?.getSettledScreen?.() ?? null + } + snapshotScreenAsMarkdown(): string { return this.headless?.getScreenMarkdown() ?? '' } diff --git a/src/providers/codex/runtime/promptDelivery.ts b/src/providers/codex/runtime/promptDelivery.ts index 3914e3a62..e7feba2fc 100644 --- a/src/providers/codex/runtime/promptDelivery.ts +++ b/src/providers/codex/runtime/promptDelivery.ts @@ -70,8 +70,12 @@ export async function deliverCodexPrompt( // browser-pocket restart was refused. The package's attribute-aware // reading says `empty` only with Codex's own empty-composer hint; either // proof is consent, anything else still refuses. + // The text proof reads the settled screen (#1319 review A2): the plain + // screen can be a paint from before bytes still being parsed, and null + // (bytes in flight, or a session without the method) is a refusal. const nativeEmpty = (io.session as { nativeComposerState?: () => string }).nativeComposerState?.() === 'empty' - if (io.requireEmptyNativeComposer && !nativeEmpty && !isCodexNativeComposerEmpty(io.session.snapshotScreen?.() ?? '')) { + const settledScreen = (io.session as { settledScreen?: () => string | null }).settledScreen?.() ?? null + if (io.requireEmptyNativeComposer && !nativeEmpty && !isCodexNativeComposerEmpty(settledScreen ?? '')) { return { ok: false, stage: 'before-write', code: 'not-ready', retrySafe: true, disposition: 'retry-after-resolve', promptWritten: false, enterWritten: false, diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx index 8d1650a6e..6793cbffe 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx @@ -208,13 +208,30 @@ describe('composer submit while the agent composer is occupied', () => { expect(showPaneToast).toHaveBeenCalledWith(SESSION, expect.stringContaining('Clear')) }) + // Round 2 B: a draft of only an image is still a send, and must refuse too. + it('keeps an image-only draft and writes nothing on Enter', async () => { + const image = { id: 'img', mediaType: 'image/png', base64Data: 'AA==', previewUrl: 'blob:x', filename: 'x.png' } + const { hook, send, showPaneToast } = setup({ + provider: 'codex', + input: '', + runtime: { inputReady: false, inputReadinessReason: 'composer-occupied', draftImages: [image] }, + }) + await act(async () => { await hook.result.current.onKeyDown(keyEvent('Enter')) }) + expect(send).not.toHaveBeenCalled() + expect(showPaneToast).toHaveBeenCalledWith(SESSION, expect.stringContaining('Clear')) + }) + // The 0.157 recording clears a draft with Ctrl+C; it must reach the agent. - it('lets Ctrl+C through to clear the agent\'s draft', async () => { - const { hook, send } = setup({ + // Round 2 B: and it must not also delete the follow-up the user typed here + // to send once that draft is gone (the refusal toast told them to press it). + it('lets Ctrl+C through to clear the agent\'s draft, keeping ours', async () => { + const { hook, send, setInputText } = setup({ provider: 'codex', + input: 'Status?', runtime: { inputReady: false, inputReadinessReason: 'composer-occupied' }, }) await act(async () => { await hook.result.current.onKeyDown(keyEvent('c', { ctrlKey: true })) }) expect(send).toHaveBeenCalledWith('\x03') + expect(setInputText).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts index 8fd66e5f7..b6e6ccf17 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.ts @@ -723,7 +723,12 @@ export function useComposerKeybinds({ return } await send('\x03') - setInputText('') + // While the agent's composer is occupied, Ctrl+C clears THAT draft, and + // the toast that refused Enter told the user to press it. Clearing ours + // too would delete the follow-up they typed to send once the agent's + // draft is gone (#1319 review round 2 B). Otherwise Ctrl+C keeps its + // terminal meaning of "abandon this line". + if (!composerOccupied) setInputText('') return } if (e.ctrlKey && e.key.toLowerCase() === 'd') { From 590838388d5a72eea88ce28d55bd046eb3c335f1 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 17:22:23 -0700 Subject: [PATCH 07/11] chore(codex): bump codex-headless to #55 review fixes (b575fdd) Settled reads now also refuse inside an open synchronized update and after an unredrawn resize, and count a chunk before pty-data listeners run. Interim pointer; the final bump goes to #55's merge commit. Co-Authored-By: Claude Opus 5.5 --- packages/codex-headless | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/codex-headless b/packages/codex-headless index 5db3b938c..b575fddf9 160000 --- a/packages/codex-headless +++ b/packages/codex-headless @@ -1 +1 @@ -Subproject commit 5db3b938c7b4391465d9e3379a93cb87e107e475 +Subproject commit b575fddf943c51677efb135a3e3c7e1144675d8c From 6aee6e6d8e5fe6be35b481095814075cae14e651 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 17:23:32 -0700 Subject: [PATCH 08/11] =?UTF-8?q?test(composer):=20#1319=20review=20round?= =?UTF-8?q?=202=20C=20=E2=80=94=20the=20occupied=20Enter=20refusal=20is=20?= =?UTF-8?q?provider-neutral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guard narrowed to provider === 'codex' survived every test. Claude also publishes composer-occupied and submits with a raw write, so it is pinned with a Claude pane. Co-Authored-By: Claude Opus 5.5 --- .../useComposerKeybinds.tab.renderer.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx index 6793cbffe..8ef798394 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useComposerKeybinds.tab.renderer.test.tsx @@ -208,6 +208,20 @@ describe('composer submit while the agent composer is occupied', () => { expect(showPaneToast).toHaveBeenCalledWith(SESSION, expect.stringContaining('Clear')) }) + // Round 2 C: the guard is provider-neutral on purpose. Claude publishes + // composer-occupied too (the Escape tests above), and its submit is also a + // raw write, so a Codex-only guard would reopen the same paste there. + it('refuses for every provider that reports an occupied composer, not only Codex', async () => { + const { hook, send, showPaneToast } = setup({ + provider: 'claude', + input: 'Status?', + runtime: { inputReady: false, inputReadinessReason: 'composer-occupied' }, + }) + await act(async () => { await hook.result.current.onKeyDown(keyEvent('Enter')) }) + expect(send).not.toHaveBeenCalled() + expect(showPaneToast).toHaveBeenCalledWith(SESSION, expect.stringContaining('Clear')) + }) + // Round 2 B: a draft of only an image is still a send, and must refuse too. it('keeps an image-only draft and writes nothing on Enter', async () => { const image = { id: 'img', mediaType: 'image/png', base64Data: 'AA==', previewUrl: 'blob:x', filename: 'x.png' } From dc03d23fab7422dc729430928ec9e0bfdb9cf6fe Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 17:24:01 -0700 Subject: [PATCH 09/11] =?UTF-8?q?test(codex):=20#1319=20review=20round=202?= =?UTF-8?q?=20B=20=E2=80=94=20a=20hintless=20pane=20returns=20to=20ready?= =?UTF-8?q?=20from=20the=20settled=20text=20proof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting the publication's text fallback passed every test. Pins that a pane with no shortcuts hint (package says unknown) publishes ready from a settled bare marker, and stays provider-not-ready while the settled read is null. Co-Authored-By: Claude Opus 5.5 --- .../codexSession.nativeComposer.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts index b2793ef25..d216e7754 100644 --- a/src/providers/codex/runtime/codexSession.nativeComposer.test.ts +++ b/src/providers/codex/runtime/codexSession.nativeComposer.test.ts @@ -109,6 +109,31 @@ describe('Codex native composer (0.157 recording)', () => { expect(readiness.at(-1)).toEqual({ ready: true, reason: 'ready' }) }) + // #1319 review round 2 B: 0.149.1 and narrow 0.157 panes show no shortcuts + // hint, so the package says `unknown` even for an empty composer. Once a + // draft there is cleared, the settled bare-marker text proof is what brings + // the pane back to ready; without it the pane would wait forever. A settled + // read of null (bytes unparsed) must not. + it('publishes ready from a settled bare marker on a pane without the hint, and not while unsettled', async () => { + const readiness: Array<{ ready: boolean; reason?: string }> = [] + const session = new CodexSession() + let settled: string | null = null + const internal = session as unknown as { composerReady: boolean; headless: unknown; publishNativeComposer(): void } + internal.headless = { + getScreen: () => '› \n\n gpt-5.6-sol high · /tmp/x', + getSettledScreen: () => settled, + getComposerState: () => 'unknown', + getConditionSnapshot: () => ({ provider: 'codex', conditions: {}, ts: Date.now() }), + } + session.on('input-readiness', state => readiness.push(state)) + internal.composerReady = true + internal.publishNativeComposer() + expect(readiness.at(-1)).toEqual({ ready: false, reason: 'provider-not-ready' }) + settled = '› \n\n gpt-5.6-sol high · /tmp/x' + internal.publishNativeComposer() + expect(readiness.at(-1)).toEqual({ ready: true, reason: 'ready' }) + }) + // #1319 review A2: the text-only proof of empty reads the plain screen, // which can lag PTY bytes still being parsed (the cell reading is then // `unknown`). A single stale bare-marker read must not consent: a human who From b6bbe9b38c3dd087c4a4f2c887540b3290cf34d9 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 17:35:14 -0700 Subject: [PATCH 10/11] docs(plan): keep the Codex native-draft plan true after both review rounds unknown is no longer 'today's behaviour': it never consents and publishes provider-not-ready. Records the settled screen, the pane's own Enter/Ctrl+C rules, the full test list, and the residuals (#1327). Co-Authored-By: Claude Opus 5.5 --- .../2026-09-25-codex-composer-occupancy.md | 69 +++++++++++++++---- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/docs/plans/2026-09-25-codex-composer-occupancy.md b/docs/plans/2026-09-25-codex-composer-occupancy.md index 6823b7f0d..c6cee6dc2 100644 --- a/docs/plans/2026-09-25-codex-composer-occupancy.md +++ b/docs/plans/2026-09-25-codex-composer-occupancy.md @@ -1,21 +1,66 @@ # Codex: see a native draft before writing into it (#800, #1313) ## Evidence -codex-headless `testing/fixtures/composer-0157/idle-draft-ctrlc.json` is a raw PTY recording of codex-cli 0.157.0. +codex-headless `testing/fixtures/composer-0157/idle-draft-ctrlc.json` is a raw PTY recording of codex-cli 0.157.0 (100×30; 112 output chunks, 65 synchronized-output opens and 65 closes). - **Empty composer:** `› Ask Codex to do anything`, with the placeholder painted dim, then a blank row, the status row and a hint row (`← for agents · ? for shortcuts`). -- **Typed draft:** `› please review the draft`, in plain cells, with the hint gone. +- **Typed draft:** `› please review the draft`, in plain cells, with the hint gone. Codex paints it over 27 chunks within ~24 ms of the keystrokes. One redraw opens a synchronized update in one chunk and closes it in the next. -Today: +Before this PR: - **#1313:** `isCodexNativeComposerEmpty` (text-only) cannot call the empty 0.157 composer empty: the placeholder is text, and the hint row sits below the status row. So every browser-pocket restart (`requireEmptyNativeComposer`) is refused. - **#800:** nothing reads a native draft. `awaitReadyForPrompt` is ready whenever `›` and ` · ` are on screen, so a normal delivery pastes after the human's draft and submits both. Readiness latches after startup, so `nativeDraft` stays `unknown`. ## Change -This bumps codex-headless to `1ac3c50`, which includes #53 (proxy chunks) and #54 (`CodexHeadless.getComposerState()`: `empty`, `drafted` or `unknown`, from the live buffer's cell attributes and Codex's own empty hint). -- **Delivery:** `awaitReadyForPrompt` answers `occupied` (`human-draft`) when the composer reads `drafted`. The delivery then refuses with `retry-after-resolve` and writes nothing. `unknown` keeps today's behaviour, so a frame we cannot read never blocks a prompt. -- **Empty-composer requirement:** consent when the package says `empty`, or when the old text check proves a bare `›`. Anything else still refuses. -- **Readiness:** once the composer latch is set, each screen frame that reads `drafted` publishes `composer-occupied`, and one that reads `empty` publishes `ready`. `unknown` publishes nothing, so a turn in progress does not flap. This makes `nativeDraft` report `occupied` and the pane show "draft in agent composer — clear it to send", as it does for Claude. - -## Tests (red on main, driven by the recorded 0.157 bytes through a real CodexHeadless) -- A restart request into the recorded idle composer is delivered (#1313). -- A normal delivery into the recorded draft is refused as occupied, with no write (#800). -- The session publishes `composer-occupied` on the draft frame, and `ready` again after Ctrl+C. +**Package.** `packages/codex-headless` moves from `ae0e935` to the merge of codex-headless#55. That includes: +- #53: proxy chunks. +- #54: `CodexHeadless.getComposerState()` returns `empty`, `drafted` or `unknown`, from the live buffer's cell attributes and Codex's own empty hint, never from the cwd. +- #55: `CodexHeadless.getSettledScreen()` returns the plain viewport only for a settled frame, and `null` otherwise. Settled means no chunk is queued in the parser, no synchronized update is open, and Codex has painted since the last resize. The cell reading uses the same rule. + +**The rule everywhere.** `drafted` means occupied. A proof of empty means ready. Anything else is neither (steering q40): it never consents, and it never latches occupied. + +A proof of empty is either the package's `empty`, or the bare-`›` text proof read from the **settled** screen. The text proof is the only one 0.149.1 and narrow 0.157 panes can give, because they have no hint row. `getScreen()` is never used as a proof: it can show the paint from before the human's keystrokes. + +- **Write gate (`awaitReadyForPrompt`):** + - `drafted` returns `occupied` (`human-draft`). Delivery refuses with `retry-after-resolve` and writes nothing. + - Text that nothing proves empty is refused at once, as a human's to resolve. + - A text-only proof must hold on two consecutive settled polls 50 ms apart (#1319 review A). That covers the keystroke Codex has read but not yet painted, which no terminal can see. + - A null settled screen resets the count. +- **Restart (`requireEmptyNativeComposer`):** the final check needs the same proof of empty, and it refuses on its own even after readiness said ready. +- **Readiness publication (`publishNativeComposer`, on each screen event after the startup latch):** + + | Frame | Published | + |---|---| + | `drafted` | `composer-occupied` | + | proof of empty | `ready` | + | anything else | `provider-not-ready`, without latching occupied | + + `nativeDraft` then reports `occupied`, and the pane shows "draft in agent composer — clear it to send", as it does for Claude. +- **Agent Code's own composer (`useComposerKeybinds`), while `composer-occupied`, for every provider:** + - Enter and Send refuse, keep the draft, and toast how to clear the agent's draft (review C). + - Ctrl+C reaches the agent, because that is how 0.157 clears a draft, and it keeps the user's Agent Code draft (review round 2 B). +- **Tool text:** the orchestration hold message and `nativeDraft` now mention a native draft. + +## Tests +All red on main where they cover new behaviour. Integration tests drive the recorded 0.157 bytes through a real `CodexHeadless`. + +`codexSession.nativeComposer.test.ts`: +- A restart into the recorded idle composer is delivered (#1313). +- A normal delivery into the recorded draft is refused with no write (#800). +- The draft publishes `composer-occupied`, and `ready` returns after Ctrl+C. +- A 13-row draft the reading cannot classify is never written into, and publishes `provider-not-ready` without latching. +- One stale bare-marker read does not consent. +- A bare marker with no settled frame never consents, for either the gate or the restart. +- A hintless pane returns to `ready` from a settled bare marker, and not while unsettled. +- The restart's final check refuses a draft after readiness said ready. + +`codexSession.launchOrdering.test.ts`: publication through the real screen listener. + +`useComposerKeybinds.tab.renderer.test.tsx`: +- Occupied Enter refuses for a text draft, for an image-only draft, and for a non-Codex provider. +- Ctrl+C passes through and keeps our draft. + +The package tests are listed in codex-headless#54 and #55. + +## Residuals +- **#1327:** a native draft the pane cannot classify publishes `provider-not-ready`, and Enter from Agent Code's composer still writes into it. Refusing every `provider-not-ready` would also block queueing a prompt during a turn. The fix is a separate "unreadable draft" readiness reason. Orchestration delivery already refuses this case. +- Readiness can blink `ready` → `provider-not-ready` while Codex streams on a hintless pane, because a flush can land mid-parse. This is display-only: keystrokes are not gated, and identical states are deduped. +- Under the known synchronized-output callback stall, the settled read stays null. Delivery then times out and refuses. That is the fail-closed direction. From c58234aa0331f0cd7c3d16043b9f08697ed8eebb Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 26 Sep 2026 17:36:04 -0700 Subject: [PATCH 11/11] chore(codex): bump codex-headless to the codex-headless#55 merge (6334d5ac) Co-Authored-By: Claude Opus 5.5 --- packages/codex-headless | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/codex-headless b/packages/codex-headless index b575fddf9..6334d5ac0 160000 --- a/packages/codex-headless +++ b/packages/codex-headless @@ -1 +1 @@ -Subproject commit b575fddf943c51677efb135a3e3c7e1144675d8c +Subproject commit 6334d5ac06435720e9b4f2a85df4102cecdb54fc