mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user