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 01/17] 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'$/); + }); +}); From fe10cad83f007cf15d948855c4e88c21cff50def Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:11:28 +0200 Subject: [PATCH 02/17] docs: finish dropping the stripped-newline rule from the input docs The first commit left the old rule standing in places: the gotchas list in extending-codeman.md still said newlines were stripped and that "echo A\necho B\r" ran `echo Aecho B`, and so did both READMEs and the skill's big-input section. Those now describe the paste. Newlines are only kept on the mux path, so the docs that describe the paste say "with useMux": without it a multi-line body is not a plain prompt and is written straight into the pane. Cron keeps rejecting multi-line prompts, but its docs and the code comments in cron-service.ts, schemas.ts and respawn-controller.ts gave a reason this change removes (writeViaMux joining lines, a newline submitting in Ink). The reason that remains is the submit check, which cannot recognise a multi-line paste in the composer, and for cron's paste mode the newlines reaching the CLI as keys. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- README.zh-CN.md | 2 +- docs/cron-guide.md | 7 +++--- docs/extending-codeman.md | 22 ++++++++++--------- docs/wiki/Cron-Jobs.md | 9 ++++---- docs/wiki/Driving-Codeman-From-An-Agent.md | 11 +++++----- docs/wiki/Input-And-Voice.md | 6 ++--- .../skills/codeman/reference/endpoints.md | 5 +++-- .../codeman/skills/codeman/reference/verbs.md | 15 +++++++------ skills/codeman/reference/endpoints.md | 5 +++-- skills/codeman/reference/verbs.md | 15 +++++++------ src/cron/cron-service.ts | 2 +- src/respawn-controller.ts | 3 ++- src/web/schemas.ts | 2 +- 14 files changed, 58 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index d3ad103a..bad01443 100644 --- a/README.md +++ b/README.md @@ -851,7 +851,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se ### Rules of the road (read before you POST) -1. **Single-line input, ending in `\r`.** Programmatic input is sent as literal text, and Enter fires **only when the input contains a carriage return**: `{"input":"run tests\r"}`. Without the `\r` the text sits on the session's prompt unsubmitted (and a combined `wait` runs 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 command `echo Aecho B`: send one line per call. +1. **Input must end in `\r`.** Programmatic input is sent as literal text, and Enter fires **only when the input contains a carriage return**: `{"input":"run tests\r"}`. Without the `\r` the text sits on the session's prompt unsubmitted (and a combined `wait` runs its full timeout on a turn that never started). With `useMux`, embedded newlines are kept and the lines go in as one paste, 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 put long multi-line content in a file the agent is told to read. 2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt. 3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard). ⚠️ A `401` replies with the bare string `Unauthorized`, **not** the JSON envelope, so piping it into `jq` throws a parse error instead of showing the failure: check the status before parsing. 4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`). diff --git a/README.zh-CN.md b/README.zh-CN.md index eb5dd9ba..e70bd7a2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -853,7 +853,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失 ### 行路规则(POST 之前先读) -1. **只发单行输入,而且必须以 `\r` 结尾。** 编程输入按字面文本发送,**只有当输入里含回车符时才会触发 Enter**:`{"input":"run tests\r"}`。少了 `\r`,文本就停在会话的输入框里不被提交(同一次调用里的 `wait` 还会在一个压根没开始的回合上耗满整个超时)。内嵌的换行会被剥掉而不是报错,因此 `"echo A\necho B\r"` 执行的是拼起来的 `echo Aecho B`:一次调用只发一行。 +1. **输入必须以 `\r` 结尾。** 编程输入按字面文本发送,**只有当输入里含回车符时才会触发 Enter**:`{"input":"run tests\r"}`。少了 `\r`,文本就停在会话的输入框里不被提交(同一次调用里的 `wait` 还会在一个压根没开始的回合上耗满整个超时)。使用 `useMux` 时内嵌的换行会保留,各行作为一次粘贴送入,因此 `"echo A\necho B\r"` 在 shell 里会执行两条命令;但重新按下滞留 Enter 的检查无法可靠识别多行粘贴,所以较长的多行内容最好写进文件,让 agent 自己去读。 2. **让输入幂等。** 在 `POST …/input` 上带上稳定的 `clientId` 和按会话单调递增的 `seq`。服务端会去重,因此连接中断后的重试不会重复投递提示。 3. **认证。** 若设置了 `CODEMAN_PASSWORD`,发送 HTTP Basic 认证(用户 `admin` 或 `CODEMAN_USERNAME`)或 `codeman_session` cookie。默认的环回安装无密码。缺失的 `Origin` 头被允许,因此普通 `curl` 可用;跨站的浏览器 origin 会被拒绝(CSRF 防护)。⚠️ `401` 回的是裸字符串 `Unauthorized`,**不是** JSON 信封,直接喂给 `jq` 只会抛解析错误而看不到真正的失败原因:先看状态码,再解析。 4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。 diff --git a/docs/cron-guide.md b/docs/cron-guide.md index 0be6ec77..3ea21f62 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -207,9 +207,10 @@ trailing carriage return: | `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter (a single character is typed, so a dialog answer selects) | Default; like a human pasting the prompt and pressing Enter. | | `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. | -> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman, -> multi-line delivery would be silently corrupted (Ink-based TUIs treat a -> newline as submit; typed mode fuses lines). So newlines are **rejected**: the +> ⚠️ **Single-line only — enforced.** A job fires unattended, and the submit +> check that re-presses an ignored Enter cannot recognise a multi-line paste in +> the composer, so a stranded prompt would go unnoticed (in paste mode the +> newlines reach the CLI as keys). So newlines are **rejected**: the > schema and the form refuse a multi-line `promptText`, and at fire time a > prompt file whose content is multi-line (after stripping trailing newlines) > fails the run with a clear `errorMessage`. Put multi-line instructions in a diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index cad0d04e..3b76697a 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -294,8 +294,8 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then fi # 3. Send the prompt AND register the wait in one call, so the answer cannot be -# the previous turn's idle state. Single line only, ending in \r (otherwise -# Enter is never sent and this wait times out on a turn that never started). +# the previous turn's idle state. It must end in \r (otherwise Enter is never +# sent and this wait times out on a turn that never started). W=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ -H 'Content-Type: application/json' \ -d '{"input":"Run the test suite and summarize the failures\r","useMux":true, @@ -414,14 +414,16 @@ Every one of these has cost somebody real time. shipped bugs more than once. - **`text/plain` bodies stay raw.** Auto-parsing them as JSON enabled simple-request CSRF, so it is deliberate. Send `application/json`. -- **Prompts are single-line and must end with `\r`.** The server splits your text - and Enter into two separate tmux writes (Ink needs them apart), but it sends the - Enter **only when the input contains a carriage return**. Without it your text - sits on the prompt unsubmitted, which is the single most common "the wait - endpoints don't work" report: the wait runs its full timeout on a turn that never - started. Newlines inside the string are stripped rather than rejected, so - `"echo A\necho B\r"` runs the single joined command `echo Aecho B`: send one line - per call. +- **Prompts must end with `\r`.** The server splits your text and Enter into two + separate tmux writes (Ink needs them apart), but it sends the Enter **only when + the input contains a carriage return**. Without it your text sits on the prompt + unsubmitted, which is the single most common "the wait endpoints don't work" + report: the wait runs its full timeout on a turn that never started. With + `useMux`, newlines inside the string 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. - **`wait-output`'s `from=now` is not "printed after you asked".** tmux repaints the visible screen on attach, on resize, and on any TUI redraw, and a repaint arrives as ordinary output, so text already on screen can satisfy a fresh wait. diff --git a/docs/wiki/Cron-Jobs.md b/docs/wiki/Cron-Jobs.md index e45f694f..36d04993 100644 --- a/docs/wiki/Cron-Jobs.md +++ b/docs/wiki/Cron-Jobs.md @@ -51,10 +51,11 @@ or a server restart shifts later runs slightly. That drift is accepted rather th ## Prompts are single line -This is the rule people trip over. Programmatic input into an agent session is single line -everywhere in Codeman, because the terminal UIs these CLIs use treat a newline as submit. A -multi-line prompt would be silently mangled, so it is **rejected** instead: the form refuses -it, and a prompt file whose contents are multi-line fails the run with a clear message. +This is the rule people trip over. A cron job fires with nobody watching, and the check that +re-presses an Enter the agent ignored cannot recognise a multi-line paste in the composer, so +a multi-line prompt could sit there unsent. In `paste` mode the newlines would reach the agent +as keys. So a multi-line prompt is **rejected**: the form refuses it, and a prompt file whose +contents are multi-line fails the run with a clear message. For anything longer than a sentence, put the instructions in a file and make the prompt tell the agent to read it: diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index bcf1bc06..3d976294 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -127,11 +127,12 @@ Read these before writing any code. Each one has cost somebody an afternoon. 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. + and a combined wait burns its full timeout on a turn that never started. With + `useMux`, 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 223835c6..928e511f 100644 --- a/docs/wiki/Input-And-Voice.md +++ b/docs/wiki/Input-And-Voice.md @@ -125,9 +125,9 @@ 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. -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 +With `useMux`, 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/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 5e8b5d38..89ea8ba5 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -420,8 +420,9 @@ 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. -- 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"}`. +- With `useMux`, newlines in `input` are kept: the text goes in as one bracketed paste + and the `\r` submits all lines (without `useMux`, multi-line input is written straight + into the pane). 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 f85fb631..47ca5cb7 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -303,10 +303,11 @@ 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 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: +means "written to the pane", **not** "submitted". With `useMux`, 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: ```bash BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}') @@ -587,9 +588,9 @@ allowlist in §4. ### 5.9 Big input via the workspace -The composer is a single line capped at 65536 characters with newlines stripped, which -makes it a bad channel for a spec, a diff or a file list. The workspace is the good -one, and for a local or docker case you are on the same filesystem as the worker. +The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed +as submitted, which makes it a bad channel for a spec, a diff or a file list. The +workspace is the good one, and for a local or docker case you are on the same filesystem as the worker. 1. Write `TASK.md` into the worker's workspace with your own file tools. The path is `.data.casePath` from `quick-start`, or the `workingDir` you passed to diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 5e8b5d38..89ea8ba5 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -420,8 +420,9 @@ 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. -- 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"}`. +- With `useMux`, newlines in `input` are kept: the text goes in as one bracketed paste + and the `\r` submits all lines (without `useMux`, multi-line input is written straight + into the pane). 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 f85fb631..47ca5cb7 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -303,10 +303,11 @@ 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 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: +means "written to the pane", **not** "submitted". With `useMux`, 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: ```bash BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}') @@ -587,9 +588,9 @@ allowlist in §4. ### 5.9 Big input via the workspace -The composer is a single line capped at 65536 characters with newlines stripped, which -makes it a bad channel for a spec, a diff or a file list. The workspace is the good -one, and for a local or docker case you are on the same filesystem as the worker. +The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed +as submitted, which makes it a bad channel for a spec, a diff or a file list. The +workspace is the good one, and for a local or docker case you are on the same filesystem as the worker. 1. Write `TASK.md` into the worker's workspace with your own file tools. The path is `.data.casePath` from `quick-start`, or the `workingDir` you passed to diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index 7a57424c..7b0a6599 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -97,7 +97,7 @@ const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev']; /** Sync blocklist for the create/update workingDir gate (no settings extras). */ const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES]; -/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */ +/** Prompt delivery is single-line only: the submit check cannot recognise a multi-line paste. */ const HAS_NEWLINE = /[\r\n]/; /** The three session calls prompt delivery needs, so it can be tested without a PTY. */ diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index ef5d84f8..41f7f490 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -2829,7 +2829,8 @@ export class RespawnController extends EventEmitter { let updatePrompt = this.config.updatePrompt; if (statusBlock?.recommendation) { - // Append RECOMMENDATION to the update prompt (single-line — writeViaMux breaks on newlines) + // Append RECOMMENDATION to the update prompt (single-line: the submit check cannot + // recognise a multi-line paste in the composer) const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200); updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`; this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ce577394..0a276d3c 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1905,7 +1905,7 @@ export const ScheduledRunSchema = z.object({ /** 'HH:MM' 24-hour time. */ const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)'); -/** Prompt delivery is single-line only (writeViaMux/Ink constraint) — reject newlines outright. */ +/** Prompt delivery is single-line only (the submit check cannot see a multi-line paste) — reject newlines outright. */ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ From f06635512abfc8238c8c9f5488c0995285232eab Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:53:56 +0200 Subject: [PATCH 03/17] fix(respawn): send a multi-line update or kickstart prompt as one line The update and kickstart prompts are textareas in the respawn settings and the schema accepts line breaks, so a saved config can hold several lines. Both steps went out through writeViaMux, which used to strip every `\n` and run the lines together. Since the mux now pastes multi-line text as one block, a multi-line respawn prompt would have gone out as a multi-line paste, and the check that re-presses a stranded Enter cannot recognise that paste in the composer. Respawn runs unattended, the same situation for which cron keeps rejecting multi-line prompts. singleLineRespawnPrompt() joins the lines with a space before the write; a single-line prompt is passed through unchanged. Respawn joins rather than rejects, so a multi-line config that was accepted before is still sent. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/multiline-input-paste.md | 2 +- src/respawn-controller.ts | 23 ++++-- test/respawn-single-line-prompt.test.ts | 93 +++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 test/respawn-single-line-prompt.test.ts diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md index abe94a80..3d3afaa6 100644 --- a/.changeset/multiline-input-paste.md +++ b/.changeset/multiline-input-paste.md @@ -2,4 +2,4 @@ '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. +A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) 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 the unattended senders stay single-line: cron still rejects multi-line prompts, respawn now joins the lines of a multi-line update or kickstart prompt with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 41f7f490..33e7c277 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -503,6 +503,19 @@ function formatRemainingSeconds(ms: number): number { return Math.max(0, Math.ceil(ms / 1000)); } +/** + * A respawn prompt as the one line it is sent as: every run of line breaks, with the + * whitespace around it, becomes a single space; a single-line prompt is returned as + * is. The config may hold several lines (the settings fields are textareas), but the + * steps run unattended through `writeViaMux`, and the check that re-presses a stranded + * Enter cannot recognise a multi-line paste in the composer. Cron rejects multi-line + * prompts for the same reason; respawn joins them instead, so a saved multi-line + * config is still sent rather than refused. + */ +export function singleLineRespawnPrompt(prompt: string): string { + return prompt.replace(/\s*[\r\n]+\s*/g, ' '); +} + /** Default configuration values */ const DEFAULT_CONFIG: RespawnConfig = { idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback) @@ -1621,7 +1634,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { if (this._state === 'stopped') return; - const prompt = this.config.kickstartPrompt!; + const prompt = singleLineRespawnPrompt(this.config.kickstartPrompt!); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI // COD-51: stop() may have run during the await; re-check before reviving the @@ -2826,13 +2839,13 @@ export class RespawnController extends EventEmitter { // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config const statusBlock = this.session.ralphTracker?.lastStatusBlock; - let updatePrompt = this.config.updatePrompt; + let updatePrompt = singleLineRespawnPrompt(this.config.updatePrompt); if (statusBlock?.recommendation) { - // Append RECOMMENDATION to the update prompt (single-line: the submit check cannot - // recognise a multi-line paste in the composer) + // Append RECOMMENDATION to the update prompt, on the same single line + // (see singleLineRespawnPrompt) const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200); - updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`; + updatePrompt = `${updatePrompt} (Claude's last recommendation: ${rec})`; this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`); } diff --git a/test/respawn-single-line-prompt.test.ts b/test/respawn-single-line-prompt.test.ts new file mode 100644 index 00000000..f976e2d5 --- /dev/null +++ b/test/respawn-single-line-prompt.test.ts @@ -0,0 +1,93 @@ +/** + * Respawn prompts go out as ONE line, whatever the config holds. + * + * The update and kickstart prompts are textareas in the settings, so a saved config + * can hold several lines. `TmuxManager.sendInput()` used to strip every `\n`, which + * ran the lines together; it now pastes multi-line text as one block. Respawn runs + * unattended, and the check that re-presses a stranded Enter cannot recognise a + * multi-line paste in the composer, so the controller joins the lines with a space + * before the write instead of leaning on the mux to drop them. + * + * Strategy: drive the step methods directly against a MockSession and read what + * reached `writeViaMux`. No real tmux. + * + * Port: N/A (no server / no real tmux) + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', async (orig) => { + const actual = await orig(); + return { ...actual, exec: vi.fn((_cmd: string, cb?: (e: Error | null, o: string) => void) => cb?.(null, '')) }; +}); + +import { RespawnController, singleLineRespawnPrompt } from '../src/respawn-controller.js'; +import { Session } from '../src/session.js'; +import { MockSession } from './mocks/index.js'; + +describe('singleLineRespawnPrompt', () => { + it('joins lines with one space, folding the whitespace around each break', () => { + expect(singleLineRespawnPrompt('update the docs\n\n then CLAUDE.md\r\nand continue')).toBe( + 'update the docs then CLAUDE.md and continue' + ); + }); + + it('returns a single-line prompt unchanged', () => { + expect(singleLineRespawnPrompt(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); + }); +}); + +describe('respawn steps send a multi-line prompt as one line', () => { + let controller: RespawnController | null = null; + + afterEach(() => controller?.stop()); + + /** Run one step method and return what it wrote through the mux. */ + async function written( + config: { updatePrompt?: string; kickstartPrompt?: string }, + step: 'sendUpdateDocs' | 'sendKickstart', + recommendation?: string + ): Promise { + const session = new MockSession(); + if (recommendation !== undefined) { + (session as unknown as { ralphTracker: unknown }).ralphTracker = { lastStatusBlock: { recommendation } }; + } + controller = new RespawnController(session as unknown as Session, { + interStepDelayMs: 10, + completionConfirmMs: 10, + noOutputTimeoutMs: 500, + aiIdleCheckEnabled: false, + ...config, + }); + (controller as never as { _state: string })._state = 'watching'; + (controller as never as Record void>)[step](); + // The step schedules its write on the step-delay timer. + await new Promise((r) => setTimeout(r, 40)); + return session.writeBuffer; + } + + it('update prompt', async () => { + const writes = await written( + { updatePrompt: 'summarise progress\nin CLAUDE.md\n\nthen continue' }, + 'sendUpdateDocs' + ); + expect(writes).toEqual(['summarise progress in CLAUDE.md then continue\r']); + }); + + it('update prompt with a recommendation appended', async () => { + const writes = await written({ updatePrompt: 'summarise\nprogress' }, 'sendUpdateDocs', 'fix the\nlogin test'); + expect(writes).toEqual(["summarise progress (Claude's last recommendation: fix the login test)\r"]); + }); + + it('kickstart prompt', async () => { + const writes = await written( + { kickstartPrompt: 'read @fix_plan.md\r\ncontinue with the next task' }, + 'sendKickstart' + ); + expect(writes).toEqual(['read @fix_plan.md continue with the next task\r']); + }); + + it('a single-line prompt is sent as before', async () => { + const writes = await written({ updatePrompt: 'update all the docs and CLAUDE.md' }, 'sendUpdateDocs'); + expect(writes).toEqual(['update all the docs and CLAUDE.md\r']); + }); +}); From 3392a6929054fa72ac6a34ce2420c2fc4f50ba38 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:53:57 +0200 Subject: [PATCH 04/17] docs: tighten the comments and skill text around multi-line input - sendInput's docstring said all text without `\r` is typed; that now holds for single-line text only. - The skill's big-input section still listed "the newline stripping" among the hazards a workspace file avoids; it is the unconfirmed multi-line paste now. Rewrapped, mirrored in plugins/codeman. - The mobile composer's hand-back comment said the prompt transport strips newlines. The hand-back writes to the terminal prompt, whose text reaches the PTY as typed input, where a newline is a key; the behaviour (a multi-line draft stays parked) is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- plugins/codeman/skills/codeman/reference/verbs.md | 11 ++++++----- skills/codeman/reference/verbs.md | 11 ++++++----- src/tmux-manager.ts | 7 ++++--- src/web/public/keyboard-accessory.js | 7 ++++--- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 47ca5cb7..cda70197 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -590,7 +590,8 @@ allowlist in §4. The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed as submitted, which makes it a bad channel for a spec, a diff or a file list. The -workspace is the good one, and for a local or docker case you are on the same filesystem as the worker. +workspace is the good one, and for a local or docker case you are on the same +filesystem as the worker. 1. Write `TASK.md` into the worker's workspace with your own file tools. The path is `.data.casePath` from `quick-start`, or the `workingDir` you passed to @@ -600,10 +601,10 @@ workspace is the good one, and for a local or docker case you are on the same fi 3. Wait on `DONE_` with `wait-output` ([§5.5](#55-markers-for-hook-less-workers)), then read `RESULT.json` back with your own tools. -This sidesteps the byte cap, the newline stripping and the quoting hazards in one -move, and it makes the marker **split by construction**: the token lives in the file, -never in the line you type, so the echo of your own keystrokes cannot match it. The -worker also gets to re-read the task instead of holding it in one echoed line. +This sidesteps the byte cap, the unconfirmed multi-line paste and the quoting hazards +in one move, and it makes the marker **split by construction**: the token lives in the +file, never in the line you type, so the echo of your own keystrokes cannot match it. +The worker also gets to re-read the task instead of holding it in one echoed line. ⚠️ Two places it does not work: a **remote-SSH case** runs on another host whose filesystem you cannot see, and any worker **currently editing** the directory you are diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 47ca5cb7..cda70197 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -590,7 +590,8 @@ allowlist in §4. The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed as submitted, which makes it a bad channel for a spec, a diff or a file list. The -workspace is the good one, and for a local or docker case you are on the same filesystem as the worker. +workspace is the good one, and for a local or docker case you are on the same +filesystem as the worker. 1. Write `TASK.md` into the worker's workspace with your own file tools. The path is `.data.casePath` from `quick-start`, or the `workingDir` you passed to @@ -600,10 +601,10 @@ workspace is the good one, and for a local or docker case you are on the same fi 3. Wait on `DONE_` with `wait-output` ([§5.5](#55-markers-for-hook-less-workers)), then read `RESULT.json` back with your own tools. -This sidesteps the byte cap, the newline stripping and the quoting hazards in one -move, and it makes the marker **split by construction**: the token lives in the file, -never in the line you type, so the echo of your own keystrokes cannot match it. The -worker also gets to re-read the task instead of holding it in one echoed line. +This sidesteps the byte cap, the unconfirmed multi-line paste and the quoting hazards +in one move, and it makes the marker **split by construction**: the token lives in the +file, never in the line you type, so the echo of your own keystrokes cannot match it. +The worker also gets to re-read the task instead of holding it in one echoed line. ⚠️ Two places it does not work: a **remote-SSH case** runs on another host whose filesystem you cannot see, and any worker **currently editing** the directory you are diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index f24af42f..8364cbeb 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3827,11 +3827,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands), * 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) + * - Single-line 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 + * the paste markers a CLI that enabled bracketed paste receives the line breaks as + * pasted text, 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 */ diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index d76aa9c1..4db19523 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -1415,9 +1415,10 @@ const KeyboardAccessoryBar = { overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => { // Hand the text BACK to the terminal prompt so both surfaces hold the same // prompt, the mirror of the adoption composePrompt() does on open. Only a - // single-line draft can make the trip: the prompt transport is single-line - // and strips embedded newlines, so a multi-line draft would arrive mangled - // and instead stays parked as a draft (the dot on the Compose key shows it). + // single-line draft can make the trip: the terminal prompt is one line and + // its text reaches the PTY as typed input, where an embedded newline is a + // key, so a multi-line draft would arrive mangled and instead stays parked + // as a draft (the dot on the Compose key shows it). const text = textarea.value; if (!text || text.includes('\n')) { close({ focusTerminal: true }); From cf999c1296e723aa978ec67cc3cf357da82ef333 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:06:42 +0200 Subject: [PATCH 05/17] fix(auto-compact): send a multi-line focus prompt as one line Auto-compact writes `/compact \r` through writeViaMux when the token count crosses the threshold, unattended. AutoCompactSchema.prompt accepts line breaks, so a prompt set through the API can hold several lines. The mux used to strip them and run the lines together; it now pastes multi-line text as one block, and the check that re-presses a stranded Enter cannot recognise that paste in the composer. That is the case respawn's update and kickstart prompts were already joined for. The join moves out of respawn-controller into a shared toSingleLine() (src/utils/single-line.ts), used by both respawn steps and by auto-compact. Behaviour for respawn is unchanged. The respawn test file becomes test/unattended-single-line-prompt.test.ts and gains an auto-compact block that feeds a real Session a token count over its threshold and reads what reaches writeViaMux. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/multiline-input-paste.md | 2 +- src/respawn-controller.ts | 27 +++----- src/session-auto-ops.ts | 7 ++- src/utils/index.ts | 1 + src/utils/single-line.ts | 20 ++++++ ... => unattended-single-line-prompt.test.ts} | 61 +++++++++++++++---- 6 files changed, 87 insertions(+), 31 deletions(-) create mode 100644 src/utils/single-line.ts rename test/{respawn-single-line-prompt.test.ts => unattended-single-line-prompt.test.ts} (53%) diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md index 3d3afaa6..8d63d181 100644 --- a/.changeset/multiline-input-paste.md +++ b/.changeset/multiline-input-paste.md @@ -2,4 +2,4 @@ 'aicodeman': patch --- -A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) 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 the unattended senders stay single-line: cron still rejects multi-line prompts, respawn now joins the lines of a multi-line update or kickstart prompt with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. +A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) 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 the unattended senders stay single-line: cron still rejects multi-line prompts, respawn's update and kickstart prompts and the auto-compact focus prompt now have their lines joined with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 33e7c277..c1b775ee 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -49,7 +49,13 @@ import { Session } from './session.js'; import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js'; import { AiPlanChecker } from './ai-plan-checker.js'; import type { TeamWatcher } from './team-watcher.js'; -import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js'; +import { + BufferAccumulator, + ANSI_ESCAPE_PATTERN_SIMPLE, + assertNever, + CleanupManager, + toSingleLine, +} from './utils/index.js'; import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js'; import { isCompletionMessage, @@ -503,19 +509,6 @@ function formatRemainingSeconds(ms: number): number { return Math.max(0, Math.ceil(ms / 1000)); } -/** - * A respawn prompt as the one line it is sent as: every run of line breaks, with the - * whitespace around it, becomes a single space; a single-line prompt is returned as - * is. The config may hold several lines (the settings fields are textareas), but the - * steps run unattended through `writeViaMux`, and the check that re-presses a stranded - * Enter cannot recognise a multi-line paste in the composer. Cron rejects multi-line - * prompts for the same reason; respawn joins them instead, so a saved multi-line - * config is still sent rather than refused. - */ -export function singleLineRespawnPrompt(prompt: string): string { - return prompt.replace(/\s*[\r\n]+\s*/g, ' '); -} - /** Default configuration values */ const DEFAULT_CONFIG: RespawnConfig = { idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback) @@ -1634,7 +1627,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { if (this._state === 'stopped') return; - const prompt = singleLineRespawnPrompt(this.config.kickstartPrompt!); + const prompt = toSingleLine(this.config.kickstartPrompt!); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI // COD-51: stop() may have run during the await; re-check before reviving the @@ -2839,11 +2832,11 @@ export class RespawnController extends EventEmitter { // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config const statusBlock = this.session.ralphTracker?.lastStatusBlock; - let updatePrompt = singleLineRespawnPrompt(this.config.updatePrompt); + let updatePrompt = toSingleLine(this.config.updatePrompt); if (statusBlock?.recommendation) { // Append RECOMMENDATION to the update prompt, on the same single line - // (see singleLineRespawnPrompt) + // (see toSingleLine) const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200); updatePrompt = `${updatePrompt} (Claude's last recommendation: ${rec})`; this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`); diff --git a/src/session-auto-ops.ts b/src/session-auto-ops.ts index 28f061c7..74c3ee47 100644 --- a/src/session-auto-ops.ts +++ b/src/session-auto-ops.ts @@ -20,6 +20,7 @@ import { EventEmitter } from 'node:events'; import { detectUsageLimitPause } from './usage-limit-patterns.js'; +import { toSingleLine } from './utils/single-line.js'; // ============================================================================ // Timing Constants @@ -408,7 +409,11 @@ export class SessionAutoOps extends EventEmitter { ); const action = async () => { - const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r'; + // One line: this fires unattended, and a multi-line focus prompt would go out + // as a multi-line paste (see toSingleLine). + const compactCmd = this._autoCompactPrompt + ? `/compact ${toSingleLine(this._autoCompactPrompt)}\r` + : '/compact\r'; await this.callbacks.writeCommand(compactCmd); this.emit('autoCompact', { tokens: totalTokens, diff --git a/src/utils/index.ts b/src/utils/index.ts index 95fab94e..717cb1dc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -27,6 +27,7 @@ export { MAX_SESSION_TOKENS } from './token-validation.js'; export { isSafePushEndpoint } from './push-endpoint-validation.js'; export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js'; export { assertNever } from './type-safety.js'; +export { toSingleLine } from './single-line.js'; export { wrapWithNice } from './nice-wrapper.js'; export { resolveLocalShell, loginShellArgs } from './shell-resolver.js'; export { diff --git a/src/utils/single-line.ts b/src/utils/single-line.ts new file mode 100644 index 00000000..21cbc3db --- /dev/null +++ b/src/utils/single-line.ts @@ -0,0 +1,20 @@ +/** + * @fileoverview Collapse a prompt onto the one line an unattended sender writes. + * + * @module utils/single-line + */ + +/** + * A prompt as one line: every run of line breaks, with the whitespace around it, + * becomes a single space; a single-line prompt is returned as is. + * + * For the senders that run unattended through `writeViaMux` (respawn's update and + * kickstart steps, auto-compact). The mux pastes a multi-line prompt as one block, + * and the check that re-presses a stranded Enter cannot recognise a multi-line paste + * in the composer, so nobody would notice a prompt left unsubmitted. Cron rejects + * multi-line prompts for the same reason; these senders join them instead, so a + * saved multi-line setting is still sent rather than refused. + */ +export function toSingleLine(prompt: string): string { + return prompt.replace(/\s*[\r\n]+\s*/g, ' '); +} diff --git a/test/respawn-single-line-prompt.test.ts b/test/unattended-single-line-prompt.test.ts similarity index 53% rename from test/respawn-single-line-prompt.test.ts rename to test/unattended-single-line-prompt.test.ts index f976e2d5..2ea1fb80 100644 --- a/test/respawn-single-line-prompt.test.ts +++ b/test/unattended-single-line-prompt.test.ts @@ -1,15 +1,17 @@ /** - * Respawn prompts go out as ONE line, whatever the config holds. + * The senders that run unattended write a prompt as ONE line, whatever the config holds. * - * The update and kickstart prompts are textareas in the settings, so a saved config - * can hold several lines. `TmuxManager.sendInput()` used to strip every `\n`, which - * ran the lines together; it now pastes multi-line text as one block. Respawn runs + * Respawn's update and kickstart prompts are textareas in the settings, and the + * auto-compact focus prompt's schema accepts line breaks, so a saved config can hold + * several lines. `TmuxManager.sendInput()` used to strip every `\n`, which ran the + * lines together; it now pastes multi-line text as one block. These senders run * unattended, and the check that re-presses a stranded Enter cannot recognise a - * multi-line paste in the composer, so the controller joins the lines with a space - * before the write instead of leaning on the mux to drop them. + * multi-line paste in the composer, so they join the lines with a space before the + * write (`toSingleLine()`) instead of leaning on the mux to drop them. * - * Strategy: drive the step methods directly against a MockSession and read what - * reached `writeViaMux`. No real tmux. + * Strategy: drive the respawn step methods against a MockSession, and feed a real + * Session a token count over its auto-compact threshold, then read what reached + * `writeViaMux`. No real tmux. * * Port: N/A (no server / no real tmux) */ @@ -20,19 +22,20 @@ vi.mock('node:child_process', async (orig) => { return { ...actual, exec: vi.fn((_cmd: string, cb?: (e: Error | null, o: string) => void) => cb?.(null, '')) }; }); -import { RespawnController, singleLineRespawnPrompt } from '../src/respawn-controller.js'; +import { RespawnController } from '../src/respawn-controller.js'; +import { toSingleLine } from '../src/utils/single-line.js'; import { Session } from '../src/session.js'; import { MockSession } from './mocks/index.js'; -describe('singleLineRespawnPrompt', () => { +describe('toSingleLine', () => { it('joins lines with one space, folding the whitespace around each break', () => { - expect(singleLineRespawnPrompt('update the docs\n\n then CLAUDE.md\r\nand continue')).toBe( + expect(toSingleLine('update the docs\n\n then CLAUDE.md\r\nand continue')).toBe( 'update the docs then CLAUDE.md and continue' ); }); it('returns a single-line prompt unchanged', () => { - expect(singleLineRespawnPrompt(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); + expect(toSingleLine(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); }); }); @@ -91,3 +94,37 @@ describe('respawn steps send a multi-line prompt as one line', () => { expect(writes).toEqual(['update all the docs and CLAUDE.md\r']); }); }); + +describe('auto-compact sends a multi-line focus prompt as one line', () => { + afterEach(() => vi.useRealTimers()); + + /** + * Feed a Session a status line over the threshold and return what its auto-compact + * wrote through `writeViaMux`: the session's own token parser, the trigger and its + * timers, and the `writeCommand` wiring all run as in production. + */ + async function compactWrites(prompt?: string): Promise { + vi.useFakeTimers(); + const session = new Session({ workingDir: '/tmp' }); + const writeViaMux = vi.spyOn(session, 'writeViaMux').mockResolvedValue(true); + session.setAutoCompact(true, 1_000, prompt); + (session as never as { parseTokensFromStatusLine(s: string): void }).parseTokensFromStatusLine('5.0k tokens'); + // The trigger waits AUTO_INITIAL_DELAY_MS (1 s) before it writes. + await vi.advanceTimersByTimeAsync(1_500); + return writeViaMux.mock.calls.map((call) => call[0]); + } + + it('focus prompt with line breaks', async () => { + expect(await compactWrites('keep the open tasks\n\n and the test results\r\nfrom today')).toEqual([ + '/compact keep the open tasks and the test results from today\r', + ]); + }); + + it('a single-line focus prompt is sent as before', async () => { + expect(await compactWrites('keep the open tasks')).toEqual(['/compact keep the open tasks\r']); + }); + + it('no focus prompt sends a bare /compact', async () => { + expect(await compactWrites()).toEqual(['/compact\r']); + }); +}); From 225fdbf50501783f5089658749723cd527c3fc0e Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:06:43 +0200 Subject: [PATCH 06/17] docs: align the cron, teammate-pane and TUI comments with the paste - cron resolvePrompt(): give the reason the rest of the cron docs give (the submit check cannot recognise a multi-line paste; in paste mode the newlines reach the CLI as keys) instead of the old one about writeViaMux fusing lines. - sendInputToPane(): it types with send-keys -l and strips line breaks, so it no longer uses "the same approach as sendInput()". - TUI client: newlines become spaces (the docstring said stripped), and the test name no longer cites "multi-line breaks Ink". Co-Authored-By: Claude Opus 5.5 (1M context) --- src/cron/cron-service.ts | 10 +++++----- src/tmux-manager.ts | 4 +++- src/tui/tui-client.ts | 6 +++--- test/tui/tui-client.test.ts | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index 7b0a6599..b1d33cd6 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -536,11 +536,11 @@ export class CronService { } /** - * Resolves the prompt text and enforces the single-line constraint: prompt - * delivery rides writeViaMux/PTY writes where a newline is Enter, so a - * multi-line prompt would be silently corrupted (typed mode fuses lines, - * paste mode submits the first line and dribbles the rest in as separate - * messages). Rather than mangle an unattended agent's instructions, fail the + * Resolves the prompt text and enforces the single-line constraint: a job + * fires unattended, and the submit check that re-presses an ignored Enter + * cannot recognise a multi-line paste in the composer (typed mode), so a + * stranded prompt would go unnoticed; in paste mode the newlines reach the + * CLI as keys. Rather than risk an unattended agent's instructions, fail the * run with a clear error. A prompt FILE may end with trailing newline(s) * (every editor writes one) — those are stripped before the check. */ diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 8364cbeb..53cd3c89 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -4019,7 +4019,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /** * Send input to a specific pane within a tmux session. - * Uses the same literal text approach as sendInput() but targets a specific pane. + * Types the text literally with `send-keys -l`, line breaks stripped, then Enter + * when the input carried a `\r`. Unlike sendInput(), it neither pastes nor keeps + * the line breaks of multi-line text. */ sendInputToPane(muxName: string, paneTarget: string, input: string): boolean { if (IS_TEST_MODE) return true; diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index a78bf453..c4f035af 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -704,9 +704,9 @@ 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 (the server would paste them as one block, which its submit check - * cannot reliably recognise in the composer). + * the text sits unsubmitted, and embedded newlines become spaces rather than + * being 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/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 59ab9844..8b78fdb8 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -327,7 +327,7 @@ describe('TuiClient.sendInput', () => { expect(JSON.parse(recorded[0].body)).toMatchObject({ input: 'hello world\r', useMux: true }); }); - it('collapses embedded newlines into spaces (multi-line breaks Ink)', async () => { + it('collapses embedded newlines into spaces (a multi-line paste cannot be confirmed as submitted)', async () => { await client().sendInput('abc', 'echo A\necho B\r\nline three '); expect(JSON.parse(recorded[0].body).input).toBe('echo A echo B line three\r'); }); From 3b747774f1039128a68dafb820760816260dde3e Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:03:53 +0200 Subject: [PATCH 07/17] fix(single-line): join lines by split and trim instead of a backtracking regex /\s*[\r\n]+\s*/g backtracks over every start position of a long whitespace run, so 50,000 spaces took over a second. Split on line breaks, trim each line, drop blank ones and join with a space: linear, and a leading or trailing blank line no longer leaves a space behind. A prompt without a line break is still returned unchanged. session-auto-ops now imports toSingleLine from the utils barrel like respawn-controller. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- src/session-auto-ops.ts | 2 +- src/utils/single-line.ts | 23 +++++++++++++++------- test/unattended-single-line-prompt.test.ts | 22 ++++++++++++++++++--- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/session-auto-ops.ts b/src/session-auto-ops.ts index 74c3ee47..d2454c65 100644 --- a/src/session-auto-ops.ts +++ b/src/session-auto-ops.ts @@ -20,7 +20,7 @@ import { EventEmitter } from 'node:events'; import { detectUsageLimitPause } from './usage-limit-patterns.js'; -import { toSingleLine } from './utils/single-line.js'; +import { toSingleLine } from './utils/index.js'; // ============================================================================ // Timing Constants diff --git a/src/utils/single-line.ts b/src/utils/single-line.ts index 21cbc3db..e29b0bd5 100644 --- a/src/utils/single-line.ts +++ b/src/utils/single-line.ts @@ -5,16 +5,25 @@ */ /** - * A prompt as one line: every run of line breaks, with the whitespace around it, - * becomes a single space; a single-line prompt is returned as is. + * A prompt as one line: each line is trimmed, blank lines are dropped, and the rest + * are joined with a single space; a prompt without a line break is returned as is. * * For the senders that run unattended through `writeViaMux` (respawn's update and * kickstart steps, auto-compact). The mux pastes a multi-line prompt as one block, - * and the check that re-presses a stranded Enter cannot recognise a multi-line paste - * in the composer, so nobody would notice a prompt left unsubmitted. Cron rejects - * multi-line prompts for the same reason; these senders join them instead, so a - * saved multi-line setting is still sent rather than refused. + * and the check that re-presses a stranded Enter cannot reliably recognise a + * multi-line paste in the composer, so nobody would notice a prompt left unsubmitted. + * Cron rejects multi-line prompts for the same reason; these senders join them + * instead, so a saved multi-line setting is still sent rather than refused. + * + * Split and trim, which is linear, rather than one regex replace that folds the + * whitespace around each break: that regex backtracks quadratically over a long + * whitespace run (50,000 spaces took over a second). */ export function toSingleLine(prompt: string): string { - return prompt.replace(/\s*[\r\n]+\s*/g, ' '); + if (!/[\r\n]/.test(prompt)) return prompt; + return prompt + .split(/[\r\n]+/) + .map((line) => line.trim()) + .filter(Boolean) + .join(' '); } diff --git a/test/unattended-single-line-prompt.test.ts b/test/unattended-single-line-prompt.test.ts index 2ea1fb80..aa834174 100644 --- a/test/unattended-single-line-prompt.test.ts +++ b/test/unattended-single-line-prompt.test.ts @@ -5,9 +5,9 @@ * auto-compact focus prompt's schema accepts line breaks, so a saved config can hold * several lines. `TmuxManager.sendInput()` used to strip every `\n`, which ran the * lines together; it now pastes multi-line text as one block. These senders run - * unattended, and the check that re-presses a stranded Enter cannot recognise a - * multi-line paste in the composer, so they join the lines with a space before the - * write (`toSingleLine()`) instead of leaning on the mux to drop them. + * unattended, and the check that re-presses a stranded Enter cannot reliably + * recognise a multi-line paste in the composer, so they join the lines with a space + * before the write (`toSingleLine()`) instead of leaning on the mux to drop them. * * Strategy: drive the respawn step methods against a MockSession, and feed a real * Session a token count over its auto-compact threshold, then read what reached @@ -37,6 +37,22 @@ describe('toSingleLine', () => { it('returns a single-line prompt unchanged', () => { expect(toSingleLine(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); }); + + it('drops leading and trailing blank lines instead of leaving a space there', () => { + expect(toSingleLine('\n compact the log \n\n')).toBe('compact the log'); + }); + + it('stays linear on a long whitespace run', () => { + // A regex that folds the whitespace around each break backtracks over every + // start position in the run: 50,000 spaces took well over a second that way. + // The split takes well under a millisecond, so the bound leaves room either side. + const run = ' '.repeat(50_000); + const started = performance.now(); + const joined = toSingleLine(`a${run}b\nc${run}`); + const elapsed = performance.now() - started; + expect(joined).toBe(`a${run}b c`); + expect(elapsed).toBeLessThan(250); + }); }); describe('respawn steps send a multi-line prompt as one line', () => { 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 08/17] 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'); From 74dcf9a59dee8fdbb2b41af642deddf7dbb71086 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:03:55 +0200 Subject: [PATCH 09/17] docs: the submit check cannot reliably recognise a multi-line paste It does match one Claude shows inline when the first line has 24+ non-space characters; it misses a short first line and the [Pasted text #N +k lines] placeholder. Say "cannot reliably" instead of "cannot". Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- docs/cron-guide.md | 6 +++--- docs/wiki/Cron-Jobs.md | 6 +++--- src/cron/cron-service.ts | 4 ++-- src/web/schemas.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/cron-guide.md b/docs/cron-guide.md index 3ea21f62..e8961fdd 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -208,9 +208,9 @@ trailing carriage return: | `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. | > ⚠️ **Single-line only — enforced.** A job fires unattended, and the submit -> check that re-presses an ignored Enter cannot recognise a multi-line paste in -> the composer, so a stranded prompt would go unnoticed (in paste mode the -> newlines reach the CLI as keys). So newlines are **rejected**: the +> check that re-presses an ignored Enter cannot reliably recognise a multi-line +> paste in the composer, so a stranded prompt would go unnoticed (in paste mode +> the newlines reach the CLI as keys). So newlines are **rejected**: the > schema and the form refuse a multi-line `promptText`, and at fire time a > prompt file whose content is multi-line (after stripping trailing newlines) > fails the run with a clear `errorMessage`. Put multi-line instructions in a diff --git a/docs/wiki/Cron-Jobs.md b/docs/wiki/Cron-Jobs.md index 36d04993..a3d49c1d 100644 --- a/docs/wiki/Cron-Jobs.md +++ b/docs/wiki/Cron-Jobs.md @@ -52,9 +52,9 @@ or a server restart shifts later runs slightly. That drift is accepted rather th ## Prompts are single line This is the rule people trip over. A cron job fires with nobody watching, and the check that -re-presses an Enter the agent ignored cannot recognise a multi-line paste in the composer, so -a multi-line prompt could sit there unsent. In `paste` mode the newlines would reach the agent -as keys. So a multi-line prompt is **rejected**: the form refuses it, and a prompt file whose +re-presses an Enter the agent ignored cannot reliably recognise a multi-line paste in the +composer, so a multi-line prompt could sit there unsent. In `paste` mode the newlines would +reach the agent as keys. So a multi-line prompt is **rejected**: the form refuses it, and a prompt file whose contents are multi-line fails the run with a clear message. For anything longer than a sentence, put the instructions in a file and make the prompt tell diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index b1d33cd6..d2b6f753 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -97,7 +97,7 @@ const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev']; /** Sync blocklist for the create/update workingDir gate (no settings extras). */ const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES]; -/** Prompt delivery is single-line only: the submit check cannot recognise a multi-line paste. */ +/** Prompt delivery is single-line only: the submit check cannot reliably recognise a multi-line paste. */ const HAS_NEWLINE = /[\r\n]/; /** The three session calls prompt delivery needs, so it can be tested without a PTY. */ @@ -538,7 +538,7 @@ export class CronService { /** * Resolves the prompt text and enforces the single-line constraint: a job * fires unattended, and the submit check that re-presses an ignored Enter - * cannot recognise a multi-line paste in the composer (typed mode), so a + * cannot reliably recognise a multi-line paste in the composer (typed mode), so a * stranded prompt would go unnoticed; in paste mode the newlines reach the * CLI as keys. Rather than risk an unattended agent's instructions, fail the * run with a clear error. A prompt FILE may end with trailing newline(s) diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 0a276d3c..2f3fcc86 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1905,7 +1905,7 @@ export const ScheduledRunSchema = z.object({ /** 'HH:MM' 24-hour time. */ const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)'); -/** Prompt delivery is single-line only (the submit check cannot see a multi-line paste) — reject newlines outright. */ +/** Prompt delivery is single-line only (the submit check cannot reliably recognise a multi-line paste) — reject newlines outright. */ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ From 00d89ff82864180d618ae568e3599109845babd9 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:09:59 +0200 Subject: [PATCH 10/17] test(single-line): make the linearity check fail on time, not on the result The input ended on a whitespace run, so the old regex returned a different string and the test failed on the result before it reached the time bound. Keep the run inside a line ('ab\nc'): the old regex and the split now return the same 'ab c', and only the elapsed time tells them apart (about 1.8 s against well under a millisecond). Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- test/unattended-single-line-prompt.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/unattended-single-line-prompt.test.ts b/test/unattended-single-line-prompt.test.ts index aa834174..a23fe0a1 100644 --- a/test/unattended-single-line-prompt.test.ts +++ b/test/unattended-single-line-prompt.test.ts @@ -43,12 +43,13 @@ describe('toSingleLine', () => { }); it('stays linear on a long whitespace run', () => { - // A regex that folds the whitespace around each break backtracks over every - // start position in the run: 50,000 spaces took well over a second that way. - // The split takes well under a millisecond, so the bound leaves room either side. + // The run sits inside a line, so the result is the same either way; only the time + // differs. A regex that folds the whitespace around each break retries the run from + // every start position: 50,000 spaces took over a second that way. The split takes + // well under a millisecond, so the bound leaves room either side. const run = ' '.repeat(50_000); const started = performance.now(); - const joined = toSingleLine(`a${run}b\nc${run}`); + const joined = toSingleLine(`a${run}b\nc`); const elapsed = performance.now() - started; expect(joined).toBe(`a${run}b c`); expect(elapsed).toBeLessThan(250); From 157752b4f03d43e9d23d8dc9b17f1b29c61b584c Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:10:00 +0200 Subject: [PATCH 11/17] docs(cron): rewrap the single-line prompt paragraph Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- docs/wiki/Cron-Jobs.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/wiki/Cron-Jobs.md b/docs/wiki/Cron-Jobs.md index a3d49c1d..e26cbd01 100644 --- a/docs/wiki/Cron-Jobs.md +++ b/docs/wiki/Cron-Jobs.md @@ -54,8 +54,8 @@ or a server restart shifts later runs slightly. That drift is accepted rather th This is the rule people trip over. A cron job fires with nobody watching, and the check that re-presses an Enter the agent ignored cannot reliably recognise a multi-line paste in the composer, so a multi-line prompt could sit there unsent. In `paste` mode the newlines would -reach the agent as keys. So a multi-line prompt is **rejected**: the form refuses it, and a prompt file whose -contents are multi-line fails the run with a clear message. +reach the agent as keys. So a multi-line prompt is **rejected**: the form refuses it, and a +prompt file whose contents are multi-line fails the run with a clear message. For anything longer than a sentence, put the instructions in a file and make the prompt tell the agent to read it: From 302e2660bea3243db66483104a040f61de1d49e5 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:19:48 +0200 Subject: [PATCH 12/17] docs: sendwait takes one line; give the submit-check reason in tui.md verbs.md section 5.3 (both copies): sendwait's Enter loop and the server's submit check cannot reliably recognise a multi-line paste, so join the lines or use a file (section 5.9). docs/tui.md gives the same reason instead of the composer one. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- docs/tui.md | 6 +++--- plugins/codeman/skills/codeman/reference/verbs.md | 6 +++++- skills/codeman/reference/verbs.md | 6 +++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 9907d5f1..ec07bb7d 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -136,9 +136,9 @@ instead of failing silently; `Enter` and `1-9` keep working. ### `p` sends exactly one line -The composer is a single line by design, ending in a carriage return: that is the -input contract every Codeman path follows, because multi-line text breaks the -agent's own composer. Pasted newlines become spaces rather than being rejected, so +The composer is a single line by design, ending in a carriage return, because the +server's submit check cannot reliably recognise a multi-line paste: a prompt it cannot +see on the composer line is not re-submitted when the agent ignored the first Enter. Pasted newlines become spaces rather than being rejected, so a paste cannot silently run a different command than the one you read. ## Answering approvals diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index cda70197..a4f43d5a 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -304,7 +304,11 @@ 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". With `useMux`, newlines are kept: the -text goes in as one paste, and the `\r` submits all of it. Build the body with `jq -n` +text goes in as one paste, and the `\r` submits all of it. **`sendwait` takes one line, +though:** its Enter loop and the server's submit check cannot reliably recognise a +multi-line paste, so a multi-line prompt to a freshly spawned worker can sit unsent in +Claude Code's Enter-ignore window. Join the lines, or put long multi-line content in a +file ([§5.9](#59-big-input-via-the-workspace)). 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/reference/verbs.md b/skills/codeman/reference/verbs.md index cda70197..a4f43d5a 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -304,7 +304,11 @@ 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". With `useMux`, newlines are kept: the -text goes in as one paste, and the `\r` submits all of it. Build the body with `jq -n` +text goes in as one paste, and the `\r` submits all of it. **`sendwait` takes one line, +though:** its Enter loop and the server's submit check cannot reliably recognise a +multi-line paste, so a multi-line prompt to a freshly spawned worker can sit unsent in +Claude Code's Enter-ignore window. Join the lines, or put long multi-line content in a +file ([§5.9](#59-big-input-via-the-workspace)). 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: From 2fe9105bab5c4cf180a6a1af1281ba1f0d4ee000 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:31:05 +0200 Subject: [PATCH 13/17] fix(skill): sendwait joins a multi-line prompt onto one line sendwait posted its prompt as is. With the mux now keeping line breaks, a multi-line prompt goes in as a paste, and right after spawn_worker returns it can land inside Claude Code's Enter-ignore window. Neither the Enter loop here nor the server's submit check recognises a pasted block that is still on the composer, so `Review this:\n- item 1` sat unsent while the re-wait ran out. sendwait now trims each line, drops blank ones and joins the rest with a space before building the body, the same result as toSingleLine, like every other waiting sender. The preamble stamp moves to 1.43.1 so cached preambles pick the change up; the plugin mirror is regenerated with scripts/sync-plugin.mjs. The new test runs the real preamble.sh with CURL stubbed and fails without the join. docs/api-reference.md gets the submit-check sentence the README and wiki already carry. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- docs/api-reference.md | 4 +- plugins/codeman/skills/codeman/SKILL.md | 21 +++-- plugins/codeman/skills/codeman/preamble.sh | 13 ++- .../skills/codeman/reference/recipes.md | 2 +- skills/codeman/SKILL.md | 21 +++-- skills/codeman/preamble.sh | 13 ++- skills/codeman/reference/recipes.md | 2 +- test/agent-skill-sendwait.test.ts | 80 +++++++++++++++++++ 8 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 test/agent-skill-sendwait.test.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index 1bd86888..b1323a58 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -437,7 +437,9 @@ 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. +break as a carriage return and runs the lines as they arrive. 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. ```bash curl -s -X POST "$API/api/v1/sessions/$SID/input" \ diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index e67e96c9..621748ae 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -98,8 +98,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.42.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.43.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -339,6 +339,15 @@ spawn_workers() { # pane clearly finished means that profile, so switch that worker to markers. sendwait() { local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i + # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`, + # the TUI): the server pastes a multi-line prompt as one block, and the loop below, + # like the server's own submit check, cannot recognise a pasted block that is still + # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for + # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are + # joined with a space. For a long multi-line task, write a file and send its path. + case "$p" in *$'\n'*|*$'\r'*) + p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; + esac # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model @@ -410,10 +419,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.42.0 +CODEMAN_PREAMBLE=1.43.1 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -464,7 +473,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' diff --git a/plugins/codeman/skills/codeman/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh index 43c30afa..b6dd5f42 100644 --- a/plugins/codeman/skills/codeman/preamble.sh +++ b/plugins/codeman/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -238,6 +238,15 @@ spawn_workers() { # pane clearly finished means that profile, so switch that worker to markers. sendwait() { local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i + # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`, + # the TUI): the server pastes a multi-line prompt as one block, and the loop below, + # like the server's own submit check, cannot recognise a pasted block that is still + # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for + # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are + # joined with a space. For a long multi-line task, write a file and send its path. + case "$p" in *$'\n'*|*$'\r'*) + p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; + esac # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model @@ -309,4 +318,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.42.0 +CODEMAN_PREAMBLE=1.43.1 diff --git a/plugins/codeman/skills/codeman/reference/recipes.md b/plugins/codeman/skills/codeman/reference/recipes.md index be1db504..99ef3108 100644 --- a/plugins/codeman/skills/codeman/reference/recipes.md +++ b/plugins/codeman/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index e67e96c9..621748ae 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -98,8 +98,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.42.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.43.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -339,6 +339,15 @@ spawn_workers() { # pane clearly finished means that profile, so switch that worker to markers. sendwait() { local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i + # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`, + # the TUI): the server pastes a multi-line prompt as one block, and the loop below, + # like the server's own submit check, cannot recognise a pasted block that is still + # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for + # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are + # joined with a space. For a long multi-line task, write a file and send its path. + case "$p" in *$'\n'*|*$'\r'*) + p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; + esac # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model @@ -410,10 +419,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.42.0 +CODEMAN_PREAMBLE=1.43.1 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -464,7 +473,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index 43c30afa..b6dd5f42 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -238,6 +238,15 @@ spawn_workers() { # pane clearly finished means that profile, so switch that worker to markers. sendwait() { local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i + # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`, + # the TUI): the server pastes a multi-line prompt as one block, and the loop below, + # like the server's own submit check, cannot recognise a pasted block that is still + # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for + # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are + # joined with a space. For a long multi-line task, write a file and send its path. + case "$p" in *$'\n'*|*$'\r'*) + p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; + esac # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model @@ -309,4 +318,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.42.0 +CODEMAN_PREAMBLE=1.43.1 diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index be1db504..99ef3108 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the diff --git a/test/agent-skill-sendwait.test.ts b/test/agent-skill-sendwait.test.ts new file mode 100644 index 00000000..d0d45e53 --- /dev/null +++ b/test/agent-skill-sendwait.test.ts @@ -0,0 +1,80 @@ +/** + * @fileoverview `sendwait` in the packaged agent preamble sends one line. + * + * Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub + * that records the request body, so it checks what the function puts on the wire, + * not a copy of its logic. The server pastes a multi-line prompt as one block, and + * neither sendwait's Enter loop nor the server's submit check can recognise a pasted + * block still sitting on the composer, so the prompt must arrive as one line, the + * way `toSingleLine` joins it for the other unattended senders. + * + * Port: N/A (no server; the stub answers every request). + */ + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { toSingleLine } from '../src/utils/single-line.js'; + +const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh'); + +// The preamble builds every request body with jq. +let hasJq = true; +try { + execFileSync('jq', ['--version'], { stdio: 'ignore' }); +} catch { + hasJq = false; +} + +/** The `input` field of the first body sendwait posts for `prompt`. */ +function sentInput(prompt: string): string { + // sendwait calls CURL inside $(...), a subshell, so the stub records to a file. + const dir = mkdtempSync(join(tmpdir(), 'codeman-sendwait-')); + const bodies = join(dir, 'bodies'); + const script = ` + . "$PREAMBLE" >/dev/null + stub() { + while [ "$#" -gt 0 ]; do + if [ "$1" = --data-binary ]; then printf '%s\\n' "$2" >> "$BODIES"; shift; fi + shift + done + printf '%s' '{"data":{"delivered":true,"wait":{"timedOut":false}}}' + } + CURL=(stub) + sendwait worker1234 "$PROMPT" 7 >/dev/null + `; + try { + execFileSync('bash', ['-c', script], { + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + PREAMBLE, + PROMPT: prompt, + BODIES: bodies, + CODEMAN_API_URL: 'http://127.0.0.1:9', + CODEMAN_SESSION_ID: 'caller-session-0000', + }, + }); + const first = readFileSync(bodies, 'utf-8').split('\n')[0]; + return (JSON.parse(first) as { input: string }).input; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe.skipIf(!hasJq)('preamble sendwait', () => { + it('joins a multi-line prompt onto one line before sending it', () => { + expect(sentInput('Review this:\n- item 1\n- item 2')).toBe('Review this: - item 1 - item 2\r'); + }); + + it('joins the same way as toSingleLine (trimmed lines, blank lines dropped, \\r\\n as a break)', () => { + const prompt = ' first line \r\n\n\t second\n\n \nthird\n'; + expect(sentInput(prompt)).toBe(toSingleLine(prompt) + '\r'); + }); + + it('leaves a single-line prompt, glob characters included, as it was', () => { + expect(sentInput('fix * in ?.ts ')).toBe('fix * in ?.ts \r'); + }); +}); From 9a00c2feaac7cbbddc653d59dfcc104e2ea48861 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:31:06 +0200 Subject: [PATCH 14/17] fix(tmux): drop a leading blank line made of spaces, not only line breaks `" \nfix it\r"` still pasted a blank first line, because the leading strip only matched `\n`. /^\s*\n/ drops everything up to the last leading line break, mirroring the trimEnd() at the other end, and the first real line keeps its indentation. The test row fails with the old pattern. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- src/tmux-manager.ts | 11 ++++++++--- test/tmux-submit-paste.test.ts | 5 +++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 9e2fd296..6c2cde4d 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3863,9 +3863,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { const hasCarriageReturn = input.includes('\r'); - // 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(); + // Leading and trailing blank lines are dropped, so neither `"\nfix it\r"` nor + // `" \nfix it\r"` pastes an empty first line (nor `"fix it\n\r"` an empty last + // one). The leading match ends at a line break, so the first real line keeps its + // indentation. + const textPart = input + .replace(/\r/g, '') + .replace(/^\s*\n/, '') + .trimEnd(); if (textPart && hasCarriageReturn) { // Send text first, then Enter as a SEPARATE tmux command after a short delay. diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index e76cf820..39eb1d55 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -261,6 +261,11 @@ describe('sendInput line breaks', () => { await multi.sendInput('s1', '\r\n\r\none\ntwo\r'); expect(bufferText(multi.ran[0])).toBe('one\ntwo'); + // A blank first line made of spaces is dropped too; the first real line keeps its indentation. + const spaced = managerWithSession(); + await spaced.sendInput('s1', ' \n\t\n fix it\nnow\r'); + expect(bufferText(spaced.ran[0])).toBe(' fix it\nnow'); + // 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'); From b9b6ce57b742879b3db0b42f34170e64641eee2f Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:38:17 +0200 Subject: [PATCH 15/17] docs(changeset): mention that sendwait now joins multi-line prompts Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- .changeset/multiline-input-paste.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md index 8d63d181..f72280ac 100644 --- a/.changeset/multiline-input-paste.md +++ b/.changeset/multiline-input-paste.md @@ -2,4 +2,4 @@ 'aicodeman': patch --- -A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) 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 the unattended senders stay single-line: cron still rejects multi-line prompts, respawn's update and kickstart prompts and the auto-compact focus prompt now have their lines joined with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. +A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) 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 the unattended senders stay single-line: cron still rejects multi-line prompts, respawn's update and kickstart prompts and the auto-compact focus prompt now have their lines joined with a space (they used to run together), `codeman agent send` and the TUI keep refusing or joining multi-line input, and the agent skill's `sendwait` now joins the lines of a multi-line prompt with a space too. From 8c8ff383c28afbb5955cb47c66738eb649867082 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:42:12 +0200 Subject: [PATCH 16/17] fix(skill): sendwait refuses an empty prompt; verbs.md says it joins lines A prompt made only of blank lines joined to nothing, and sendwait then sent a bare Enter onto whatever sat on the worker's composer. It now refuses with "sendwait: empty prompt" (the test fails without the check). reference/verbs.md still said sendwait takes one line and a multi-line prompt can sit unsent; it now says sendwait joins the lines with a space, which loses their layout, so long multi-line content belongs in a file. Plugin mirror regenerated. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude --- plugins/codeman/skills/codeman/SKILL.md | 1 + plugins/codeman/skills/codeman/preamble.sh | 1 + plugins/codeman/skills/codeman/reference/verbs.md | 9 +++++---- skills/codeman/SKILL.md | 1 + skills/codeman/preamble.sh | 1 + skills/codeman/reference/verbs.md | 9 +++++---- test/agent-skill-sendwait.test.ts | 5 +++++ 7 files changed, 19 insertions(+), 8 deletions(-) diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 621748ae..c6c85f4a 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -348,6 +348,7 @@ sendwait() { case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac + [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; } # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model diff --git a/plugins/codeman/skills/codeman/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh index b6dd5f42..a0e95a32 100644 --- a/plugins/codeman/skills/codeman/preamble.sh +++ b/plugins/codeman/skills/codeman/preamble.sh @@ -247,6 +247,7 @@ sendwait() { case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac + [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; } # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index a4f43d5a..fa91a749 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -304,11 +304,12 @@ 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". With `useMux`, newlines are kept: the -text goes in as one paste, and the `\r` submits all of it. **`sendwait` takes one line, +text goes in as one paste, and the `\r` submits all of it. **`sendwait` sends one line, though:** its Enter loop and the server's submit check cannot reliably recognise a -multi-line paste, so a multi-line prompt to a freshly spawned worker can sit unsent in -Claude Code's Enter-ignore window. Join the lines, or put long multi-line content in a -file ([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n` +multi-line paste, so a multi-line prompt to a freshly spawned worker could sit unsent in +Claude Code's Enter-ignore window. `sendwait` therefore joins the lines with a space, +which loses their layout; put long multi-line content in a file +([§5.9](#59-big-input-via-the-workspace)). 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 621748ae..c6c85f4a 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -348,6 +348,7 @@ sendwait() { case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac + [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; } # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index b6dd5f42..a0e95a32 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -247,6 +247,7 @@ sendwait() { case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac + [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; } # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index a4f43d5a..fa91a749 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -304,11 +304,12 @@ 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". With `useMux`, newlines are kept: the -text goes in as one paste, and the `\r` submits all of it. **`sendwait` takes one line, +text goes in as one paste, and the `\r` submits all of it. **`sendwait` sends one line, though:** its Enter loop and the server's submit check cannot reliably recognise a -multi-line paste, so a multi-line prompt to a freshly spawned worker can sit unsent in -Claude Code's Enter-ignore window. Join the lines, or put long multi-line content in a -file ([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n` +multi-line paste, so a multi-line prompt to a freshly spawned worker could sit unsent in +Claude Code's Enter-ignore window. `sendwait` therefore joins the lines with a space, +which loses their layout; put long multi-line content in a file +([§5.9](#59-big-input-via-the-workspace)). 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/test/agent-skill-sendwait.test.ts b/test/agent-skill-sendwait.test.ts index d0d45e53..b38d412f 100644 --- a/test/agent-skill-sendwait.test.ts +++ b/test/agent-skill-sendwait.test.ts @@ -47,6 +47,7 @@ function sentInput(prompt: string): string { `; try { execFileSync('bash', ['-c', script], { + stdio: ['ignore', 'pipe', 'pipe'], env: { PATH: process.env.PATH, HOME: process.env.HOME, @@ -74,6 +75,10 @@ describe.skipIf(!hasJq)('preamble sendwait', () => { expect(sentInput(prompt)).toBe(toSingleLine(prompt) + '\r'); }); + it('refuses a prompt made only of blank lines instead of sending a bare Enter', () => { + expect(() => sentInput(' \n \t\n')).toThrow(/sendwait: empty prompt/); + }); + it('leaves a single-line prompt, glob characters included, as it was', () => { expect(sentInput('fix * in ?.ts ')).toBe('fix * in ?.ts \r'); }); From 6c97d634998f2a5654a46fed21bf78a1a057480b Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 22 Oct 2026 19:12:03 +0200 Subject: [PATCH 17/17] docs: give the composer frame's mux-fallback rule its current reason Merge-time fix for #670. TmuxManager.sendInput() now keeps line feeds and strips only `\r`. The rule that the composer's bracketed-paste frame must never take the mux fallback still holds, because the frame maps its line breaks to `\r`: on tmux the lines weld into one, pasted inside a second pair of markers, and get submitted. docs/architecture-invariants.md and src/web/CLAUDE.md now say that instead of "strips every \r and \n" and "pastes the frame's own markers inside a second paste". Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture-invariants.md | 2 +- src/web/CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1d8ca4a5..659350c8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1050,7 +1050,7 @@ Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Spac ⚠️ **Delivery is a hand-built bracketed-paste frame** (`\x1b[200~` + the text with newlines mapped to `\r` + `\x1b[201~`, byte-identical to what `terminal.paste()` would emit) through `_sendInputAsync` WITHOUT `{ useMux: true }`, plus a SEPARATE Enter 120 ms later WITH it (codex drops keys that share a PTY read with a bracketed paste). Not `terminal.paste()`, for two reasons: xterm's `bracketedPasteMode` mirror is false for every session after a tab switch or reload (`terminal.reset()` in the replay re-clones the DEC modes, the tmux capture carries no `?2004h`, and tmux never forwards the pane's DECSET to a client after attach), so a paste through xterm would go out unbracketed and the CLI would submit at the first `\r`; and xterm's onData is where the local-echo paste branch flushes pending overlay text AHEAD of the block, text the composer has already taken and erased from the PTY, so the frame goes straight to the wire with the composer as the prompt's only owner. The unconditional frame is safe for a CLI that never enabled DECSET 2004 because tmux does the gating (measured against a live pane: markers stripped for a `cat -v` pane, forwarded intact to a process that had emitted `?2004h`). -⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r` and `\n`, which welds the lines together and submits them. +⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one, pasted inside a second pair of markers, and get submitted. ⚠️ The size guard sits on the `MAX_INPUT_LENGTH` boundary (64 KiB of UTF-16 code units): `ws-routes.ts` drops a longer frame WITHOUT an ACK, which would wedge the durable queue, so `_composerMaxLength` is derived from that limit minus both markers and an oversized prompt stays a draft with a toast. diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index b7e20d33..bf9addfe 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 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) +**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 (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_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)