diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md new file mode 100644 index 00000000..abe94a80 --- /dev/null +++ b/.changeset/multiline-input-paste.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`, respawn prompts) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so `codeman agent send` and the TUI keep refusing or joining multi-line input. diff --git a/CLAUDE.md b/CLAUDE.md index bb78756b..afb393c7 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 +- **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 - **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) @@ -124,7 +124,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Key Patterns -**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`, single-line only. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) +**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..1d8ca4a5 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -119,7 +119,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden. ### Input delivery and WS resilience -**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`). +**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. A multi-line prompt keeps its line breaks inside that one paste, with or without the Enter (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`). ### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index eaf18448..cad0d04e 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -183,7 +183,7 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions \ -H 'Content-Type: application/json' \ -d '{"workingDir":"/home/me/project","mode":"claude"}' -# Send a prompt (single-line only, and it must end with \r: Enter is sent only +# Send a prompt (it must end with \r: Enter is sent only # when the input contains a carriage return; without it the text sits on the # session's prompt unsubmitted) curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \ diff --git a/docs/reliable-input-delivery.md b/docs/reliable-input-delivery.md index 99f65beb..37e154dd 100644 --- a/docs/reliable-input-delivery.md +++ b/docs/reliable-input-delivery.md @@ -78,7 +78,7 @@ input for the session, and came back from localStorage on each reload. - `_sendInputAsync()` splits a paste over the frame limit into in-limit frames (`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They go out in seq order, so the PTY sees one contiguous stream. A paste over - `PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never + `PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (one paste plus its own Enter, never split), is refused with a toast and never queued. - The WebSocket answers an oversized sequenced frame with `{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 01bcaded..bcf1bc06 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -125,11 +125,13 @@ These are set in every managed session. Read them rather than hardcoding anythin Read these before writing any code. Each one has cost somebody an afternoon. -1. **Input is single line and must end with `\r`.** Enter fires only when the payload - contains a carriage return. Without it the text sits unsubmitted on the prompt, the - request still succeeds, and a combined wait burns its full timeout on a turn that never - started. Embedded newlines are stripped rather than rejected, so `"echo A\necho B\r"` runs - the joined `echo Aecho B`. One line per call. +1. **Input must end with `\r`.** Enter fires only when the payload contains a carriage + return. Without it the text sits unsubmitted on the prompt, the request still succeeds, + and a combined wait burns its full timeout on a turn that never started. Embedded + newlines are kept: the text goes in as one paste and the Enter after it submits all of + it, so `"echo A\necho B\r"` runs both commands in a shell. The check that re-presses a + stranded Enter cannot reliably recognise a multi-line paste, so a long multi-line + prompt to a just-started agent is still safer in a file it is told to read. 2. **Make input idempotent.** Send a stable `clientId` and a monotonic per-session `seq`. The server deduplicates, so a retry after a dropped connection cannot double-deliver. 3. **Auth.** With `CODEMAN_PASSWORD` set, use HTTP Basic or the session cookie. A missing diff --git a/docs/wiki/Input-And-Voice.md b/docs/wiki/Input-And-Voice.md index a873a504..223835c6 100644 --- a/docs/wiki/Input-And-Voice.md +++ b/docs/wiki/Input-And-Voice.md @@ -125,9 +125,10 @@ Sending prompts over the API has one rule that catches everyone: **the payload m `\r`** or Enter is never sent. The request still succeeds, the text sits unsubmitted in the composer, and any wait burns its whole timeout on a turn that never started. -Input is also **single line**. Embedded newlines are stripped rather than rejected, so -`"echo A\necho B\r"` runs the joined `echo Aecho B`. Put multi-line content in a file and -tell the agent to read it. +Embedded newlines are kept: multi-line input goes in as one paste and the Enter after it +submits all of it, so `"echo A\necho B\r"` runs both commands in a shell. The check that +re-presses a stranded Enter cannot reliably recognise a multi-line paste, so long +multi-line content is still safer in a file the agent is told to read. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index a9772f77..e67e96c9 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -587,7 +587,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de | know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) | | make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) | | resume a worker halted on a usage limit | `POST .../auto-resume {"enabled":true}`. Respawn and Ralph are **not** the remedy: respawn runs `/clear` | [§5.8](reference/verbs.md#58-usage-limits) | -| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, single-line, newlines stripped | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) | +| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, and `send` takes one line | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) | | watch N workers at once | one in-flight wait per worker (per-session waiter cap 16); fan-out shapes differ for claude and shell | [§5.10](reference/verbs.md#510-fan-out) | | find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) | | read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) | diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index f2ef2fb2..5e8b5d38 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -420,8 +420,8 @@ callers send no body at all. **or Enter is never sent**: the text is typed onto the worker's prompt and sits there unsubmitted. This is [symptom 1](#1-deliveredtrue-then-every-wait-times-out), the number-one silent failure. -- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm - a dialog), send `{"input":"\r"}`. +- Newlines in `input` are kept: the text goes in as one bracketed paste and the `\r` + submits all lines. To send a bare Enter (confirm a dialog), send `{"input":"\r"}`. - `input` is capped at **65536** characters. ⚠️ **Two caps disagree and the smaller one is the real one**: the Zod schema allows 100000 (`schemas.ts:1035`), so a 65537-to-100000 character body passes validation and *then* 400s at the route against diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 2dfe8bb0..f85fb631 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -303,8 +303,8 @@ duplicate resend costs nothing. Codeman types the text and sends Enter **only when the input contains a carriage return**; without it your command sits unsubmitted on the worker's prompt and everything downstream times out. No response field catches this: `delivered:true` -means "written to the pane", **not** "submitted". Newlines are stripped, so input is -single-line by construction. Build the body with `jq -n` for any prompt you did not +means "written to the pane", **not** "submitted". Newlines are kept: the text goes in +as one paste, and the `\r` submits all of it. Build the body with `jq -n` for any prompt you did not author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$` in a real prompt: diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index a9772f77..e67e96c9 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -587,7 +587,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de | know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) | | make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) | | resume a worker halted on a usage limit | `POST .../auto-resume {"enabled":true}`. Respawn and Ralph are **not** the remedy: respawn runs `/clear` | [§5.8](reference/verbs.md#58-usage-limits) | -| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, single-line, newlines stripped | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) | +| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, and `send` takes one line | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) | | watch N workers at once | one in-flight wait per worker (per-session waiter cap 16); fan-out shapes differ for claude and shell | [§5.10](reference/verbs.md#510-fan-out) | | find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) | | read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) | diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index f2ef2fb2..5e8b5d38 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -420,8 +420,8 @@ callers send no body at all. **or Enter is never sent**: the text is typed onto the worker's prompt and sits there unsubmitted. This is [symptom 1](#1-deliveredtrue-then-every-wait-times-out), the number-one silent failure. -- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm - a dialog), send `{"input":"\r"}`. +- Newlines in `input` are kept: the text goes in as one bracketed paste and the `\r` + submits all lines. To send a bare Enter (confirm a dialog), send `{"input":"\r"}`. - `input` is capped at **65536** characters. ⚠️ **Two caps disagree and the smaller one is the real one**: the Zod schema allows 100000 (`schemas.ts:1035`), so a 65537-to-100000 character body passes validation and *then* 400s at the route against diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 2dfe8bb0..f85fb631 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -303,8 +303,8 @@ duplicate resend costs nothing. Codeman types the text and sends Enter **only when the input contains a carriage return**; without it your command sits unsubmitted on the worker's prompt and everything downstream times out. No response field catches this: `delivered:true` -means "written to the pane", **not** "submitted". Newlines are stripped, so input is -single-line by construction. Build the body with `jq -n` for any prompt you did not +means "written to the pane", **not** "submitted". Newlines are kept: the text goes in +as one paste, and the `\r` submits all of it. Build the body with `jq -n` for any prompt you did not author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$` in a real prompt: diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 505a5af3..f89de132 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -129,16 +129,18 @@ export function sendPromptFromArgs(words: readonly string[]): { text: string } | } /** - * Why `send` refuses this text, or `undefined` when it is printable. The composer - * takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and - * DEL) reaches the pane as a keypress. None of that is a prompt. + * Why `send` refuses this text, or `undefined` when it is printable. A line break is + * refused for the reason below; everything else below 0x20 (and DEL) reaches the pane + * as a keypress. None of that is a prompt. */ export function inputRefusal(text: string): string | undefined { if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send ""` is never a prompt)'; - // The composer is one line: the server strips newlines, which silently joins the - // lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle). + // The server pastes a multi-line prompt as one block, but the check that re-presses + // a stranded Enter cannot reliably recognise that block in the composer, so a wait + // could burn its timeout on a prompt that never went out. A tab reaches the pane as + // a keypress (claude: mode toggle). if (/[\n\r\t]/.test(text)) { - return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path'; + return 'refusing: input must be a single line (a multi-line paste cannot be confirmed as submitted) — join them yourself, or write a file into the workspace and send its path'; } // C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal). // eslint-disable-next-line no-control-regex diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d3a2d174..f24af42f 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3828,6 +3828,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * so Codex cannot fold the Enter into a paste burst; a single character is typed * with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys) * - Text without `\r` is typed literally with `send-keys -l` (no key interpretation) + * - A `\n` inside the text is kept (`\r\n` loses only its `\r`), and multi-line text + * always goes in as ONE bracketed paste, with or without a following Enter: inside + * the paste markers an agent CLI inserts the line breaks into its composer, where + * typed as keys each one could submit a line on its own * - `Enter` key is sent as a SEPARATE tmux invocation after a small delay * - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline */ @@ -3840,8 +3844,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return false; } - // TEST MODE: No-op — don't send input to real tmux sessions - if (IS_TEST_MODE) { + // TEST MODE: No-op — don't send input to real tmux sessions. The gate lives in + // inputRunner() so a test subclass can record what WOULD reach tmux. + const run = this.inputRunner(); + if (!run) { return true; } @@ -3856,7 +3862,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { const hasCarriageReturn = input.includes('\r'); - const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); + const textPart = input.replace(/\r/g, '').trimEnd(); if (textPart && hasCarriageReturn) { // Send text first, then Enter as a SEPARATE tmux command after a short delay. @@ -3868,31 +3874,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // selection dialog ignores a pasted `3` and Enter then confirms the // highlighted option (see submitsAsKeys). if (submitsAsKeys(textPart)) { - await execAsync( - `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, - { timeout: EXEC_TIMEOUT_MS } + await run( + `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` ); } else { - await pasteText( - (cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }), - buildPasteTextCommands(this.tmux(), session.muxName, textPart) - ); + await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart)); } await new Promise((resolve) => setTimeout(resolve, 50)); - await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { - timeout: EXEC_TIMEOUT_MS, - }); + 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. + await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart)); } else if (textPart) { // Text only, no Enter - await execAsync( - `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, - { timeout: EXEC_TIMEOUT_MS } + await run( + `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` ); } else if (hasCarriageReturn) { // Enter only - await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { - timeout: EXEC_TIMEOUT_MS, - }); + await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`); } return true; @@ -3902,6 +3903,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** + * How {@link sendInput} reaches tmux, or null where it must not (vitest). Split + * out like {@link readPaneRows}: with the test-mode gate at the top of sendInput, + * no test could see which commands a submit actually sends. + */ + protected inputRunner(): ((cmd: string) => Promise) | null { + if (IS_TEST_MODE) return null; + return (cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }); + } + // ========== Pane Methods (for Agent Team teammate panes) ========== /** diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index b90863cc..a78bf453 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -705,7 +705,8 @@ export class TuiClient { * Two hard rules from CLAUDE.md, both enforced here so no caller can get them * wrong: the payload must END with `\r` or the server never issues Enter and * the text sits unsubmitted, and embedded newlines are stripped rather than - * sent (multi-line input breaks Ink, and the server would join the lines). + * sent (the server would paste them as one block, which its submit check + * cannot reliably recognise in the composer). * The `clientId`/`seq` pair makes delivery exactly-once, so a retry after a * dropped connection cannot type the prompt twice. */ diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 30ef4952..b7e20d33 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -70,7 +70,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl) -**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it strips newlines). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer) +**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it would paste the frame's own markers inside a second paste). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer) **Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines) diff --git a/src/web/public/app.js b/src/web/public/app.js index 62716497..f6e48baa 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3938,8 +3938,8 @@ class CodemanApp { // Both transports refuse a frame over the server's limit (issue #484), and a // refused frame used to sit at the head of the durable queue for good. So an // oversized paste goes out as several in-limit frames, delivered in seq order - // as one contiguous stream. A mux write is line-oriented (it strips newlines - // and sends Enter on its own), so it is never split: refuse it instead. + // as one contiguous stream. A mux write is one paste that sends Enter on its + // own, so it is never split: refuse it instead. const limit = window.CodemanInputLimit; if (limit && input.length > limit.FRAME_MAX_CHARS) { if (useMux || input.length > limit.PASTE_MAX_CHARS) { diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index ba529f18..7104fdc7 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -11,16 +11,27 @@ * a one-line prompt). With `paste-buffer -p` the paste is marked and the separate * Enter submits. * + * Also the line breaks of a multi-line `sendInput`: kept, and pasted as ONE block + * with or without the Enter, where they used to be stripped and the lines joined. + * * Strategy: pure command shape, plus a round trip through a real shell with a fake * `tmux` that prints its argv, so quoting and tmux's trailing-`;` rule are checked * on the bytes a shell actually hands over. `pasteText` runs against a recording - * runner. No real tmux. + * runner, and `sendInput` against a subclass whose `inputRunner()` records the + * commands instead of running them. No real tmux. * * Port: N/A (no server / no real tmux) */ import { execSync } from 'node:child_process'; import { describe, expect, it } from 'vitest'; -import { buildPasteTextCommands, escapeTmuxTrailingSemicolon, pasteText, submitsAsKeys } from '../src/tmux-manager.js'; +import type { MuxSession } from '../src/mux-interface.js'; +import { + TmuxManager, + buildPasteTextCommands, + escapeTmuxTrailingSemicolon, + pasteText, + submitsAsKeys, +} from '../src/tmux-manager.js'; const FAKE_TMUX = `${JSON.stringify(process.execPath)} -e 'console.log(JSON.stringify(process.argv.slice(1)))' --`; @@ -136,8 +147,8 @@ describe('pasteText', () => { }); describe('sendInput wiring', () => { - // sendInput() returns before touching tmux under vitest (IS_TEST_MODE), so the - // branch that submits text is pinned on the source instead. + // These pin the branch shapes on the source; the commands sendInput actually runs + // are recorded in 'sendInput line breaks' below. async function source(): Promise { const { readFileSync } = await import('node:fs'); return readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8'); @@ -178,3 +189,78 @@ describe('sendInput wiring', () => { for (const cmd of typedHere) expect(cmd).toContain('escapeTmuxTrailingSemicolon(textPart)'); }); }); + +describe('sendInput line breaks', () => { + class RecordingManager extends TmuxManager { + ran: string[] = []; + protected override inputRunner(): (cmd: string) => Promise { + return async (cmd: string) => { + this.ran.push(cmd); + }; + } + } + + function managerWithSession(): RecordingManager { + const manager = new RecordingManager(); + manager.registerSession({ + sessionId: 's1', + muxName: 'codeman-abc12345', + pid: 100, + createdAt: 0, + workingDir: '/tmp', + mode: 'claude', + attached: true, + } as MuxSession); + return manager; + } + + /** The text a recorded `set-buffer` command hands to tmux, as a shell delivers it. */ + function bufferText(setBufferCmd: string): string { + return argvOf(setBufferCmd.replace(/^.*? set-buffer /, `${FAKE_TMUX} set-buffer `)).at(-1) as string; + } + + it('submits a multi-line prompt as one paste with its line breaks, then Enter', async () => { + const manager = managerWithSession(); + expect(await manager.sendInput('s1', 'line one\nline two\n\nparagraph three\r')).toBe(true); + + expect(manager.ran).toHaveLength(3); + const [set, paste, enter] = manager.ran; + expect(bufferText(set)).toBe('line one\nline two\n\nparagraph three'); + expect(paste).toMatch(/ paste-buffer -p -d -b \S+ -t 'codeman-abc12345'$/); + expect(enter).toMatch(/ send-keys -t "codeman-abc12345" Enter$/); + }); + + it('turns CRLF line endings into plain line breaks', async () => { + const manager = managerWithSession(); + await manager.sendInput('s1', 'one\r\ntwo\r'); + expect(bufferText(manager.ran[0])).toBe('one\ntwo'); + }); + + it('pastes multi-line text without Enter too, and never types it as keys', async () => { + const manager = managerWithSession(); + await manager.sendInput('s1', 'one\ntwo'); + + expect(manager.ran).toHaveLength(2); + expect(bufferText(manager.ran[0])).toBe('one\ntwo'); + expect(manager.ran[1]).toContain(' paste-buffer -p '); + for (const cmd of manager.ran) expect(cmd).not.toContain('send-keys'); + }); + + it('drops trailing line breaks instead of pasting an empty last line', async () => { + const manager = managerWithSession(); + await manager.sendInput('s1', 'one\ntwo\n\n\r'); + expect(bufferText(manager.ran[0])).toBe('one\ntwo'); + }); + + 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'); + expect(bufferText(submitted.ran[0])).toBe('read your inbox'); + expect(submitted.ran.at(-1)).toMatch(/ send-keys -t "codeman-abc12345" Enter$/); + + const typed = managerWithSession(); + await typed.sendInput('s1', 'just type'); + expect(typed.ran).toHaveLength(1); + expect(typed.ran[0]).toMatch(/ send-keys -t "codeman-abc12345" -l 'just type'$/); + }); +});