From e3de35446135c3c07dcfe8d27de771247e59d671 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:51:31 +0200 Subject: [PATCH] fix(input): keep the line breaks of a multi-line mux prompt, pasted as one block TmuxManager.sendInput() removed every `\n` before the text reached tmux, so a multi-line prompt sent with `useMux` (or a multi-line respawn prompt) arrived as one line with its lines run together: `"echo A\necho B\r"` ran `echo Aecho B`. The text half of a submit has gone in as one bracketed paste (buildPasteTextCommands) since the Codex paste-burst fix, and inside the paste markers Claude Code and Codex insert a pasted line break into the composer instead of submitting. So the `\n` is now kept (`\r\n` loses only its `\r`) and the existing paste carries the lines; the Enter stays a separate tmux call. Multi-line text without an Enter is pasted too rather than typed with `send-keys -l`, where every line break would reach the pane as a key. Single-line input is unchanged. The test-mode gate moves from the top of sendInput() into inputRunner(), as readPaneRows() does, so a test subclass can record the commands sendInput() would run instead of pinning the branch shapes on the source. SubmitVerifier still compares the composer's last glyph line with the prompt minus its newlines, so it does not reliably recognise a multi-line paste and may not re-press an Enter that Claude Code ignored. `agent send` and the TUI therefore keep refusing or joining multi-line input; the docs that described the stripping now describe the paste and that limit. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/multiline-input-paste.md | 5 + CLAUDE.md | 4 +- docs/architecture-invariants.md | 2 +- docs/extending-codeman.md | 2 +- docs/reliable-input-delivery.md | 2 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 12 ++- docs/wiki/Input-And-Voice.md | 7 +- plugins/codeman/skills/codeman/SKILL.md | 2 +- .../skills/codeman/reference/endpoints.md | 4 +- .../codeman/skills/codeman/reference/verbs.md | 4 +- skills/codeman/SKILL.md | 2 +- skills/codeman/reference/endpoints.md | 4 +- skills/codeman/reference/verbs.md | 4 +- src/cli-agent.ts | 14 +-- src/tmux-manager.ts | 49 ++++++---- src/tui/tui-client.ts | 3 +- src/web/CLAUDE.md | 2 +- src/web/public/app.js | 4 +- test/tmux-submit-paste.test.ts | 94 ++++++++++++++++++- 19 files changed, 164 insertions(+), 56 deletions(-) create mode 100644 .changeset/multiline-input-paste.md 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'$/); + }); +});