fix(input): deliver API prompts through tmux so their Enter is not lost

A prompt posted to /api/sessions/:id/input without `useMux` was written
into the pane in one piece. Claude Code (measured on 2.1.283) takes a
`<text>\r` burst of about a hundred characters or more as a paste, so the
trailing `\r` landed as a newline in the composer and the prompt sat there
unsent while the route answered 200. A later raw `\r` did not recover it;
a tmux `send-keys Enter` did. Short prompts submitted, which is why it
looked random. The same stranding was seen with Codex and OpenCode.

A plain prompt (printable text plus exactly one trailing `\r`, detected by
`isPlainPromptInput()`) now goes through `writeViaMux` even without
`useMux`: the text is typed, Enter is pressed as its own key, and the
SubmitVerifier re-presses it while the prompt is still on the composer.
The write is awaited, since the browser's POST fallback sends frames one
at a time and a following keystroke must not overtake the Enter. Raw
frames (escape sequences, bracketed paste, a line feed, a bare `\r`) and
an explicit `useMux: false` keep the direct write.

Verified on an isolated instance: the 239- and 104-character prompts that
stranded (at +1 s, at +50 s on ultracode, and on a warm session) all
submitted on the first Enter with no `useMux`. The phone's local-echo
flow (a burst, then its `\r` as a separate write) was measured unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-28 16:44:59 +02:00
parent 7659ca8b44
commit c2dfc775a3
5 changed files with 155 additions and 3 deletions
+20
View File
@@ -437,6 +437,26 @@ export function parseBody<T>(schema: z.ZodType<T>, body: unknown, errorMessage?:
return result.data;
}
/**
* Whether an input body is a plain prompt: printable text followed by exactly one
* carriage return, and nothing else.
*
* That is the shape a script, a bot or a curl call sends to submit a prompt, and the
* one that must NOT be written into the pane in one piece. Measured on Claude Code
* 2.1.283 (2026-09-28): a direct write of `<text>\r` arrives as a single burst, and a
* burst of about a hundred characters or more is taken as a paste, so its trailing
* `\r` lands as a NEWLINE in the composer and the prompt sits there unsent. A later
* bare `\r` written the same way does not recover it; a tmux `send-keys Enter` does.
* Short bursts (tens of characters) submit, which is why the failure looked random.
*
* Anything with another control character (escape sequences, a bracketed-paste frame,
* a line feed, a tab, C1 controls) is raw terminal input and keeps the direct write.
*/
export function isPlainPromptInput(input: string): boolean {
// eslint-disable-next-line no-control-regex -- matching control characters is the point
return /^[^\x00-\x1f\x7f-\x9f]+\r$/.test(input);
}
/**
* Persist session state and broadcast a SessionUpdated event.
* Replaces the repeated two-line pattern across route handlers.
+20 -1
View File
@@ -91,6 +91,7 @@ import {
findSessionOrFail,
getAuthUser,
isAdmin,
isPlainPromptInput,
isWorkingDirAllowed,
ownerFor,
parseBody,
@@ -1835,10 +1836,18 @@ export function registerSessionRoutes(
// the wrong recovery — wait longer, when the truth is "restart the worker".
let delivered = false;
// A plain prompt (`<text>\r`) goes through the mux even when the caller did not
// ask for it: written straight into the pane it arrives as one burst, and Claude
// Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
// sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter
// separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
// raw write for a caller that really wants it.
const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
if (duplicate) {
// Redelivery of an already-applied input: skip the write, but still honor the
// wait, since the caller's question ("tell me when this settles") is unanswered.
} else if (useMux && waitPromise) {
} else if ((useMux || autoMux) && waitPromise) {
// The response is already staying open for the wait, so the tmux write can be
// awaited here. This is the ONE path where a writeViaMux failure is observable.
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
@@ -1849,6 +1858,16 @@ export function registerSessionRoutes(
delivered = session.write(inputStr, { fromUser: true });
if (!delivered) undoOnFailure();
}
} else if (autoMux) {
// Awaited, unlike the explicit useMux branch below. This shape also reaches here
// from the browser's POST fallback, which sends its frames one at a time and
// waits for each 2xx; answering only once Enter has gone out is what keeps the
// next keystroke from overtaking it.
const ok = await session.writeViaMux(inputStr, { fromUser: true }).catch(() => false);
if (!ok) {
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
if (!session.write(inputStr, { fromUser: true })) undoOnFailure();
}
} else if (useMux) {
// Fire-and-forget: don't block the HTTP response on a tmux child process.
// Fallback to a direct write on failure. Unchanged from before send-and-wait.