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 `