mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge the input-delivery fix from master
fix(input): deliver API prompts through tmux so their Enter is not lost
This commit is contained in:
@@ -131,7 +131,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
|
||||
- **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 `<text>\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)
|
||||
|
||||
@@ -311,6 +311,15 @@ worker's prompt but never submitted, and the wait then runs its full timeout on
|
||||
turn that never started. Verified live; this is the most common silent failure on
|
||||
this endpoint.
|
||||
|
||||
A **plain prompt** (printable text followed by exactly one `\r`, nothing else) is
|
||||
delivered through tmux even without `useMux`: the text is typed, Enter is pressed as
|
||||
a separate key, and the server re-presses Enter while the prompt is still visibly
|
||||
sitting on the composer. Written straight into the pane in one piece, a prompt of
|
||||
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.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$API/api/v1/sessions/$SID/input" \
|
||||
-H 'Content-Type: application/json' \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Session } from '../../src/session.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { isPlainPromptInput } from '../../src/web/route-helpers.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
|
||||
@@ -155,3 +156,106 @@ describe('POST /api/sessions/:id/input rollback wiring', () => {
|
||||
expect(session.shouldApplyInput('c2', 5)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A plain prompt goes through the mux even when the caller did not say `useMux`.
|
||||
*
|
||||
* Measured on Claude Code 2.1.283: a direct write of `<text>\r` arrives as one burst,
|
||||
* a burst of about a hundred characters is taken as a paste, and its `\r` lands as a
|
||||
* newline in the composer, so a script's prompt sat there unsent while the route
|
||||
* answered 200. The mux path types the text, presses Enter on its own, and arms the
|
||||
* submit verifier.
|
||||
*/
|
||||
describe('POST /api/sessions/:id/input plain-prompt routing', () => {
|
||||
let harness: { app: FastifyInstance; ctx: MockRouteContext };
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
const post = (body: Record<string, unknown>) =>
|
||||
harness.app.inject({ method: 'POST', url: '/api/sessions/test-session-1/input', payload: body });
|
||||
const spies = () => {
|
||||
const session = harness.ctx.sessions.get('test-session-1')!;
|
||||
return { session, viaMux: vi.spyOn(session, 'writeViaMux'), direct: vi.spyOn(session, 'write') };
|
||||
};
|
||||
const LONG_PROMPT =
|
||||
'Reply with only the word ok and nothing else, this sentence is padding to reach about one hundred chars.\r';
|
||||
|
||||
it('sends a prompt with no useMux through the mux, not as one burst', async () => {
|
||||
const { viaMux, direct } = spies();
|
||||
|
||||
const res = await post({ input: LONG_PROMPT });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(viaMux).toHaveBeenCalledWith(LONG_PROMPT, { fromUser: true });
|
||||
expect(direct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers only once the mux write is done, so the next frame cannot overtake it', async () => {
|
||||
// The browser's POST fallback sends frames one at a time and waits for each 2xx;
|
||||
// a fire-and-forget write here would let its next keystroke land before the Enter.
|
||||
const { viaMux } = spies();
|
||||
let finished = false;
|
||||
viaMux.mockImplementation(async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
finished = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
await post({ input: 'ok\r', clientId: 'browser-1', seq: 1 });
|
||||
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the direct write when the mux write fails', async () => {
|
||||
const { viaMux, direct } = spies();
|
||||
viaMux.mockResolvedValue(false);
|
||||
|
||||
await post({ input: 'hello\r' });
|
||||
|
||||
expect(direct).toHaveBeenCalledWith('hello\r', { fromUser: true });
|
||||
});
|
||||
|
||||
it('keeps the raw write for an explicit useMux: false', async () => {
|
||||
const { viaMux, direct } = spies();
|
||||
|
||||
await post({ input: LONG_PROMPT, useMux: false });
|
||||
|
||||
expect(direct).toHaveBeenCalledWith(LONG_PROMPT, { fromUser: true });
|
||||
expect(viaMux).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a bare Enter', '\r'],
|
||||
['text with no Enter', 'hello'],
|
||||
['an arrow key', '\x1b[A'],
|
||||
['a bracketed paste frame', '\x1b[200~line one\nline two\x1b[201~'],
|
||||
['a line feed inside', 'line one\nline two\r'],
|
||||
['two Enters', 'hello\r\r'],
|
||||
['a tab', 'a\tb\r'],
|
||||
])('leaves %s on the direct write', async (_label, input) => {
|
||||
const { viaMux, direct } = spies();
|
||||
|
||||
await post({ input });
|
||||
|
||||
expect(direct).toHaveBeenCalledWith(input, { fromUser: true });
|
||||
expect(viaMux).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlainPromptInput', () => {
|
||||
it('accepts printable text ending in exactly one carriage return', () => {
|
||||
expect(isPlainPromptInput('run the tests\r')).toBe(true);
|
||||
expect(isPlainPromptInput('ünïcødé and emoji 🚀\r')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses anything carrying another control character', () => {
|
||||
for (const input of ['\r', 'x', 'x\n', 'x\r\n', 'x\r\r', '\x1b[Ax\r', 'a\tb\r', 'x\x7f\r', 'x\u009b\r', '\rx']) {
|
||||
expect(isPlainPromptInput(input), JSON.stringify(input)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user