Merge pull request #670: fix(input): keep the line breaks of a multi-line mux prompt, pasted as one block

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:23:17 +02:00
commit b67018b318
39 files changed
+618 -135

No files matched your search

+6
View File
@@ -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" \
+2 -2
View File
@@ -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.
+4 -3
View File
@@ -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
+13 -11
View File
@@ -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.
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+5 -4
View File
@@ -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:
+8 -5
View File
@@ -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
+4 -3
View File
@@ -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).