mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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:
1 parent
5db6c633d3
commit
83d80578e4
13 files changed
+292
-37
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user