From cf9511cb57d1e1838a180487f35ce43d2a057662 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:03:54 +0200 Subject: [PATCH] fix(tmux): drop leading line breaks from sendInput text "\nfix it\r" pasted an empty first line. Leading line breaks are now dropped like trailing ones. Document what useMux does with a line feed, and that a pane without bracketed paste receives each pasted line break as CR, so a multi-line write without \r runs every line but the last there, where the old line-stripping typed the joined text and ran nothing. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- CLAUDE.md | 2 +- docs/api-reference.md | 4 ++++ src/tmux-manager.ts | 9 +++++++-- test/tmux-submit-paste.test.ts | 15 +++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index afb393c7..735f03e5 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 -- **A prompt is one paste plus Enter** — `writeViaMux()` sends the text as one bracketed paste and Enter separately. ⚠️ **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 kept (`\r\n` becomes `\n`): all lines go in as that ONE paste and the Enter after it submits them together, so `"echo A\necho B\r"` runs both commands in a shell (bash holds the pasted lines until the Enter; a shell without bracketed paste runs each line as it arrives). ⚠️ `SubmitVerifier` does not recognise a multi-line paste in the composer (it shows as a placeholder such as `[Pasted text #N +k lines]`, or as its first line only), so it may not re-press a stranded Enter for one. ⚠️ **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 +- **A prompt is one paste plus Enter** — `writeViaMux()` sends the text as one bracketed paste and Enter separately. ⚠️ **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 kept (`\r\n` becomes `\n`): all lines go in as that ONE paste and the Enter after it submits them together, so `"echo A\necho B\r"` runs both commands in a shell (bash holds the pasted lines until the Enter; a shell without bracketed paste runs each line as it arrives). Multi-line text WITHOUT `\r` is pasted the same way; on a pane that has not enabled bracketed paste (POSIX sh, macOS `/bin/bash` 3.2) tmux delivers every line break as a CR, so every line but the last runs, where the old line-stripping typed the joined text and ran nothing. Leading and trailing line breaks are dropped. ⚠️ `SubmitVerifier` cannot reliably recognise a multi-line paste in the composer: it matches one Claude shows inline whose first line has 24+ non-space characters, but misses a short first line and the `[Pasted text #N +k lines]` placeholder, so it may not re-press a stranded Enter for one. ⚠️ **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 - **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/docs/api-reference.md b/docs/api-reference.md index c0350a66..1bd86888 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -434,6 +434,10 @@ about a hundred characters or more is taken as a paste by Claude Code, its `\r` becomes a newline, and the prompt stays unsent (measured on 2.1.283). Any other input (escape sequences, a bracketed-paste frame, a line feed, a bare `\r`) keeps the raw write, and an explicit `"useMux": false` forces it. +With `"useMux": true`, a line feed is kept: the text goes in as one bracketed paste +(`\r\n` read as `\n`, leading and trailing line breaks dropped), then Enter if the +input carried a `\r`; a pane that has not enabled bracketed paste receives each line +break as a carriage return and runs the lines as they arrive. ```bash curl -s -X POST "$API/api/v1/sessions/$SID/input" \ diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 53cd3c89..9e2fd296 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3863,7 +3863,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { const hasCarriageReturn = input.includes('\r'); - const textPart = input.replace(/\r/g, '').trimEnd(); + // Leading and trailing line breaks are dropped, so `"\nfix it\r"` does not paste + // an empty first line (nor `"fix it\n\r"` an empty last one). + const textPart = input.replace(/\r/g, '').replace(/^\n+/, '').trimEnd(); if (textPart && hasCarriageReturn) { // Send text first, then Enter as a SEPARATE tmux command after a short delay. @@ -3885,7 +3887,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`); } else if (textPart.includes('\n')) { // Multi-line text, no Enter: pasted, because typed as keys every `\n` would - // reach the pane as a key the CLI may read as submit. + // reach the pane as a key the CLI may read as submit. A pane that has not + // enabled bracketed paste (POSIX sh, macOS /bin/bash 3.2) still gets every line + // break as a CR, so there every line but the last runs; before line breaks were + // kept, the joined text was typed and nothing ran. await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart)); } else if (textPart) { // Text only, no Enter diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index 7104fdc7..e76cf820 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -252,6 +252,21 @@ describe('sendInput line breaks', () => { expect(bufferText(manager.ran[0])).toBe('one\ntwo'); }); + it('drops leading line breaks instead of pasting an empty first line', async () => { + const single = managerWithSession(); + await single.sendInput('s1', '\nfix it\r'); + expect(bufferText(single.ran[0])).toBe('fix it'); + + const multi = managerWithSession(); + await multi.sendInput('s1', '\r\n\r\none\ntwo\r'); + expect(bufferText(multi.ran[0])).toBe('one\ntwo'); + + // Without an Enter, what is left is one line, so it is typed like any other. + const typed = managerWithSession(); + await typed.sendInput('s1', '\n\njust type'); + expect(typed.ran).toEqual([expect.stringMatching(/ send-keys -t "codeman-abc12345" -l 'just type'$/)]); + }); + it('leaves single-line input as it was: pasted with Enter, typed without', async () => { const submitted = managerWithSession(); await submitted.sendInput('s1', 'read your inbox\r');