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

+8 -6
View File
@@ -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 <id> ""` 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
+6 -6
View File
@@ -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.
*/
+12 -5
View File
@@ -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)}...`);
}
+6 -1
View File
@@ -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,
+45 -21
View File
@@ -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<unknown>) | 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;
+3 -2
View File
@@ -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.
*/
+1
View File
@@ -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 {
+29
View File
@@ -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(' ');
}
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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) {
+4 -3
View File
@@ -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 });
+1 -1
View File
@@ -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. */