From a6abbaa6730b761fde8754ea2486388b8c017c17 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 19 Oct 2026 19:27:41 +0200 Subject: [PATCH] fix(input): a composer's paste-then-Enter send is checked like any other prompt Story Mode's prompt bar and the phone's Compose sheet send a prompt as two writes: one bracketed paste, then a bare Enter about 120 ms later. Neither is a `text\r` pair, so SubmitVerifier was never armed for them, and a send while Claude Code was still ignoring Enter (2.1.277+, the first 30 to 50 s after the composer paints) sat unsent. PastedPromptWatch pairs the two writes back up, in Session.write() (the WebSocket path) and writeViaMux() (the POST fallback's Enter), and arms the verifier with the pasted text. Nothing may come between the two, and a newer paste cancels a pending check, so Enter never sends the user's own paste. The composer match learns what a pasted prompt looks like (measured on 2.1.303): continuation lines count for a short first line, and Claude's `[Pasted text #1 +3 lines]` stand-in counts only when its line count is the prompt's own. A prompt under 24 characters must now match whole, so a short answer cannot read a dialog's highlighted `1. Yes` row as itself. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- src/session-submit-verifier.ts | 103 +++++++++++++-- src/session.ts | 30 ++++- src/web/CLAUDE.md | 2 +- test/session-submit-verifier.test.ts | 182 ++++++++++++++++++++++++++- 5 files changed, 300 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 25878898..57b43dd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ## Common Gotchas -- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write +- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`, and by `PastedPromptWatch` for the browser composers' two-write send, one bracketed paste then a bare Enter, on the WebSocket and the POST fallback alike) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent (a prompt under 24 characters must match whole; Claude's `[Pasted text #1 +3 lines]` stand-in counts only when its line count is the prompt's own); an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write or a newer paste replaces or cancels the schedule. Typed keystrokes plus Enter arm nothing. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write - **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks - **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`) - **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically) diff --git a/src/session-submit-verifier.ts b/src/session-submit-verifier.ts index 063ad6ce..2424fbbd 100644 --- a/src/session-submit-verifier.ts +++ b/src/session-submit-verifier.ts @@ -21,6 +21,13 @@ * prompt with the same glyph higher up in the transcript, so only the last one says * whether the text was taken. * + * The browser's composers (Story Mode's prompt bar, the phone's Compose sheet) never send + * a `text\r` pair: they write one bracketed paste and a bare Enter about 120 ms later, + * because Codex drops a key that shares a read with a paste. `PastedPromptWatch` pairs + * those two writes back up so the same check covers them. A long paste is not on the + * composer as text at all (Claude Code draws `[Pasted text #1 +3 lines]`), so that + * stand-in counts as the prompt only when its line count is the prompt's own. + * * Pure apart from the injected capture, send and log, so the schedule, the cap and * every stop condition are unit-tested with fake timers (test/session-submit-verifier.test.ts). */ @@ -36,33 +43,105 @@ export const SUBMIT_VERIFY_DELAYS_MS: readonly number[] = [ 2_000, 3_000, 5_000, 5_000, 5_000, 10_000, 10_000, 10_000, 10_000, ]; -/** How many leading characters of the prompt have to match, whitespace removed. */ +/** + * How many leading characters of the prompt have to match, whitespace removed. A prompt + * shorter than this has to match whole, so a one-word answer cannot read a selection + * dialog's highlighted row (`❯ 1. Yes`) as itself. + */ const PROMPT_HEAD_CHARS = 24; const compact = (s: string): string => s.replace(/\s+/g, ''); +/** + * Claude Code's stand-in for a long paste, whitespace removed (measured on 2.1.303): + * `[Pasted text #1]` for one line over 800 characters, `[Pasted text #1 +3 lines]` for + * four lines or more, the number counting line BREAKS. `#n` counts pastes, so it is never + * pinned. Codex's `[Pasted Content N chars]` is not matched: not measured here. + */ +const PASTE_PLACEHOLDER = /^\[Pastedtext#\d+(?:\+(\d+)lines?)?\]$/; + +/** The box-drawing rule Claude Code draws under its composer, which ends the block. */ +const RULE_LINE = /^[\u2500-\u257f]/; + +const lineBreaks = (s: string): number => (s.match(/\r\n|\r|\n/g) ?? []).length; + /** * Whether `prompt` is still sitting unsubmitted in the composer of `screen`. * - * - `true`: the last `glyph` line holds the prompt's head. + * - `true`: the last `glyph` line holds the prompt's head, or the paste stand-in for a + * prompt with that many lines. * - `false`: the composer is empty (the prompt was taken) or holds other text. * - `undefined`: no composer line at all; nothing can be said, so nothing is sent. * * Whitespace is removed on both sides before comparing, because the composer wraps a * long prompt onto indented continuation lines and Claude Code draws a no-break space - * after the glyph; `\s` covers that one in JavaScript. + * after the glyph; `\s` covers that one in JavaScript. Those continuation lines (up to a + * blank line or the rule) count too, for a prompt whose first line is short. */ export function promptStillInComposer(screen: string, prompt: string, glyph: string): boolean | undefined { if (!glyph) return undefined; - const composerLines = stripAnsi(screen) - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.startsWith(glyph)); - if (composerLines.length === 0) return undefined; - const composer = compact(composerLines[composerLines.length - 1].slice(glyph.length)); - if (!composer) return false; - const head = compact(prompt).slice(0, PROMPT_HEAD_CHARS); - return head.length > 0 && composer.startsWith(head); + const lines = stripAnsi(screen).split('\n'); + let at = lines.length - 1; + while (at >= 0 && !lines[at].trim().startsWith(glyph)) at--; + if (at < 0) return undefined; + const first = compact(lines[at].trim().slice(glyph.length)); + if (!first) return false; + const placeholder = PASTE_PLACEHOLDER.exec(first); + if (placeholder) return Number(placeholder[1] ?? 0) === lineBreaks(prompt); + const wanted = compact(prompt); + if (!wanted) return false; + const head = wanted.slice(0, PROMPT_HEAD_CHARS); + const holds = (composer: string): boolean => + wanted.length >= PROMPT_HEAD_CHARS ? composer.startsWith(head) : composer === wanted; + if (holds(first)) return true; + let block = first; + for (let i = at + 1; i < lines.length; i++) { + const text = lines[i].trim(); + if (!text || !/^\s/.test(lines[i]) || RULE_LINE.test(text)) break; + block += compact(text); + } + return block !== first && holds(block); +} + +/** How long after a paste a bare Enter still counts as submitting it. */ +export const PASTE_ENTER_WINDOW_MS = 10_000; + +const PASTE_START = '\x1b[200~'; +const PASTE_END = '\x1b[201~'; + +/** The text of a write that is exactly one bracketed paste, else null. */ +export function bracketedPasteText(data: string): string | null { + if (data.length < PASTE_START.length + PASTE_END.length) return null; + if (!data.startsWith(PASTE_START) || !data.endsWith(PASTE_END)) return null; + const text = data.slice(PASTE_START.length, -PASTE_END.length); + return text.includes(PASTE_START) || text.includes(PASTE_END) ? null : text; +} + +/** + * Pairs a composer's two writes: one bracketed paste, then a bare Enter. Fed every + * write to the pane; anything between the two breaks the pair, so the Enter is + * only ever matched with the paste right before it. + */ +export class PastedPromptWatch { + private pending: { text: string; at: number } | null = null; + + constructor(private readonly windowMs: number = PASTE_ENTER_WINDOW_MS) {} + + /** + * `paste` when `data` is a paste (a newer one owns the composer now), `submit` with + * the pasted text when it is the Enter that sends the paste just before it. + */ + feed(data: string, now: number): { kind: 'paste' } | { kind: 'submit'; text: string } | null { + const pasted = bracketedPasteText(data); + if (pasted !== null) { + this.pending = pasted.trim() ? { text: pasted, at: now } : null; + return { kind: 'paste' }; + } + const pending = this.pending; + this.pending = null; + if (data !== '\r' || !pending || now - pending.at > this.windowMs) return null; + return { kind: 'submit', text: pending.text }; + } } export interface SubmitVerifierDeps { diff --git a/src/session.ts b/src/session.ts index a54e1431..0de9a366 100644 --- a/src/session.ts +++ b/src/session.ts @@ -125,7 +125,7 @@ import { import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; -import { SubmitVerifier } from './session-submit-verifier.js'; +import { PastedPromptWatch, SubmitVerifier } from './session-submit-verifier.js'; import { compileVersionRegex } from './config/cli-registry/patterns.js'; import { resolveSessionCliVersion } from './utils/cli-resolver.js'; import { @@ -646,6 +646,7 @@ export class Session extends EventEmitter { private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below) /** Re-sends Enter while a programmatic prompt still sits in the composer (session-submit-verifier.ts). */ private _submitVerifier: SubmitVerifier | null = null; + private readonly _pastedPromptWatch = new PastedPromptWatch(); private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan) private _taskTracker: TaskTracker; @@ -4598,6 +4599,7 @@ export class Session extends EventEmitter { if (!this.ptyProcess) return false; this.ptyProcess.write(data); this._emitSubmittedPrompt(submittedPrompt); + this._notePastedPrompt(data); return true; } @@ -4746,6 +4748,7 @@ export class Session extends EventEmitter { if (sent) { this._emitSubmittedPrompt(submittedPrompt); this._verifySubmitted(data); + this._notePastedPrompt(data); } return sent; } @@ -4775,9 +4778,28 @@ export class Session extends EventEmitter { * can read its pane, only text can be stranded, and the glyph is the CLI's own. */ private _verifySubmitted(data: string): void { - if (!data.includes('\r') || !this._mux?.capturePaneText || !this._muxSession) return; - const text = data.replace(/[\r\n]/g, '').trimEnd(); - if (!text) return; + if (!data.includes('\r')) return; + this._armSubmitVerifier(data.replace(/[\r\n]/g, '').trimEnd()); + } + + /** + * The browser composers (Story Mode's prompt bar, the phone's Compose sheet) send a + * prompt as a bracketed paste and then a bare Enter, as two writes: over the + * WebSocket both land in `write()`, on the POST fallback the Enter takes + * `writeViaMux()`. Neither is a `text\r` pair, so the Enter armed nothing and a + * prompt sent while Claude Code was ignoring Enter sat unsent. This pairs them + * (PastedPromptWatch) and checks the pasted text, line breaks kept so a paste the + * CLI shows as `[Pasted text #1 +3 lines]` is still recognised. A newer paste + * cancels any pending check: pressing Enter then would send the user's paste too. + */ + private _notePastedPrompt(data: string): void { + const seen = this._pastedPromptWatch.feed(data, Date.now()); + if (seen?.kind === 'paste') this._submitVerifier?.cancel(); + else if (seen?.kind === 'submit') this._armSubmitVerifier(seen.text); + } + + private _armSubmitVerifier(text: string): void { + if (!text.trim() || !this._mux?.capturePaneText || !this._muxSession) return; this._submitVerifier ??= new SubmitVerifier({ capture: () => this._isStopped || !this._mux || !this._muxSession diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index cf169522..11b39918 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -54,7 +54,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `
` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `