fix(sessions): read the composer from a styled capture so a prompt suggestion is not a draft

Claude Code draws its prompt suggestion in an empty composer, the first letter in the reverse-video cursor cell and the rest faint, so a plain capture read it as typed text and keep-warm never nudged those sessions. The keep-warm pane read now uses capture-pane -e and drops the suggestion before checking the composer.

Also from review: an off status carries no window, automation and an unreadable composer retry instead of stopping, a failed evaluation re-arms a retry, a trigger during an evaluation queues one follow-up that remove() cancels, and the cap's clock is seeded from lastSubmitAt for pre-upgrade state.
This commit is contained in:
JD committed 2026-10-19 19:58:14 -04:00
1 parent 5db6c633d3
commit 83d80578e4
13 files changed
+292 -37

No files matched your search

+4 -3
View File
@@ -428,11 +428,12 @@ export interface TerminalMultiplexer extends EventEmitter {
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
/**
* Plain text of the visible frame: no styles, no cursor query, no repaint
* reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle
* Plain text of the visible frame: no cursor query, no repaint
* reconstruction, and no styles unless `styles` asks for them (SGR only, lines
* still `\n`-separated). Deliberately cheaper than `capturePaneBuffer` because idle
* detection calls it on a timer: it only needs to read what the CLI is
* currently rendering, never to replay it into an xterm. Returns null when the
* pane cannot be read.
*/
capturePaneText?(muxName: string, paneTarget?: string): string | null;
capturePaneText?(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null;
}
+42 -6
View File
@@ -134,7 +134,6 @@ export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision {
if ((c.recacheTokensIfCold ?? 0) < KEEP_WARM_MIN_TOKENS) return stop('context too small to be worth a turn');
if (!f.paneAlive) return stop('no live pane');
if (f.limitPaused) return stop('usage-limit pause');
if (f.automationActive) return stop('respawn, Ralph or an agent is driving the session');
if (f.now >= f.budgetAnchorAt + f.maxKeepWarmMs) return stop('keep-warm window after the last prompt has passed');
const dueAt = c.expiresAt - KEEP_WARM_LEAD_MS;
if (f.now < dueAt) return wait(dueAt, 'not due yet');
@@ -143,7 +142,10 @@ export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision {
const retryAt = Math.min(f.now + KEEP_WARM_RETRY_MS, lastChance);
if (!f.idle) return wait(retryAt, 'session is working');
if (f.pendingApproval) return wait(retryAt, 'a dialog is open');
if (f.composerEmpty === null) return stop('cannot read the composer');
// Transient, and nothing else re-triggers an idle session: an agent's long-poll waiter ends
// without a turn, and so does a capture that failed once.
if (f.automationActive) return wait(retryAt, 'respawn, Ralph or an agent is driving the session');
if (f.composerEmpty === null) return wait(retryAt, 'cannot read the composer');
if (!f.composerEmpty) return wait(retryAt, 'the composer holds text');
if (f.lastHumanInputAt && f.now - f.lastHumanInputAt < KEEP_WARM_RECENT_INPUT_MS) {
return wait(retryAt, 'someone typed a moment ago');
@@ -213,6 +215,9 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
private readonly tallies = new Map<string, { nudges: number; lastNudgeAt?: number }>();
/** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */
private readonly inFlight = new Map<string, symbol>();
/** The evaluation running per session, and the one follow-up queued behind it. */
private readonly running = new Map<string, Promise<void>>();
private readonly followUps = new Map<string, Promise<void>>();
/**
* The default and cap, read once and kept: every statusline report schedules an evaluation,
* and reading settings.json on each would cost every install, the feature off included.
@@ -225,9 +230,36 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
schedule(sessionId: string): Promise<void> {
if (this.stopped) return Promise.resolve();
return this.evaluate(sessionId).catch((err) => {
this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
});
const running = this.running.get(sessionId);
if (running) {
// A trigger landing mid-evaluation (a toggle while the nudge is written, a settings change)
// carries what that evaluation read too early, so one follow-up runs after it, shared by
// every trigger that arrives meanwhile.
let next = this.followUps.get(sessionId);
if (!next) {
next = running.then(() => {
// `remove()` drops the queued follow-up: a deleted session gets no evaluation.
if (this.followUps.get(sessionId) !== next) return;
this.followUps.delete(sessionId);
return this.schedule(sessionId);
});
this.followUps.set(sessionId, next);
}
return next;
}
const run = this.evaluate(sessionId)
.catch((err) => {
this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
// A failed settings read or write is transient, and an idle session gets no other
// trigger until its next turn.
// Not after `remove()`, which drops this run: the session is being deleted.
if (!this.stopped && this.running.get(sessionId) === run) this.arm(sessionId, Date.now() + KEEP_WARM_RETRY_MS);
})
.finally(() => {
if (this.running.get(sessionId) === run) this.running.delete(sessionId);
});
this.running.set(sessionId, run);
return run;
}
rescheduleAll(): void {
@@ -239,6 +271,8 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
this.clearTimer(sessionId);
this.tallies.delete(sessionId);
this.inFlight.delete(sessionId);
this.running.delete(sessionId);
this.followUps.delete(sessionId);
}
stop(): void {
@@ -318,7 +352,9 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
idleCompactionOff: session.idleCompactionOptOut,
nudges: tally.nudges,
lastNudgeAt: tally.lastNudgeAt,
budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs,
// Off, the window means nothing, and it moves with every real prompt: carrying it would
// broadcast a change on every turn of every session.
...(enabled && { budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs }),
};
if (decision.action === 'nudge') {
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
+61 -4
View File
@@ -65,26 +65,83 @@ export function promptStillInComposer(screen: string, prompt: string, glyph: str
return head.length > 0 && composer.startsWith(head);
}
/**
* A styled frame as plain text. SGR goes first with its own pattern because tmux writes
* colon sub-parameters (`ESC[4:3m`, a curly underline), which `stripAnsi` leaves behind.
*/
export function plainFrame(styled: string): string {
// eslint-disable-next-line no-control-regex
return stripAnsi(styled.replace(/\x1b\[[0-9;:]*m/g, ''));
}
/**
* `screen` (a `capture-pane -p -e` frame) without the text Claude Code draws in an empty
* composer that nobody typed: its prompt suggestion, drawn faint (SGR 2), and the suggestion's
* first letter, which sits in the reverse-video cursor cell (SGR 7) right before the faint rest.
* A typed draft is never faint, so a cursor cell followed by anything else is kept. Every line
* break is kept, so line `n` here is line `n` of the plain frame, and plain text passes through
* unchanged. Other escapes are left for `stripAnsi`.
*/
function withoutSuggestion(screen: string): string {
let faint = false;
let inverse = false;
let cursorCell = '';
let out = '';
// eslint-disable-next-line no-control-regex
for (const m of screen.matchAll(/\x1b\[([0-9;:]*)m|[\s\S]/gu)) {
if (m[1] !== undefined) {
const params = m[1].split(';');
for (let i = 0; i < params.length; i++) {
const p = params[i];
if (p === '' || p === '0') faint = inverse = false;
else if (p === '2') faint = true;
else if (p === '22') faint = false;
else if (p === '7') inverse = true;
else if (p === '27') inverse = false;
// An extended colour's own arguments (`38;5;n`, `38;2;r;g;b`) are not attributes.
else if (p === '38' || p === '48' || p === '58') i += params[i + 1] === '5' ? 2 : params[i + 1] === '2' ? 4 : 0;
}
continue;
}
const ch = m[0];
if (ch !== '\n' && faint) {
cursorCell = '';
continue;
}
out += cursorCell;
cursorCell = '';
if (ch !== '\n' && inverse) cursorCell = ch;
else out += ch;
}
return out + cursorCell;
}
/**
* Whether the composer holds nothing: the LAST `glyph` line after the glyph, and every
* continuation line below it down to the composer's bottom rule (a draft that opens with a
* newline leaves the glyph line itself blank). Undefined when there is no composer line or no
* rule under it to bound the read (a shell, a dialog, a direct-PTY session): nothing can be
* said. The prompt-cache keep-warm refuses to type unless this is true: `send-keys -l` would
* append its nudge to whatever draft sits there, and the Enter would submit both.
* said. The prompt-cache keep-warm refuses to type unless this is true: its paste would land
* in whatever draft sits there, and the Enter would submit both.
*
* Pass a STYLED frame (`capture-pane -p -e`): Claude Code's prompt suggestion (on by default)
* fills an empty composer with faint text, and only the styling tells it from a typed draft.
* The paste replaces a suggestion rather than joining it (measured on 2.1.300). A plain frame
* still parses, but reads a suggestion as a draft.
*/
export function composerIsEmpty(screen: string, glyph: string): boolean | undefined {
if (!glyph) return undefined;
// The structure is read at column 0, before any trimming: Claude Code draws the glyph line and
// both rules there and indents every continuation line, so a draft line that holds the glyph or
// starts with a rule (` ❯`, ` ───`) can never pass for the composer's own edges.
const lines = stripAnsi(screen).split('\n');
const lines = plainFrame(screen).split('\n');
let at = lines.length - 1;
while (at >= 0 && !lines[at].startsWith(glyph)) at--;
if (at < 0) return undefined;
const end = lines.findIndex((l, i) => i > at && l.startsWith('\u2500'));
if (end < 0) return undefined;
return compact(lines[at].slice(glyph.length) + lines.slice(at + 1, end).join('')).length === 0;
const typed = plainFrame(withoutSuggestion(screen)).split('\n');
return compact(typed[at].slice(glyph.length) + typed.slice(at + 1, end).join('')).length === 0;
}
export interface SubmitVerifierDeps {
+3 -2
View File
@@ -1001,8 +1001,9 @@ export class Session extends EventEmitter {
// response viewer re-derive the live conversation without waiting for the
// user to type again.
this._lastSubmitAt = config.lastSubmitAt ?? 0;
// Restored so a restart neither reopens the keep-warm window nor drops the choice.
this._lastRealPromptAt = config.lastRealPromptAt ?? 0;
// Restored so a restart neither reopens the keep-warm window nor drops the choice. A state.json
// from before the field has only `lastSubmitAt`, and every submit then was a real one.
this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 0;
this._keepWarmEnabled = config.keepWarmEnabled;
// Restored chain: its tail is the conversation the CLI was actually on when
// the server stopped, which outranks the launch id seeded just above. The
+8 -6
View File
@@ -4143,17 +4143,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/**
* Plain visible-frame text for the working/idle probe (see `session.ts`).
*
* One `capture-pane` and nothing else: no `-e` styles, no `display-message`
* cursor query, no repaint reconstruction: this feeds a regex, not a
* terminal. Returns null in tests (no tmux) so callers fall back to their
* stream heuristics rather than reading an empty screen as "not working".
* One `capture-pane` and nothing else: no `display-message` cursor query, no
* repaint reconstruction: this feeds a regex, not a terminal. `styles` adds
* `-e` for a reader that must tell faint text from typed text (the keep-warm
* composer check); lines stay `\n`-separated either way. Returns null in tests
* (no tmux) so callers fall back to their stream heuristics rather than
* reading an empty screen as "not working".
*/
capturePaneText(muxName: string, paneTarget?: string): string | null {
capturePaneText(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null {
if (IS_TEST_MODE) return null;
const target = resolveTmuxPaneTarget(muxName, paneTarget);
if (!target) return null;
try {
return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, {
return execSync(`${this.tmux()} capture-pane -p${opts?.styles ? ' -e' : ''} -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
+4 -4
View File
@@ -773,7 +773,7 @@ function formatKeepWarmStatus(status, now, statusLine) {
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return '';
if (!status || typeof status.state !== 'string') return '';
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const window = Number.isFinite(status.budgetEndsAt)
const keepWarmWindow = Number.isFinite(status.budgetEndsAt)
? status.budgetEndsAt > now
? `stops ${clock(status.budgetEndsAt)}`
: 'window closed'
@@ -794,13 +794,13 @@ function formatKeepWarmStatus(status, now, statusLine) {
break;
}
case 'armed':
parts = [`next ${clock(status.nextCheckAt)}`, window, idleCompaction];
parts = [`next ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
break;
case 'waiting':
parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, window, idleCompaction];
parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
break;
case 'nudged':
parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, window, idleCompaction];
parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, keepWarmWindow, idleCompaction];
break;
default:
parts = ['stopped', status.reason || 'nothing to do', idleCompaction];
+5 -4
View File
@@ -119,7 +119,7 @@ import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js';
import { composerIsEmpty } from '../session-submit-verifier.js';
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
@@ -348,10 +348,11 @@ export class WebServer extends EventEmitter {
readPane: (view) => {
const session = view as Session;
const muxName = session.muxName;
// `capture-pane -p`: the visible frame as plain text.
const text = muxName ? (this.mux.capturePaneText?.(muxName) ?? null) : null;
// `capture-pane -p -e`: the visible frame with its styles, which is how `composerIsEmpty()`
// tells Claude Code's faint prompt suggestion from a typed draft.
const text = muxName ? (this.mux.capturePaneText?.(muxName, undefined, { styles: true }) ?? null) : null;
if (!text) return { composerEmpty: null, limitShown: false };
const limit = detectUsageLimitPause(text);
const limit = detectUsageLimitPause(plainFrame(text));
return {
composerEmpty: composerIsEmpty(text, getCli(session.mode)?.capabilities.workDetect?.promptGlyph ?? '') ?? null,
limitShown: limit !== null && limit.resetAt > Date.now(),