mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +02:00
Merge pull request #455
fix(input): make sure a prompt sent through the API actually leaves the composer
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @fileoverview Verify that a programmatically sent prompt actually LEFT the composer,
|
||||
* and press Enter again while it has not.
|
||||
*
|
||||
* Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment its
|
||||
* composer paints but ignores Enter for the first 30 to 50 seconds after it, so the
|
||||
* `send-keys -l <text>` + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
|
||||
* apart leaves the prompt sitting on the composer with `0 tokens`, and every caller
|
||||
* that then waits for the turn (send-and-wait, the agent skill, the maintainer bot,
|
||||
* cron, Ralph) burns its whole timeout on a turn that never started. Measured through
|
||||
* the input route on 2026-09-19: an Enter at 28 s stranded, one at 51 s submitted.
|
||||
*
|
||||
* The rule: after a write that carried a carriage return, read the pane on a short
|
||||
* schedule; while the LAST composer line (the CLI's own prompt glyph) still holds the
|
||||
* head of what was sent, send Enter again. An empty composer ends it, and so does a
|
||||
* composer holding anything else, because that text is the user's or the CLI's, never
|
||||
* ours. A pane with no composer line at all (a shell, a CLI whose glyph is not
|
||||
* declared, a direct-PTY session with no pane to read) does nothing: this runs for
|
||||
* EVERY programmatic sender, so a blind Enter here could confirm a dialog nobody asked
|
||||
* about. The composer is the last glyph line on purpose: Claude Code echoes a submitted
|
||||
* prompt with the same glyph higher up in the transcript, so only the last one says
|
||||
* whether the text was taken.
|
||||
*
|
||||
* Pure apart from the injected capture, send and log, so the schedule, the cap and
|
||||
* every stop condition are unit-tested with fake timers (test/session-submit-verifier.test.ts).
|
||||
*/
|
||||
import { stripAnsi } from './utils/index.js';
|
||||
|
||||
/**
|
||||
* When to look, counted from the write: 2 s catches the common case (taken) with one
|
||||
* capture, and the tail reaches 60 s, past twice the longest window measured. Enter is
|
||||
* re-sent at every check that still finds the prompt, so a 50 s window costs about
|
||||
* seven Enters and one capture each; a taken prompt costs one capture.
|
||||
*/
|
||||
export const SUBMIT_VERIFY_DELAYS_MS: readonly number[] = [
|
||||
2_000, 3_000, 5_000, 5_000, 5_000, 10_000, 10_000, 10_000, 10_000,
|
||||
];
|
||||
|
||||
/** How many leading characters of the prompt have to match, whitespace removed. */
|
||||
const PROMPT_HEAD_CHARS = 24;
|
||||
|
||||
const compact = (s: string): string => s.replace(/\s+/g, '');
|
||||
|
||||
/**
|
||||
* Whether `prompt` is still sitting unsubmitted in the composer of `screen`.
|
||||
*
|
||||
* - `true`: the last `glyph` line holds the prompt's head.
|
||||
* - `false`: the composer is empty (the prompt was taken) or holds other text.
|
||||
* - `undefined`: no composer line at all; nothing can be said, so nothing is sent.
|
||||
*
|
||||
* Whitespace is removed on both sides before comparing, because the composer wraps a
|
||||
* long prompt onto indented continuation lines and Claude Code draws a no-break space
|
||||
* after the glyph; `\s` covers that one in JavaScript.
|
||||
*/
|
||||
export function promptStillInComposer(screen: string, prompt: string, glyph: string): boolean | undefined {
|
||||
if (!glyph) return undefined;
|
||||
const composerLines = stripAnsi(screen)
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith(glyph));
|
||||
if (composerLines.length === 0) return undefined;
|
||||
const composer = compact(composerLines[composerLines.length - 1].slice(glyph.length));
|
||||
if (!composer) return false;
|
||||
const head = compact(prompt).slice(0, PROMPT_HEAD_CHARS);
|
||||
return head.length > 0 && composer.startsWith(head);
|
||||
}
|
||||
|
||||
export interface SubmitVerifierDeps {
|
||||
/** The rendered pane, or null when there is none to read. */
|
||||
capture: () => string | null | undefined;
|
||||
/** Press Enter once. Failures are swallowed; the next check decides again. */
|
||||
sendEnter: () => Promise<unknown> | unknown;
|
||||
/** The CLI's composer glyph, resolved at check time (the registry can change). */
|
||||
glyph: () => string;
|
||||
log?: (message: string) => void;
|
||||
/** Test seam; production uses SUBMIT_VERIFY_DELAYS_MS. */
|
||||
delaysMs?: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One per session. `arm(text)` starts the schedule for the prompt just sent and
|
||||
* cancels any earlier one: a newer write owns the composer now, and re-sending Enter
|
||||
* for an older prompt could submit the newer one early. `cancel()` is for teardown.
|
||||
*/
|
||||
export class SubmitVerifier {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private generation = 0;
|
||||
|
||||
constructor(private readonly deps: SubmitVerifierDeps) {}
|
||||
|
||||
arm(text: string): void {
|
||||
this.cancel();
|
||||
const gen = this.generation;
|
||||
const delays = this.deps.delaysMs ?? SUBMIT_VERIFY_DELAYS_MS;
|
||||
let step = 0;
|
||||
let elapsed = 0;
|
||||
let resent = 0;
|
||||
|
||||
const schedule = (): void => {
|
||||
if (step >= delays.length) return;
|
||||
const delay = delays[step++];
|
||||
elapsed += delay;
|
||||
this.timer = setTimeout(() => void check(), delay);
|
||||
this.timer.unref?.();
|
||||
};
|
||||
const check = async (): Promise<void> => {
|
||||
this.timer = null;
|
||||
if (gen !== this.generation) return;
|
||||
const screen = this.deps.capture();
|
||||
if (promptStillInComposer(screen ?? '', text, this.deps.glyph()) !== true) return;
|
||||
resent++;
|
||||
this.deps.log?.(
|
||||
`prompt still in the composer after ${Math.round(elapsed / 1000)}s, re-sending Enter (${resent}/${delays.length})`
|
||||
);
|
||||
try {
|
||||
await this.deps.sendEnter();
|
||||
} catch {
|
||||
// The next check re-reads the screen and decides again.
|
||||
}
|
||||
if (gen !== this.generation) return;
|
||||
schedule();
|
||||
};
|
||||
schedule();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.generation++;
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -110,6 +110,7 @@ import {
|
||||
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { SubmitVerifier } from './session-submit-verifier.js';
|
||||
import { compileVersionRegex } from './config/cli-registry/patterns.js';
|
||||
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
|
||||
import {
|
||||
@@ -502,6 +503,8 @@ export class Session extends EventEmitter {
|
||||
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
|
||||
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
|
||||
private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below)
|
||||
/** Re-sends Enter while a programmatic prompt still sits in the composer (session-submit-verifier.ts). */
|
||||
private _submitVerifier: SubmitVerifier | null = null;
|
||||
private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan)
|
||||
private _taskTracker: TaskTracker;
|
||||
|
||||
@@ -3190,6 +3193,9 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
private _clearAllTimers(): void {
|
||||
// Stop re-sending Enter for a prompt this session will never take now
|
||||
this._submitVerifier?.cancel();
|
||||
this._submitVerifier = null;
|
||||
// Clear the workspace-trust follow-up read
|
||||
if (this._trustDialogTimer) {
|
||||
clearTimeout(this._trustDialogTimer);
|
||||
@@ -3721,7 +3727,10 @@ export class Session extends EventEmitter {
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
if (this._mux && this._muxSession) {
|
||||
const sent = await this._mux.sendInput(this.id, data);
|
||||
if (sent) this._emitSubmittedPrompt(submittedPrompt);
|
||||
if (sent) {
|
||||
this._emitSubmittedPrompt(submittedPrompt);
|
||||
this._verifySubmitted(data);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
// Fallback to PTY write
|
||||
@@ -3733,6 +3742,28 @@ export class Session extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the composer check for a write that carried Enter (session-submit-verifier.ts):
|
||||
* Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints,
|
||||
* so the pair `sendInput` just sent can leave the text stranded. Only a mux session
|
||||
* can read its pane, only text can be stranded, and the glyph is the CLI's own.
|
||||
*/
|
||||
private _verifySubmitted(data: string): void {
|
||||
if (!data.includes('\r') || !this._mux?.capturePaneText || !this._muxSession) return;
|
||||
const text = data.replace(/[\r\n]/g, '').trimEnd();
|
||||
if (!text) return;
|
||||
this._submitVerifier ??= new SubmitVerifier({
|
||||
capture: () =>
|
||||
this._isStopped || !this._mux || !this._muxSession
|
||||
? null
|
||||
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
||||
sendEnter: () => this._mux?.sendInput(this.id, '\r'),
|
||||
glyph: () => getCli(this.mode)?.capabilities.workDetect?.promptGlyph ?? '❯',
|
||||
log: (m) => console.log(`[Session ${this.id.slice(0, 8)}] ${m}`),
|
||||
});
|
||||
this._submitVerifier.arm(text);
|
||||
}
|
||||
|
||||
/** Current PTY dimensions — used to skip no-op resizes that trigger Ink redraws */
|
||||
private _ptyCols = 120;
|
||||
private _ptyRows = 40;
|
||||
|
||||
Reference in New Issue
Block a user