From 01e14a91f366bd2bde546998041bb9b690257c10 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 07:38:39 -0700 Subject: [PATCH 1/6] docs(plan): Claude rollback reads the attribute-aware composer (#1291) Co-Authored-By: Claude Opus 5.5 --- ...6-09-25-claude-rollback-attribute-composer.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/plans/2026-09-25-claude-rollback-attribute-composer.md diff --git a/docs/plans/2026-09-25-claude-rollback-attribute-composer.md b/docs/plans/2026-09-25-claude-rollback-attribute-composer.md new file mode 100644 index 000000000..816c4469c --- /dev/null +++ b/docs/plans/2026-09-25-claude-rollback-attribute-composer.md @@ -0,0 +1,16 @@ +# Claude delivery rollback reads the attribute-aware composer state (#1291) + +## Evidence +- Paste-debug journals: 4 `rollback-exhausted {presses: 64, restored: true}` against 1 `rollback-cleared`. Each exhausted case takes 6.7–8.6 s: 5 s absorption timeout, then about 0.4 s, then 64 × 25 ms of kills. The delivery then reports "still in Claude's composer — clear it there", and the gate reads `occupied`. +- Prompts: "yes fix all 9" (typical of a Claude prompt suggestion), "draft the WhatsApp message for Falco", and two long human messages (144 and 380 chars). +- `rollbackWrittenPrompt` classifies with `parseClaudeComposerState(screen, null)`, i.e. text-only. That path allowlists only two placeholder hints, and every other placeholder row reads as `drafted`. After a kill empties the composer, Claude repaints placeholder text (a suggestion, a hint), so the text-only read never becomes `empty`. The rollback presses 64 times and then yanks the prompt back: stranded. +- The prompt gate already avoids this. `claudeSession.derivePromptGateState` reads `headless.getComposerState()`, which classifies with cell attributes, so dim placeholder text is `empty`. The rollback bypasses it. +- No PTY frame of these moments survives. The proxy run was pruned, and feed-debug has no screen text. The mechanism is shown by the parser's two paths plus the outcome journals. + +## Change +- `ClaudeSession.getComposerState()` exposes the headless's attribute-aware state, and `AgentSession` gets it as an optional capability. +- The rollback prefers it and keeps the text-only fail-closed read for sessions without it. +- The observe/kill/yank structure is unchanged. + +## Test +A delivery whose composer shows prompt-suggestion text before and after the kill. The attribute-aware state goes `drafted` to `empty` after the first kill. The rollback must report `cleared` after 1 press, not exhaust 64. Red on main. From a797be11f323ee95059bf3096fa813ad224be98a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 07:40:12 -0700 Subject: [PATCH 2/6] fix(claude): the delivery rollback reads the attribute-aware composer (#1291) After absorption timed out, the rollback classified the composer text-only, and Claude repaints placeholder text (a prompt suggestion, a hint) into an emptied composer, which text-only reads as a draft: 4 of 5 recorded rollbacks exhausted 64 kills and yanked the prompt back, stranding it. It now prefers the session's attribute-aware reading (the one the prompt gate uses), keeping the fail-closed text read as fallback. Co-Authored-By: Claude Opus 5.5 --- src/providers/claude/runtime/claudeSession.ts | 4 ++ ...promptDelivery.rollbackPlaceholder.test.ts | 51 +++++++++++++++++++ .../claude/runtime/promptDelivery.ts | 19 ++++--- src/shared/types/session.ts | 6 +++ 4 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts diff --git a/src/providers/claude/runtime/claudeSession.ts b/src/providers/claude/runtime/claudeSession.ts index ec30cd5ea..daa0f7a9b 100644 --- a/src/providers/claude/runtime/claudeSession.ts +++ b/src/providers/claude/runtime/claudeSession.ts @@ -733,6 +733,10 @@ export class ClaudeSession extends EventEmitter { return this.headless?.getScreen() ?? '' } + getComposerState(): 'empty' | 'drafted' | 'unpainted' | null { + return this.headless?.getComposerState() ?? null + } + snapshotScreenAsMarkdown(): string { return this.headless?.getScreenMarkdown() ?? '' } diff --git a/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts b/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts new file mode 100644 index 000000000..d84cc6eb4 --- /dev/null +++ b/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts @@ -0,0 +1,51 @@ +import { afterEach, expect, it, vi } from 'vitest' + +import { deliverClaudePrompt } from './promptDelivery.js' +import type { PromptDeliveryIo } from '@shared/types/providerConfig.js' + +// #1291: the paste-debug journals hold 4 `rollback-exhausted {presses: 64}` +// against 1 `rollback-cleared`. After absorption timed out, the rollback read +// the composer TEXT-ONLY, and Claude repaints placeholder text (a prompt +// suggestion such as the recorded "yes fix all 9", or a hint) into an emptied +// composer; text-only reads that as a draft, so every kill looked like it +// failed, 64 presses later the prompt was yanked back, and the user was told +// to clear it by hand. The session's attribute-aware state (the one the prompt +// gate uses) classifies dim placeholder text as empty. +afterEach(() => { vi.useRealTimers() }) + +// Claude's composer box as rendered: divider, prompt row, divider. The row +// holds the same text before our write (a dim suggestion) and after the kill +// (the suggestion repainted), so a text-only read can never see it empty. +const SUGGESTION_SCREEN = ['─'.repeat(40), '❯ yes fix all 9', '─'.repeat(40)].join('\n') + +it('clears a stranded prompt when the emptied composer repaints a suggestion', async () => { + vi.useFakeTimers() + const writes: string[] = [] + const records: string[] = [] + let killed = false + const io = { + sessionId: 's1', + prompt: 'yes fix all 9', + write: (data: string) => { + writes.push(data) + if (data === '\x15') killed = true + return true + }, + record: (event: string) => { records.push(event) }, + session: { + snapshotScreen: () => SUGGESTION_SCREEN, + // Our typed bytes are plain text (drafted) until the first kill; after it + // only the dim suggestion remains (empty), as cell attributes show. + getComposerState: () => (killed ? 'empty' as const : 'drafted' as const), + armPromptAcceptance: () => ({ promise: new Promise(() => {}), cancel: vi.fn() }), + }, + } as unknown as PromptDeliveryIo + + const delivery = deliverClaudePrompt(io) + await vi.advanceTimersByTimeAsync(30_000) + const result = await delivery + expect(records.join(',')).toContain('rollback-cleared') + expect(records).not.toContain('rollback-exhausted') + expect(writes.filter(data => data === '\x15')).toHaveLength(1) + expect(result).toMatchObject({ ok: false, promptWritten: false, retrySafe: true }) +}) diff --git a/src/providers/claude/runtime/promptDelivery.ts b/src/providers/claude/runtime/promptDelivery.ts index c6da53fd8..d90b95272 100644 --- a/src/providers/claude/runtime/promptDelivery.ts +++ b/src/providers/claude/runtime/promptDelivery.ts @@ -453,12 +453,19 @@ async function rollbackWrittenPrompt( io: PromptDeliveryIo, ): Promise<'cleared' | 'restored' | 'unrecoverable'> { const readComposer = (): 'empty' | 'drafted' | 'unpainted' => - // Classified WITHOUT cell attributes, which is the fail-closed path: with - // no attributes an unrecognised row is reported as 'drafted'. That error - // direction is the safe one here — a false 'drafted' aborts and restores, - // whereas a false 'empty' would let us report success over a composer still - // holding half a prompt. - parseClaudeComposerState(io.session.snapshotScreen?.() ?? '', null) + // #1291: the session's ATTRIBUTE-AWARE reading first — the same one the + // prompt gate trusts. After a kill empties the composer, Claude repaints + // placeholder text into it (a prompt suggestion, a hint), and only cell + // attributes tell that dim text from typing. The text-only read called it + // a draft, so all 64 kills "failed" and the prompt was yanked back and + // stranded: 4 of 5 recorded rollbacks ended that way. + // + // The text-only read stays as the fallback for sessions without the + // capability. It fails closed (an unrecognised row is 'drafted'), and that + // error direction is the safe one: a false 'drafted' aborts and restores, + // whereas a false 'empty' would report success over half a prompt. + io.session.getComposerState?.() + ?? parseClaudeComposerState(io.session.snapshotScreen?.() ?? '', null) // STEP 1 — wait until our bytes are actually VISIBLE before touching anything. // diff --git a/src/shared/types/session.ts b/src/shared/types/session.ts index 2264ac2af..044b4ac76 100644 --- a/src/shared/types/session.ts +++ b/src/shared/types/session.ts @@ -592,6 +592,12 @@ export interface AgentSession extends AgentSessionEmitter { * headless instance isn't up yet. */ snapshotScreen?(): string + /** Optional (Claude today): the composer's state as classified WITH cell + * attributes, so dim placeholder text (prompt suggestions, hints) reads + * `empty`. The prompt gate uses the same reading; the delivery rollback + * needs it too (#1291). Null when the headless instance isn't up. */ + getComposerState?(): 'empty' | 'drafted' | 'unpainted' | null + /** * Optional (Claude today): arm an authoritative prompt-acceptance waiter * BEFORE Enter is written. Claude acknowledges a finished prompt through From a8d477754f6653d6ebb987733f755033d9f80ecf Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 08:15:00 -0700 Subject: [PATCH 3/6] =?UTF-8?q?fix(claude):=20#1309=20review=20round=201?= =?UTF-8?q?=20=E2=80=94=20rollback=20reads=20the=20live=20buffer=20and=20n?= =?UTF-8?q?eeds=20a=20painted=20placeholder=20to=20call=20it=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A1: the cached composer state moves only on a throttled (and stallable) screen event, slower than the 25 ms kill loop, so it could keep saying 'drafted' over an emptied composer. The rollback now reads text and cell attributes from the live buffer at the same instant (claude-code-headless getComposerAttributes, bumped to #63's head). - A2: attributes alone called a leftover character under the inverse cursor 'empty'. The text read stays the base; it is overruled to 'empty' only when dim placeholder cells are painted and nothing is typed. - Tests: dim suggestion (cleared), inverse residual (not cleared), bare marker without attributes (cleared). Co-Authored-By: Claude Opus 5.5 --- packages/claude-code-headless | 2 +- src/providers/claude/runtime/claudeSession.ts | 6 ++- ...promptDelivery.rollbackPlaceholder.test.ts | 53 +++++++++++++------ .../claude/runtime/promptDelivery.ts | 39 +++++++++----- src/shared/types/session.ts | 11 ++-- 5 files changed, 75 insertions(+), 36 deletions(-) diff --git a/packages/claude-code-headless b/packages/claude-code-headless index 93f5a5450..9702cbcde 160000 --- a/packages/claude-code-headless +++ b/packages/claude-code-headless @@ -1 +1 @@ -Subproject commit 93f5a545061cdca67f66cfab68d276d72bcaba45 +Subproject commit 9702cbcde53d8ce75d9d373fe8da10a147251381 diff --git a/src/providers/claude/runtime/claudeSession.ts b/src/providers/claude/runtime/claudeSession.ts index daa0f7a9b..361e97aa8 100644 --- a/src/providers/claude/runtime/claudeSession.ts +++ b/src/providers/claude/runtime/claudeSession.ts @@ -733,8 +733,10 @@ export class ClaudeSession extends EventEmitter { return this.headless?.getScreen() ?? '' } - getComposerState(): 'empty' | 'drafted' | 'unpainted' | null { - return this.headless?.getComposerState() ?? null + readComposer(): { screen: string; attributes: { dim: number; inverse: number; plain: number } | null } | null { + if (!this.headless) return null + // Same instant: text and attributes both from the live buffer. + return { screen: this.headless.getScreen(), attributes: this.headless.getComposerAttributes() } } snapshotScreenAsMarkdown(): string { diff --git a/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts b/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts index d84cc6eb4..74e4a331f 100644 --- a/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts +++ b/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts @@ -8,17 +8,20 @@ import type { PromptDeliveryIo } from '@shared/types/providerConfig.js' // the composer TEXT-ONLY, and Claude repaints placeholder text (a prompt // suggestion such as the recorded "yes fix all 9", or a hint) into an emptied // composer; text-only reads that as a draft, so every kill looked like it -// failed, 64 presses later the prompt was yanked back, and the user was told -// to clear it by hand. The session's attribute-aware state (the one the prompt -// gate uses) classifies dim placeholder text as empty. +// failed and 64 presses later the prompt was yanked back and stranded. +// #1309 review: the fix must read the LIVE buffer (not the lagging per-frame +// cache) and must not call a leftover inverse-cursor character "empty". afterEach(() => { vi.useRealTimers() }) -// Claude's composer box as rendered: divider, prompt row, divider. The row -// holds the same text before our write (a dim suggestion) and after the kill -// (the suggestion repainted), so a text-only read can never see it empty. -const SUGGESTION_SCREEN = ['─'.repeat(40), '❯ yes fix all 9', '─'.repeat(40)].join('\n') +const RULE = '─'.repeat(40) +const composer = (row: string) => [RULE, row, RULE].join('\n') +type Attributes = { dim: number; inverse: number; plain: number } +type Frame = { screen: string; attributes: Attributes | null } -it('clears a stranded prompt when the emptied composer repaints a suggestion', async () => { +// Our bytes, as typed (plain cells). +const TYPED: Frame = { screen: composer('❯ yes fix all 9'), attributes: { dim: 0, inverse: 1, plain: 12 } } + +async function rollback(afterKill: Frame, before: Frame = TYPED) { vi.useFakeTimers() const writes: string[] = [] const records: string[] = [] @@ -33,19 +36,39 @@ it('clears a stranded prompt when the emptied composer repaints a suggestion', a }, record: (event: string) => { records.push(event) }, session: { - snapshotScreen: () => SUGGESTION_SCREEN, - // Our typed bytes are plain text (drafted) until the first kill; after it - // only the dim suggestion remains (empty), as cell attributes show. - getComposerState: () => (killed ? 'empty' as const : 'drafted' as const), + // The same frame is visible before our write too, so absorption never + // sees a transition and the delivery reaches the rollback (#1230). + snapshotScreen: () => (killed ? afterKill : before).screen, + readComposer: () => (killed ? afterKill : before), armPromptAcceptance: () => ({ promise: new Promise(() => {}), cancel: vi.fn() }), }, } as unknown as PromptDeliveryIo - const delivery = deliverClaudePrompt(io) await vi.advanceTimersByTimeAsync(30_000) const result = await delivery + return { result, records, kills: writes.filter(data => data === '\x15').length } +} + +it('clears a stranded prompt when the emptied composer repaints a dim suggestion', async () => { + // After the kill only the suggestion remains: dim text, the cursor on its + // first cell, nothing typed. + const { result, records, kills } = await rollback({ screen: composer('❯ yes fix all 9'), attributes: { dim: 12, inverse: 1, plain: 0 } }) expect(records.join(',')).toContain('rollback-cleared') - expect(records).not.toContain('rollback-exhausted') - expect(writes.filter(data => data === '\x15')).toHaveLength(1) + expect(kills).toBe(1) expect(result).toMatchObject({ ok: false, promptWritten: false, retrySafe: true }) }) + +it('does not call a leftover character under the cursor empty', async () => { + // A partial kill: one real character remains, drawn inverse (the cursor on + // it), no dim placeholder. Attributes alone would say "nothing typed". + const { records } = await rollback({ screen: composer('❯ y'), attributes: { dim: 0, inverse: 1, plain: 0 } }) + expect(records.join(',')).not.toContain('rollback-cleared') +}) + +it('trusts a bare prompt marker even when attributes are unavailable', async () => { + // The emptied composer shows only the marker; this is the text read's own + // reliable "empty", independent of any attribute or cache timing. + const { records, kills } = await rollback({ screen: composer('❯'), attributes: null }) + expect(records.join(',')).toContain('rollback-cleared') + expect(kills).toBe(1) +}) diff --git a/src/providers/claude/runtime/promptDelivery.ts b/src/providers/claude/runtime/promptDelivery.ts index d90b95272..372ac5892 100644 --- a/src/providers/claude/runtime/promptDelivery.ts +++ b/src/providers/claude/runtime/promptDelivery.ts @@ -452,20 +452,33 @@ const sleep = (ms: number): Promise => async function rollbackWrittenPrompt( io: PromptDeliveryIo, ): Promise<'cleared' | 'restored' | 'unrecoverable'> { - const readComposer = (): 'empty' | 'drafted' | 'unpainted' => - // #1291: the session's ATTRIBUTE-AWARE reading first — the same one the - // prompt gate trusts. After a kill empties the composer, Claude repaints - // placeholder text into it (a prompt suggestion, a hint), and only cell - // attributes tell that dim text from typing. The text-only read called it - // a draft, so all 64 kills "failed" and the prompt was yanked back and - // stranded: 4 of 5 recorded rollbacks ended that way. + const readComposer = (): 'empty' | 'drafted' | 'unpainted' => { + // #1291: after a kill empties the composer, Claude repaints placeholder + // text into it (a prompt suggestion, a hint). The text-only read calls any + // unrecognised row 'drafted', so all 64 kills "failed" and the prompt was + // yanked back and stranded: 4 of 5 recorded rollbacks ended that way. // - // The text-only read stays as the fallback for sessions without the - // capability. It fails closed (an unrecognised row is 'drafted'), and that - // error direction is the safe one: a false 'drafted' aborts and restores, - // whereas a false 'empty' would report success over half a prompt. - io.session.getComposerState?.() - ?? parseClaudeComposerState(io.session.snapshotScreen?.() ?? '', null) + // The rule (#1309 review): + // - the text-only read stays the base. It fails closed, and its 'empty' + // (a bare prompt marker) is trustworthy; + // - it is overruled to 'empty' only when the LIVE cell attributes show a + // painted placeholder: dim cells and no typed (plain) cells. A leftover + // character under the inverse cursor has no dim cells, so it stays + // 'drafted'; + // - text and attributes come from the live buffer at the same instant, + // never the per-frame cache, which lags a 25 ms kill loop and can + // stall behind pendingWrites. + // A false 'drafted' aborts and restores; a false 'empty' would report + // success over half a prompt, so every doubt resolves to 'drafted'. + const live = io.session.readComposer?.() + const screen = live?.screen ?? io.session.snapshotScreen?.() ?? '' + const textOnly = parseClaudeComposerState(screen, null) + if (textOnly !== 'drafted') return textOnly + const attributes = live?.attributes + if (attributes && attributes.plain === 0 && attributes.dim > 0 + && parseClaudeComposerState(screen, attributes) === 'empty') return 'empty' + return 'drafted' + } // STEP 1 — wait until our bytes are actually VISIBLE before touching anything. // diff --git a/src/shared/types/session.ts b/src/shared/types/session.ts index be14d25dc..e46b17260 100644 --- a/src/shared/types/session.ts +++ b/src/shared/types/session.ts @@ -605,11 +605,12 @@ export interface AgentSession extends AgentSessionEmitter { * headless instance isn't up yet. */ snapshotScreen?(): string - /** Optional (Claude today): the composer's state as classified WITH cell - * attributes, so dim placeholder text (prompt suggestions, hints) reads - * `empty`. The prompt gate uses the same reading; the delivery rollback - * needs it too (#1291). Null when the headless instance isn't up. */ - getComposerState?(): 'empty' | 'drafted' | 'unpainted' | null + /** Optional (Claude today): the screen and the composer's cell-attribute + * counts read from the LIVE buffer at the same instant (#1291/#1309). Not + * the per-frame cached classification the prompt gate uses: the delivery + * rollback verifies its own keystroke 25 ms later, faster than that cache + * moves. Null when the headless instance isn't up. */ + readComposer?(): { screen: string; attributes: { dim: number; inverse: number; plain: number } | null } | null /** * Optional (Claude today): arm an authoritative prompt-acceptance waiter From 4ab1d86786f533e9edabbcdde78296d2d1258575 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 08:16:06 -0700 Subject: [PATCH 4/6] =?UTF-8?q?test(claude):=20#1309=20review=20B=20?= =?UTF-8?q?=E2=80=94=20the=20rollback=20classifier=20driven=20by=20a=20rea?= =?UTF-8?q?l=20headless=20frame;=20evidence=20stated=20precisely?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The classifier is exported and tested with a real ClaudeCodeHeadless: painted frames go through the live screen and attribute pipeline (dim suggestion empty, typed text drafted, bare marker empty). - The comment now cites the whole corpus (4 exhausted, 1 cleared, 7 unobserved) and says the placeholder repaint fits the exhausted records rather than being observed in them (no screens were recorded). Co-Authored-By: Claude Opus 5.5 --- ...promptDelivery.rollbackPlaceholder.test.ts | 22 ++++++ .../claude/runtime/promptDelivery.ts | 68 +++++++++++-------- 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts b/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts index 74e4a331f..93aa48075 100644 --- a/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts +++ b/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts @@ -72,3 +72,25 @@ it('trusts a bare prompt marker even when attributes are unavailable', async () expect(records.join(',')).toContain('rollback-cleared') expect(kills).toBe(1) }) + +// #1309 review B: the classifier fed by a REAL ClaudeCodeHeadless, so the +// live screen + attribute pipeline (not a hand-written object) reaches it. +it('classifies real painted frames: a dim suggestion is empty, typed text is not', async () => { + vi.useRealTimers() + const { ClaudeCodeHeadless } = await import('claude-code-headless') + const { classifyRollbackComposer } = await import('./promptDelivery.js') + const pty = { + pid: 1, process: 'claude', cols: 120, rows: 40, handleFlowControl: false, + write: vi.fn(), resize: vi.fn(), clear: vi.fn(), pause: vi.fn(), resume: vi.fn(), kill: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), onExit: vi.fn(() => ({ dispose: vi.fn() })), + } + const headless = new ClaudeCodeHeadless({ pty: pty as never, cwd: '/tmp' }) + const terminal = (headless as unknown as { terminal: { writeForTest(data: string): Promise } }).terminal + const paint = async (row: string) => { + await terminal.writeForTest('\x1b[2J\x1b[H' + ['─'.repeat(60), row, '─'.repeat(60)].join('\r\n')) + return classifyRollbackComposer({ screen: headless.getScreen(), attributes: headless.getComposerAttributes() }, '') + } + expect(await paint('❯ \x1b[2myes fix all 9\x1b[22m')).toBe('empty') + expect(await paint('❯ yes fix all 9')).toBe('drafted') + expect(await paint('❯')).toBe('empty') +}) diff --git a/src/providers/claude/runtime/promptDelivery.ts b/src/providers/claude/runtime/promptDelivery.ts index 372ac5892..578e8bc27 100644 --- a/src/providers/claude/runtime/promptDelivery.ts +++ b/src/providers/claude/runtime/promptDelivery.ts @@ -452,33 +452,8 @@ const sleep = (ms: number): Promise => async function rollbackWrittenPrompt( io: PromptDeliveryIo, ): Promise<'cleared' | 'restored' | 'unrecoverable'> { - const readComposer = (): 'empty' | 'drafted' | 'unpainted' => { - // #1291: after a kill empties the composer, Claude repaints placeholder - // text into it (a prompt suggestion, a hint). The text-only read calls any - // unrecognised row 'drafted', so all 64 kills "failed" and the prompt was - // yanked back and stranded: 4 of 5 recorded rollbacks ended that way. - // - // The rule (#1309 review): - // - the text-only read stays the base. It fails closed, and its 'empty' - // (a bare prompt marker) is trustworthy; - // - it is overruled to 'empty' only when the LIVE cell attributes show a - // painted placeholder: dim cells and no typed (plain) cells. A leftover - // character under the inverse cursor has no dim cells, so it stays - // 'drafted'; - // - text and attributes come from the live buffer at the same instant, - // never the per-frame cache, which lags a 25 ms kill loop and can - // stall behind pendingWrites. - // A false 'drafted' aborts and restores; a false 'empty' would report - // success over half a prompt, so every doubt resolves to 'drafted'. - const live = io.session.readComposer?.() - const screen = live?.screen ?? io.session.snapshotScreen?.() ?? '' - const textOnly = parseClaudeComposerState(screen, null) - if (textOnly !== 'drafted') return textOnly - const attributes = live?.attributes - if (attributes && attributes.plain === 0 && attributes.dim > 0 - && parseClaudeComposerState(screen, attributes) === 'empty') return 'empty' - return 'drafted' - } + const readComposer = (): 'empty' | 'drafted' | 'unpainted' => + classifyRollbackComposer(io.session.readComposer?.() ?? null, io.session.snapshotScreen?.() ?? '') // STEP 1 — wait until our bytes are actually VISIBLE before touching anything. // @@ -554,3 +529,42 @@ function describeReadiness( if (outcome.kind === 'occupied') return 'occupied by a human draft' return `unavailable (${outcome.reason})` } + +/** + * The rollback's reading of Claude's composer (#1291, #1309 review). + * + * After a kill empties the composer, Claude repaints placeholder text into it + * (a prompt suggestion, a hint), and the text-only read calls any unrecognised + * row 'drafted'. The paste-debug corpus holds 4 `rollback-exhausted` (all 64 + * presses, then yanked back), 1 `rollback-cleared` and 7 `rollback-unobserved`. + * The exhausted records carry no screen, so a repainted placeholder is the + * explanation that fits them (one prompt was a typical suggestion, "yes fix + * all 9"), not a recorded observation. + * + * Exported so a test can drive it with a real ClaudeCodeHeadless frame. + */ +export function classifyRollbackComposer( + live: { screen: string; attributes: { dim: number; inverse: number; plain: number } | null } | null, + fallbackScreen: string, +): 'empty' | 'drafted' | 'unpainted' { + // The rule (#1309 review): + // - the text-only read stays the base. It fails closed, and its 'empty' + // (a bare prompt marker) is trustworthy; + // - it is overruled to 'empty' only when the LIVE cell attributes show a + // painted placeholder: dim cells and no typed (plain) cells. A leftover + // character under the inverse cursor has no dim cells, so it stays + // 'drafted'; + // - text and attributes come from the live buffer at the same instant, + // never the per-frame cache, which lags a 25 ms kill loop and can + // stall behind pendingWrites. + // A false 'drafted' aborts and restores; a false 'empty' would report + // success over half a prompt, so every doubt resolves to 'drafted'. + const screen = live?.screen ?? fallbackScreen + const textOnly = parseClaudeComposerState(screen, null) + if (textOnly !== 'drafted') return textOnly + const attributes = live?.attributes + if (attributes && attributes.plain === 0 && attributes.dim > 0 + && parseClaudeComposerState(screen, attributes) === 'empty') return 'empty' + return 'drafted' +} + From ab9ab02e61780f47c16764a681cb454b932389c9 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 08:41:11 -0700 Subject: [PATCH 5/6] =?UTF-8?q?fix(claude):=20#1309=20review=20round=202?= =?UTF-8?q?=20=E2=80=94=20a=20typed=20row=20matching=20a=20known=20hint=20?= =?UTF-8?q?is=20a=20draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The text-only read allowlists "Press up to edit" as empty. A kill clears only the current visual row, so a prompt whose first row reads like that hint could be left behind, plain, and reported cleared with retrySafe. Live plain cells now veto that empty. Tests pin the real painted frame, the rollback outcome, the continuation-row guard and ClaudeSession.readComposer. Co-Authored-By: Claude Opus 5.5 --- .../runtime/claudeSession.suspension.test.ts | 16 ++++++++++++++ ...promptDelivery.rollbackPlaceholder.test.ts | 21 +++++++++++++++++++ .../claude/runtime/promptDelivery.ts | 10 +++++++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/providers/claude/runtime/claudeSession.suspension.test.ts b/src/providers/claude/runtime/claudeSession.suspension.test.ts index a1e10906b..70b692614 100644 --- a/src/providers/claude/runtime/claudeSession.suspension.test.ts +++ b/src/providers/claude/runtime/claudeSession.suspension.test.ts @@ -35,3 +35,19 @@ describe('ClaudeSession.noteSystemSuspension', () => { })).not.toThrow() }) }) + +// #1309 round 2 B: the production adapter the rollback reads. Both halves +// must come from the live headless at the same call, and a session without a +// headless reports nothing rather than an empty (= "cleared") screen. +describe('ClaudeSession.readComposer', () => { + it('reads screen and attributes from the live headless together', () => { + const session = new ClaudeSession() + expect(session.readComposer()).toBeNull() + const attributes = { dim: 0, inverse: 1, plain: 4 } + ;(session as unknown as { headless: unknown }).headless = { + getScreen: () => '❯ typed', + getComposerAttributes: () => attributes, + } + expect(session.readComposer()).toEqual({ screen: '❯ typed', attributes }) + }) +}) diff --git a/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts b/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts index 93aa48075..40a060062 100644 --- a/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts +++ b/src/providers/claude/runtime/promptDelivery.rollbackPlaceholder.test.ts @@ -93,4 +93,25 @@ it('classifies real painted frames: a dim suggestion is empty, typed text is not expect(await paint('❯ \x1b[2myes fix all 9\x1b[22m')).toBe('empty') expect(await paint('❯ yes fix all 9')).toBe('drafted') expect(await paint('❯')).toBe('empty') + // #1309 round 2 (A and B): a known hint is empty only when it is PAINTED + // as one. The same words typed (a first visual row left behind by a kill + // of the row below) are a draft. + expect(await paint('❯ \x1b[2mPress up to edit\x1b[22m')).toBe('empty') + expect(await paint('❯ Press up to edit')).toBe('drafted') +}) + +// #1309 round 2: the rollback itself must not report that typed row cleared; +// a retry would append to it and submit the fragment. +it('does not call typed text that matches a known hint empty', async () => { + const { records, result } = await rollback({ screen: composer('❯ Press up to edit'), attributes: { dim: 0, inverse: 1, plain: 15 } }) + expect(records.join(',')).not.toContain('rollback-cleared') + expect(result).not.toMatchObject({ retrySafe: true }) +}) + +// #1309 round 2 B mutation: the attributes sample only the MARKER row, so a +// dim marker row says nothing about typed text on a continuation row. +it('does not call a dim marker row empty while a continuation row holds text', async () => { + const { classifyRollbackComposer } = await import('./promptDelivery.js') + const screen = [RULE, '❯ yes fix all 9', ' and the rest of a draft', RULE].join('\n') + expect(classifyRollbackComposer({ screen, attributes: { dim: 12, inverse: 1, plain: 0 } }, '')).toBe('drafted') }) diff --git a/src/providers/claude/runtime/promptDelivery.ts b/src/providers/claude/runtime/promptDelivery.ts index 578e8bc27..4b679b5ee 100644 --- a/src/providers/claude/runtime/promptDelivery.ts +++ b/src/providers/claude/runtime/promptDelivery.ts @@ -549,7 +549,12 @@ export function classifyRollbackComposer( ): 'empty' | 'drafted' | 'unpainted' { // The rule (#1309 review): // - the text-only read stays the base. It fails closed, and its 'empty' - // (a bare prompt marker) is trustworthy; + // is trustworthy for a bare prompt marker; + // - but NOT for its allowlisted hints ("Press up to edit") when the live + // cells show typed text (#1309 round 2, reviews A and B). A kill clears + // only the current VISUAL row, so a prompt whose first row reads exactly + // like a hint can leave that row behind, plain. Calling it cleared told + // the caller a retry was safe, and the retry would append to it; // - it is overruled to 'empty' only when the LIVE cell attributes show a // painted placeholder: dim cells and no typed (plain) cells. A leftover // character under the inverse cursor has no dim cells, so it stays @@ -561,8 +566,9 @@ export function classifyRollbackComposer( // success over half a prompt, so every doubt resolves to 'drafted'. const screen = live?.screen ?? fallbackScreen const textOnly = parseClaudeComposerState(screen, null) - if (textOnly !== 'drafted') return textOnly const attributes = live?.attributes + if (textOnly === 'empty' && attributes && attributes.plain > 0) return 'drafted' + if (textOnly !== 'drafted') return textOnly if (attributes && attributes.plain === 0 && attributes.dim > 0 && parseClaudeComposerState(screen, attributes) === 'empty') return 'empty' return 'drafted' From 907c796c847840a7a351b1d32490afd624761554 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 25 Sep 2026 08:56:46 -0700 Subject: [PATCH 6/6] chore(deps): claude-code-headless to the merged #63 (64fd0eaf) Co-Authored-By: Claude Opus 5.5 --- packages/claude-code-headless | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/claude-code-headless b/packages/claude-code-headless index 9702cbcde..64fd0eaf1 160000 --- a/packages/claude-code-headless +++ b/packages/claude-code-headless @@ -1 +1 @@ -Subproject commit 9702cbcde53d8ce75d9d373fe8da10a147251381 +Subproject commit 64fd0eaf18a0a6cef58b9048e3768843b3e0e65b