diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md new file mode 100644 index 00000000..f72280ac --- /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`) 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. diff --git a/CLAUDE.md b/CLAUDE.md index ebadaabe..1173fc69 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). 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) @@ -125,7 +125,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/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/api-reference.md b/docs/api-reference.md index c0350a66..b1323a58 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -434,6 +434,12 @@ 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. 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/docs/architecture-invariants.md b/docs/architecture-invariants.md index 473a52b8..f67c06bc 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -123,7 +123,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 @@ -1054,7 +1054,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/docs/cron-guide.md b/docs/cron-guide.md index 461b5cb7..824916f2 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 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/extending-codeman.md b/docs/extending-codeman.md index eaf18448..3b76697a 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 \ @@ -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/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/tui.md b/docs/tui.md index 55d981e6..8abab15b 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/docs/wiki/Cron-Jobs.md b/docs/wiki/Cron-Jobs.md index e45f694f..e26cbd01 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 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 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 01bcaded..3d976294 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -125,11 +125,14 @@ 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. 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 a873a504..928e511f 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. +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/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index d1376225..9aedd574 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,16 @@ 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 + [ -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 @@ -410,10 +420,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 +474,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' @@ -587,7 +597,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/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh index 43c30afa..a0e95a32 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,16 @@ 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 + [ -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 @@ -309,4 +319,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/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 2dafe613..3edf4f70 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. -- `input` must be single-line (newlines are stripped). 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/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/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index c0020676..14d37133 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -304,10 +304,16 @@ 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 -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. **`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 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: ```bash BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}') @@ -588,9 +594,10 @@ 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 @@ -600,10 +607,10 @@ one, and for a local or docker case you are on the same filesystem as the worker 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/SKILL.md b/skills/codeman/SKILL.md index d1376225..9aedd574 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,16 @@ 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 + [ -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 @@ -410,10 +420,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 +474,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' @@ -587,7 +597,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/preamble.sh b/skills/codeman/preamble.sh index 43c30afa..a0e95a32 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,16 @@ 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 + [ -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 @@ -309,4 +319,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/endpoints.md b/skills/codeman/reference/endpoints.md index 2dafe613..3edf4f70 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. -- `input` must be single-line (newlines are stripped). 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/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/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index c0020676..14d37133 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -304,10 +304,16 @@ 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 -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. **`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 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: ```bash BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}') @@ -588,9 +594,10 @@ 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 @@ -600,10 +607,10 @@ one, and for a local or docker case you are on the same filesystem as the worker 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/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/cron/cron-service.ts b/src/cron/cron-service.ts index 7a57424c..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 (writeViaMux/Ink constraint). */ +/** 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. */ @@ -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 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) * (every editor writes one) — those are stripped before the check. */ diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index ef5d84f8..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, @@ -1621,7 +1627,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { if (this._state === 'stopped') return; - const prompt = 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 @@ -2826,12 +2832,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 = toSingleLine(this.config.updatePrompt); if (statusBlock?.recommendation) { - // Append RECOMMENDATION to the update prompt (single-line — writeViaMux breaks on newlines) + // Append RECOMMENDATION to the update prompt, on the same single line + // (see toSingleLine) 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/src/session-auto-ops.ts b/src/session-auto-ops.ts index 28f061c7..d2454c65 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/index.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/tmux-manager.ts b/src/tmux-manager.ts index fbcd2733..fc079ae5 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3882,7 +3882,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 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 */ @@ -3895,8 +3900,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; } @@ -3911,7 +3918,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { const hasCarriageReturn = input.includes('\r'); - const textPart = input.replace(/\r/g, '').replace(/\n/g, '').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. @@ -3923,31 +3937,29 @@ 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. 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 - 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; @@ -3957,6 +3969,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) ========== /** @@ -4062,7 +4084,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 b90863cc..c4f035af 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -704,8 +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 (multi-line input breaks Ink, and the server would join the lines). + * 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/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..e29b0bd5 --- /dev/null +++ b/src/utils/single-line.ts @@ -0,0 +1,29 @@ +/** + * @fileoverview Collapse a prompt onto the one line an unattended sender writes. + * + * @module utils/single-line + */ + +/** + * 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 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 { + if (!/[\r\n]/.test(prompt)) return prompt; + return prompt + .split(/[\r\n]+/) + .map((line) => line.trim()) + .filter(Boolean) + .join(' '); +} diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 328d0b73..27a89d38 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 (`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) 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/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 33391135..7ae3213c 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -1432,9 +1432,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 }); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 0c12f797..7d3bca91 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 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. */ diff --git a/test/agent-skill-sendwait.test.ts b/test/agent-skill-sendwait.test.ts new file mode 100644 index 00000000..b38d412f --- /dev/null +++ b/test/agent-skill-sendwait.test.ts @@ -0,0 +1,85 @@ +/** + * @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], { + stdio: ['ignore', 'pipe', 'pipe'], + 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('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'); + }); +}); diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index ba529f18..39eb1d55 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,98 @@ 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('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'); + + // 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'); + 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'); + 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'$/); + }); +}); 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'); }); diff --git a/test/unattended-single-line-prompt.test.ts b/test/unattended-single-line-prompt.test.ts new file mode 100644 index 00000000..a23fe0a1 --- /dev/null +++ b/test/unattended-single-line-prompt.test.ts @@ -0,0 +1,147 @@ +/** + * The senders that run unattended write a prompt as ONE line, whatever the config holds. + * + * 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 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 + * `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 } 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('toSingleLine', () => { + it('joins lines with one space, folding the whitespace around each break', () => { + 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(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', () => { + // 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`); + 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', () => { + 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']); + }); +}); + +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']); + }); +});