Merge pull request #666: feat(sessions): keep an idle session's prompt cache warm with a guarded keep-alive turn

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:31:08 +02:00
commit f80f107445
45 files changed
+3017 -59

No files matched your search

+6 -1
View File
@@ -329,7 +329,12 @@ const CLAUDE: CliEntry = {
'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
'CLAUDE_CONFIG_DIR',
],
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
// idleCompactionSetting: the `idleCompaction` settings key first ships in 2.1.290 (2.1.289
// already idle-compacts with no opt-out); see TmuxManager._resolveIdleCompactionOptOut.
gates: {
nameFlag: { minVersion: '2.1.224', failClosed: true },
idleCompactionSetting: { minVersion: '2.1.290', failClosed: true },
},
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
mcpConfig: {
path: '.claude.json',
+36
View File
@@ -41,6 +41,7 @@ import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js';
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
import { isNearStalledPath, probePath } from './utils/index.js';
import { DEFAULT_KEEP_WARM_MAX_HOURS, type KeepWarmSettings } from './prompt-cache-keepwarm.js';
/**
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
@@ -1144,6 +1145,41 @@ export async function readPinCliSessionNameEnabled(): Promise<boolean> {
return settings.pinCliSessionName === true;
}
/**
* The prompt-cache keep-warm's app-wide default (`promptCacheKeepWarm`, absent = OFF) and its
* cap (`promptCacheKeepWarmMaxHours`, hours after the last real prompt). Read FRESH like
* {@link readPinCliSessionNameEnabled} at every claude create and respawn, which decides the
* launch's `idleCompaction` opt-out.
*/
export async function readKeepWarmSettings(): Promise<KeepWarmSettings> {
return keepWarmSettingsFrom(await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}));
}
/**
* The same two keys for the keep-warm service, which keeps what it reads until the next
* settings change. A missing file is the defaults, but any other read or parse failure rejects
* rather than falling back: settings.json is rewritten in place, so a read can land mid-write,
* and a fallback kept from it would hold keep-warm off until the next keep-warm settings change.
*/
export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> {
let text: string;
try {
text = await readFile(SETTINGS_PATH, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({});
throw err;
}
return keepWarmSettingsFrom(JSON.parse(text) as Record<string, unknown>);
}
function keepWarmSettingsFrom(settings: Record<string, unknown>): KeepWarmSettings {
const hours = settings?.promptCacheKeepWarmMaxHours;
return {
keepWarmDefault: settings?.promptCacheKeepWarm === true,
maxHours: typeof hours === 'number' && Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_KEEP_WARM_MAX_HOURS,
};
}
/**
* Resolve the statusLine command to pass as an EPHEMERAL `claude --settings`
* CLI flag for this one process (see buildSpawnCommandFromRegistry in
+4
View File
@@ -24,6 +24,7 @@ import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from './config/instance.js';
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
import type { IntentProfile, IntentPromptEntry } from './types/index.js';
// ========== Limits ==========
@@ -62,6 +63,9 @@ export function isCapturablePrompt(text: string): boolean {
if (text.startsWith('<system-reminder>')) return false;
if (text.startsWith('Caveat: The messages below')) return false;
if (text.startsWith('[Request interrupted')) return false;
// Codeman's own prompt-cache keep-alive turn is not something the user meant. The exact
// text, so a prompt of the user's that merely mentions it is still captured.
if (text.trim() === KEEP_WARM_NUDGE) return false;
return true;
}
+17 -3
View File
@@ -61,6 +61,11 @@ export interface MuxSession {
ralphEnabled?: boolean;
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
statusLine?: StatusLineExporter;
/**
* Whether the current launch carries `idleCompaction: false` in its `--settings` JSON (keep-warm
* on at launch, the exporter injected, Claude Code new enough); set on every create and respawn.
*/
idleCompactionOff?: boolean;
/**
* This record was rebuilt from the tmux socket rather than from Codeman's own
* bookkeeping, so everything on it but the name, the pid and, when tmux
@@ -124,6 +129,10 @@ export interface CreateSessionOptions {
effort?: EffortLevel;
/** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */
advisorModel?: string;
/** The session's own prompt-cache keep-warm choice (undefined = app default); decides the `idleCompaction` opt-out. */
keepWarm?: boolean;
/** Spawned by another session: it does not inherit the keep-warm app default (`keepWarmOn`). */
spawnedByAgent?: boolean;
/** tmux history-limit (scrollback lines) allocated when this session is created. */
historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */
@@ -177,6 +186,10 @@ export interface RespawnPaneOptions {
effort?: EffortLevel;
/** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */
advisorModel?: string;
/** The session's own prompt-cache keep-warm choice, re-read at every relaunch (see CreateSessionOptions). */
keepWarm?: boolean;
/** Spawned by another session (see CreateSessionOptions). */
spawnedByAgent?: boolean;
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
historyLimit?: number;
/** Remote execution metadata for local tmux sessions wrapping SSH */
@@ -416,11 +429,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;
}
+438
View File
@@ -0,0 +1,438 @@
/**
* @fileoverview Prompt-cache keep-warm: a one-line keep-alive turn shortly before an idle
* claude session's prompt cache would expire, so the next real prompt reads the conversation
* from cache instead of re-processing it at the cache-write rate.
*
* Why it exists: Claude Code gives a subscription's main conversation a one-hour cache and
* lets it expire after an hour idle. Measured on real sessions, nearly every gap over an
* hour went cold and the first turn back re-wrote the whole context at 2x; a keep-alive turn
* costs a cache read plus a one-word reply, a small fraction of the miss it prevents, and the
* payoff is concentrated in gaps of one to four hours, which is why there is a cap after the
* last real prompt rather than an indefinite loop.
*
* Codeman owns the idle policy for these sessions (Discussion #554): a launch with keep-warm
* on also carries `idleCompaction: false` in its one `--settings` JSON, so Claude Code's own
* idle compaction (which fires at 90% of the TTL, a minute before the nudge) cannot replace
* the conversation with a summary first. That is decided once per launch in
* `TmuxManager._resolveIdleCompactionOptOut()` and recorded on the mux record; a session
* switched on after launch is kept warm best-effort and says so.
*
* Where the facts come from: `Session.promptCache` (the statusline's `prompt_cache` object,
* see usage-telemetry.ts) carries `expires_at`, the TTL and the tokens at stake, so nothing
* here infers the lifetime from idle detection or the plan tier.
*
* Shape: `decideKeepWarm()` is the pure rulebook (every guard in one place, tested as a
* table); `PromptCacheKeepWarmService` owns the per-session timer and the ports it needs
* from the server. It never touches the session except through `beginKeepAliveTurn()` and
* `writeViaMux()`: the window `beginKeepAliveTurn()` opens is what keeps the nudge off the
* real-prompt clock and opens its stop hook and idle prompt pre-acknowledged
* (hook-event-routes.ts). The nudge is still a submit, so it moves `lastSubmitAt`.
*
* @module prompt-cache-keepwarm
*/
import type { KeepWarmStatus, PromptCacheStatus, StatusLineExporter } from './types/index.js';
/** The nudge. Under 100 chars on purpose: a longer burst reads as a paste to Claude Code. */
export const KEEP_WARM_NUDGE = 'Codeman cache keep-alive: no action needed, reply with just "ok".';
/** Nudge this long before `expires_at`. The nudge's own request is what refreshes the timer. */
export const KEEP_WARM_LEAD_MS = 5 * 60_000;
/** Re-check cadence while a transient guard (working, dialog, draft in the composer) defers. */
export const KEEP_WARM_RETRY_MS = 60_000;
/** Give up this close to expiry: a turn takes seconds to land and the Enter may be re-pressed. */
export const KEEP_WARM_LAST_CHANCE_MS = 60_000;
/** Below this many tokens at stake a cold rewrite is cheap and a turn is not worth it. */
export const KEEP_WARM_MIN_TOKENS = 20_000;
/** A person typed this recently: they may be about to submit, which refreshes the cache for free. */
export const KEEP_WARM_RECENT_INPUT_MS = 3 * 60_000;
/**
* Ceiling on the keep-alive window. The window normally closes on the turn's own idle prompt
* (Claude Code sends it about a minute after the turn ends, so a slow turn must not outlast a
* short fixed window) or earlier, at any other submit; the ceiling covers a turn whose idle
* prompt never arrives, such as a workspace without Codeman's hooks.
*/
export const KEEP_WARM_TURN_WINDOW_MS = 10 * 60_000;
/** Default cap: hours after the last real prompt during which nudges are sent. */
export const DEFAULT_KEEP_WARM_MAX_HOURS = 2;
/** Longest delay `setTimeout` honours; Node fires anything longer after 1 ms. */
const MAX_TIMER_MS = 2 ** 31 - 1;
/**
* Whether keep-warm is on for a session: its own choice, else the app-wide default. A session
* an agent spawned (`parentSessionId`) does not inherit the default: a worker that finished
* its task is idle exactly like one that will be prompted again, and most never are, so only
* its own switch keeps it warm. One function because the launch (the `idleCompaction`
* opt-out) and the service must agree.
*/
export function keepWarmOn(choice: boolean | undefined, appDefault: boolean, spawnedByAgent: boolean): boolean {
return choice ?? (appDefault && !spawnedByAgent);
}
export interface KeepWarmFacts {
now: number;
/** The session's own choice; undefined = the app-wide default applies. */
choice: boolean | undefined;
appDefault: boolean;
/** Spawned by another session (`parentSessionId`); see `keepWarmOn`. */
spawnedByAgent: boolean;
/** Whether this launch carries the statusLine exporter; undefined when unknown. */
statusLine?: StatusLineExporter;
cache: PromptCacheStatus | null | undefined;
/** Idle by Codeman's own probe and not mid-turn. */
idle: boolean;
/** A pid and no recorded agent exit (`paneExit`: an exited agent's pane keeps its pid). */
paneAlive: boolean;
/** Auto-resume's pause, or a usage-limit message on screen whose reset is still ahead. */
limitPaused: boolean;
/** Respawn running, a Ralph loop active, a task assigned, or an agent long-polling the session. */
automationActive: boolean;
/** A permission, question or elicitation dialog is open (never the idle item). */
pendingApproval: boolean;
/** The composer line holds no text; null when the pane could not be read. */
composerEmpty: boolean | null;
/** Last keystroke a person sent, epoch ms; 0 when none seen. */
lastHumanInputAt: number;
/** Start of the keep-warm window: the last real (non-keep-alive) prompt, else the session start. */
budgetAnchorAt: number;
maxKeepWarmMs: number;
}
export type KeepWarmDecision =
| { action: 'nudge' }
| { action: 'wait'; until: number; reason: string }
| { action: 'stop'; reason: string };
const stop = (reason: string): KeepWarmDecision => ({ action: 'stop', reason });
const wait = (until: number, reason: string): KeepWarmDecision => ({ action: 'wait', until, reason });
/**
* The rulebook. Hard stops first (nothing to do until the next cache report or a flag
* change), then the schedule, then the transient guards that defer by `KEEP_WARM_RETRY_MS`
* and never past the last-chance line. Pure: `now` is passed in.
*/
export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision {
if (!keepWarmOn(f.choice, f.appDefault, f.spawnedByAgent)) {
return stop(
f.choice === undefined && f.appDefault
? 'an agent-spawned session follows the app-wide default only when switched on for itself'
: 'off'
);
}
// Only a launch that carries the exporter ever reports `prompt_cache`; the readout names the
// cause from `statusLineExporter`. This is also what stops a CLI without cache telemetry.
if (f.statusLine !== 'injected') return stop(`no cache reports on this launch (${f.statusLine ?? 'unknown'})`);
const c = f.cache;
if (!c) return stop('no cache report yet');
if (!c.warm || !c.expiresAt) return stop('cache is cold; the next prompt rebuilds it');
// Compacted this gap (`/compact`, or Claude Code's own idle compaction on a launch without
// the opt-out): the full context is already gone, so keeping the summary warm buys little.
// The stamp is dropped at the next request, which re-opens the next gap.
if (c.compactedAt != null) return stop('the context was compacted; resumes after the next prompt');
if (c.ttl === '5m') {
return stop('5-minute cache lifetime; a nudge every four minutes costs more than the miss it prevents');
}
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.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');
const lastChance = c.expiresAt - KEEP_WARM_LAST_CHANCE_MS;
if (f.now >= lastChance) return stop('too close to expiry to land a turn');
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');
// 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');
}
return { action: 'nudge' };
}
/**
* Whether a session could be nudged once the settings are known: its own switch is not off, its
* launch reports the cache, and the last report is warm. Only such a session has anything to
* lose while the settings cannot be read, so only it retries on its own.
*/
function couldNudge(s: KeepWarmSessionView): boolean {
return (
s.keepWarmEnabled !== false &&
s.statusLineExporter === 'injected' &&
!!s.promptCache?.warm &&
!!s.promptCache.expiresAt
);
}
/** What the service needs from a session; `Session` satisfies it structurally. */
export interface KeepWarmSessionView {
readonly id: string;
readonly createdAt: number;
readonly pid: number | null;
/** Set when the agent exited in a pane that stays open (`remain-on-exit`); the pid stays. */
readonly paneExit?: unknown;
readonly parentSessionId?: string;
readonly promptCache: PromptCacheStatus | null;
readonly statusLineExporter?: StatusLineExporter;
/** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */
readonly idleCompactionOptOut: boolean;
readonly keepWarmEnabled: boolean | undefined;
readonly isWorking: boolean;
readonly isLimitPaused: boolean;
readonly lastHumanInputAt: number;
/** Last submit that was not the keep-alive's own (persisted): the cap's clock. */
readonly lastRealPromptAt: number;
isIdle(): boolean;
beginKeepAliveTurn(windowMs: number): void;
/** Close the window `beginKeepAliveTurn()` opened, when the nudge never went out. */
endKeepAliveTurn(): void;
writeViaMux(data: string): Promise<boolean>;
}
export interface KeepWarmSettings {
/** App-wide default for sessions that have not chosen (`promptCacheKeepWarm`). */
keepWarmDefault: boolean;
/** Cap in hours after the last real prompt (`promptCacheKeepWarmMaxHours`). */
maxHours: number;
}
export interface KeepWarmPorts {
getSession(id: string): KeepWarmSessionView | undefined;
sessionIds(): Iterable<string>;
readSettings(): Promise<KeepWarmSettings>;
isAutomationActive(id: string): boolean;
hasPendingApproval(id: string): boolean;
/**
* Read the pane once a nudge is otherwise due: whether the composer is empty (null when it
* cannot be read), and whether a usage-limit message with a reset still ahead is on screen
* (auto-resume's own pause tracking only runs while auto-resume is on).
*/
readPane(session: KeepWarmSessionView): { composerEmpty: boolean | null; limitShown: boolean };
/** Store and broadcast the status (the server dedupes against the session's last one). */
publish(id: string, status: KeepWarmStatus): void;
log?(message: string): void;
}
/** The scheduler the routes see: one method per trigger, nothing else. */
export interface KeepWarmScheduler {
/** Re-evaluate a session: after a cache report, a flag change, or a settings change. Resolves once the status is published. */
schedule(sessionId: string): Promise<void>;
/** Re-read the app-wide default and cap, then re-evaluate every session (either one changed). */
rescheduleAll(): void;
/** The session is gone. */
remove(sessionId: string): void;
}
export class PromptCacheKeepWarmService implements KeepWarmScheduler {
private readonly timers = new Map<string, NodeJS.Timeout>();
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.
* `rescheduleAll()` drops it, and the settings PUT calls that whenever either key changes.
*/
private settings: Promise<KeepWarmSettings> | undefined;
/** The last settings read failure logged, so one that persists is logged once; cleared by a good read. */
private settingsFailure: string | undefined;
private stopped = false;
constructor(private readonly ports: KeepWarmPorts) {}
schedule(sessionId: string): Promise<void> {
if (this.stopped) return Promise.resolve();
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 write or pane read is transient, and an idle session gets no other trigger
// until its next turn. (A failed settings read is handled in evaluate().)
// 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 {
this.settings = undefined;
for (const id of this.ports.sessionIds()) void this.schedule(id);
}
remove(sessionId: string): void {
this.clearTimer(sessionId);
this.tallies.delete(sessionId);
this.inFlight.delete(sessionId);
this.running.delete(sessionId);
this.followUps.delete(sessionId);
}
stop(): void {
this.stopped = true;
for (const id of [...this.timers.keys()]) this.clearTimer(id);
}
private readSettings(): Promise<KeepWarmSettings> {
if (!this.settings) {
const read = this.ports.readSettings();
this.settings = read;
// A failed read is not kept: the next evaluation tries again.
read.catch(() => {
if (this.settings === read) this.settings = undefined;
});
}
return this.settings;
}
private clearTimer(id: string): void {
const t = this.timers.get(id);
if (t) clearTimeout(t);
this.timers.delete(id);
}
private arm(id: string, at: number): void {
this.clearTimer(id);
const t = setTimeout(() => void this.schedule(id), Math.min(Math.max(0, at - Date.now()), MAX_TIMER_MS));
t.unref?.();
this.timers.set(id, t);
}
private async evaluate(id: string): Promise<void> {
if (this.stopped || this.inFlight.has(id)) return;
const token = Symbol(id);
this.inFlight.set(id, token);
// A session removed (or the service stopped) during an await gets nothing after it: no
// nudge, no tally, no timer, no status.
const cancelled = () => this.stopped || this.inFlight.get(id) !== token;
try {
this.clearTimer(id);
const session = this.ports.getSession(id);
if (!session) return;
let settings: KeepWarmSettings;
try {
settings = await this.readSettings();
} catch (err) {
// A read that lands mid-write is retried. A settings.json that stays broken (a hand edit
// with a trailing comma) fails every read until it is fixed, so it is logged once rather
// than once a minute per session, and only a session that could nudge retries.
const message = err instanceof Error ? err.message : String(err);
if (message !== this.settingsFailure) {
this.settingsFailure = message;
this.ports.log?.(`[KeepWarm] ${id.slice(0, 8)}: cannot read the settings: ${message}`);
}
if (!cancelled() && couldNudge(session)) this.arm(id, Date.now() + KEEP_WARM_RETRY_MS);
return;
}
this.settingsFailure = undefined;
if (cancelled()) return;
const tally = this.tallies.get(id) ?? { nudges: 0 };
const facts: KeepWarmFacts = {
now: Date.now(),
choice: session.keepWarmEnabled,
appDefault: settings.keepWarmDefault,
spawnedByAgent: !!session.parentSessionId,
statusLine: session.statusLineExporter,
cache: session.promptCache,
idle: session.isIdle() && !session.isWorking,
paneAlive: session.pid !== null && !session.paneExit,
limitPaused: session.isLimitPaused,
automationActive: this.ports.isAutomationActive(id),
pendingApproval: this.ports.hasPendingApproval(id),
// Optimistic until everything else says "due": the pane read is the one costly fact.
composerEmpty: true,
lastHumanInputAt: session.lastHumanInputAt,
budgetAnchorAt: session.lastRealPromptAt || session.createdAt,
maxKeepWarmMs: settings.maxHours * 3_600_000,
};
let decision = decideKeepWarm(facts);
if (decision.action === 'nudge') {
const pane = this.ports.readPane(session);
decision = decideKeepWarm({
...facts,
composerEmpty: pane.composerEmpty,
limitPaused: facts.limitPaused || pane.limitShown,
});
}
const enabled = keepWarmOn(facts.choice, facts.appDefault, facts.spawnedByAgent);
const base = {
enabled,
idleCompactionOff: session.idleCompactionOptOut,
nudges: tally.nudges,
lastNudgeAt: tally.lastNudgeAt,
// 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);
const ok = await session.writeViaMux(`${KEEP_WARM_NUDGE}\r`);
if (cancelled()) return;
if (ok) {
tally.nudges += 1;
tally.lastNudgeAt = Date.now();
this.tallies.set(id, tally);
}
this.ports.log?.(
`[KeepWarm] ${ok ? 'nudged' : 'failed to nudge'} ${id.slice(0, 8)} with ${facts.cache?.recacheTokensIfCold ?? '?'} tokens at stake (nudge ${tally.nudges})`
);
// Sent: the nudge's own cache report re-schedules, and this timer is the fallback if none
// arrives. Not sent: no turn is running, so the window closes now rather than at its
// ceiling, and the retry lands inside the last minutes before the expiry, where a nudge
// can still go out (a lead time later would be past it).
let nextCheckAt = Date.now() + KEEP_WARM_LEAD_MS;
if (!ok) {
session.endKeepAliveTurn();
const expiresAt = facts.cache?.expiresAt ?? 0;
nextCheckAt = Math.min(Date.now() + KEEP_WARM_RETRY_MS, expiresAt - KEEP_WARM_LAST_CHANCE_MS);
}
this.arm(id, nextCheckAt);
this.ports.publish(id, {
...base,
nudges: tally.nudges,
lastNudgeAt: tally.lastNudgeAt,
state: ok ? 'nudged' : 'waiting',
nextCheckAt,
reason: ok ? undefined : 'the keep-alive could not be written',
});
} else if (decision.action === 'wait') {
this.arm(id, decision.until);
this.ports.publish(id, {
...base,
state: decision.reason === 'not due yet' ? 'armed' : 'waiting',
nextCheckAt: decision.until,
reason: decision.reason === 'not due yet' ? undefined : decision.reason,
});
} else {
this.ports.publish(id, { ...base, state: enabled ? 'stopped' : 'off', reason: decision.reason });
}
} finally {
if (this.inFlight.get(id) === token) this.inFlight.delete(id);
}
}
}
+29 -3
View File
@@ -68,6 +68,12 @@ export interface SpawnBridgeOptions {
* rendered as two independent params.
*/
statusLineCommand?: string;
/**
* Add `idleCompaction: false` to the same `--settings` JSON: Codeman's prompt-cache keep-warm
* owns the idle policy for this launch. Decided (keep-warm on, exporter injected, version gate
* passed) by TmuxManager._resolveIdleCompactionOptOut, which records it on the mux record.
*/
idleCompactionOff?: boolean;
}
/**
@@ -168,6 +174,20 @@ function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null
return passed;
}
/**
* Whether `mode`'s `capabilities.gates[gate]` passes, through the same fail-closed resolution
* the launch template uses (an unknown version passes no gate). For a launch-time decision that
* is also recorded, like the `idleCompaction` opt-out. `claudeCliVersion` as in
* SpawnBridgeOptions: undefined probes the local CLI, null means unknown.
*/
export function launchGatePasses(mode: string, gate: string, claudeCliVersion?: string | null): boolean {
const entry = getCli(mode);
if (!entry?.capabilities.gates[gate]) return false;
return resolveGatesPassed(entry, () =>
claudeCliVersion !== undefined ? claudeCliVersion : getClaudeCliVersion()
).has(gate);
}
/**
* Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a
* `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall
@@ -208,10 +228,15 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
// rendering them as independent params would let the last one silently win. Claude-only in
// practice: only claude's launch template renders this engine value, so another CLI's
// session carrying an advisorModel launches exactly as before.
// Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor
// byte-identical to one from before the advisor existed.
// Key order (ultracode, advisorModel, statusLine, idleCompaction) keeps a launch without an
// advisor or keep-warm byte-identical to one from before either existed.
const advisorSettings = buildAdvisorSettings(options.advisorModel);
if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) {
if (
(effortFlag === '--settings' && effortValue) ||
advisorSettings.advisorModel ||
options.statusLineCommand ||
options.idleCompactionOff
) {
const settingsObj: Record<string, unknown> = {
...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}),
...advisorSettings,
@@ -219,6 +244,7 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
if (options.statusLineCommand) {
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
}
if (options.idleCompactionOff) settingsObj.idleCompaction = false;
engineValues.effortSettingsJson = JSON.stringify(settingsObj);
}
+79
View File
@@ -65,6 +65,85 @@ 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: 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 = 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;
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 {
/** The rendered pane, or null when there is none to read. */
capture: () => string | null | undefined;
+126 -6
View File
@@ -65,6 +65,8 @@ import {
type SessionWriteOptions,
type PaneExit,
type PromptCacheStatus,
type StatusLineExporter,
type KeepWarmStatus,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
@@ -127,6 +129,7 @@ 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 { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
import { compileVersionRegex } from './config/cli-registry/patterns.js';
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
import {
@@ -453,6 +456,15 @@ export interface ClaudeMessage {
duration_ms?: number;
}
/**
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
*/
function isKeepAliveWrite(data: string): boolean {
const text = data.replace(/[\r\n]/g, '').trim();
return text === '' || text === KEEP_WARM_NUDGE;
}
/**
* Event signatures emitted by the Session class.
* Subscribe using `session.on('eventName', handler)`.
@@ -597,6 +609,16 @@ export class Session extends EventEmitter {
private _watching: string | null = null;
/** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */
private _promptCache: PromptCacheStatus | null = null;
/** Prompt-cache keep-warm opt-in; undefined = follow the app-wide default. Persisted. */
private _keepWarmEnabled: boolean | undefined;
/** Last status the keep-warm service published for this session; ephemeral. */
private _keepWarm: KeepWarmStatus | null = null;
/** Until when the current turn counts as Codeman's own keep-alive (hooks open pre-acknowledged). */
private _keepAliveTurnUntil = 0;
/** Last keystroke a person sent through a `fromUser` write. */
private _lastHumanInputAt = 0;
/** Last submit that was not the keep-alive's own; the keep-warm cap's clock. Persisted. */
private _lastRealPromptAt = 0;
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
private _workingLineRe: RegExp | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
@@ -804,7 +826,7 @@ export class Session extends EventEmitter {
// The session that spawned this one (tab lineage lines). Resolved by the create
// route before it reaches here, so this is always either an id that existed at
// create time or undefined. Decoration only — see SessionState.parentSessionId.
// create time or undefined. Decoration only (plus the keep-warm default); see SessionState.parentSessionId.
private readonly _parentSessionId?: string;
// Session color for visual differentiation
@@ -899,6 +921,10 @@ export class Session extends EventEmitter {
attachmentHistory?: SessionAttachmentHistoryItem[];
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
lastSubmitAt?: number;
/** Restored last non-keep-alive submit (see `lastRealPromptAt`). */
lastRealPromptAt?: number;
/** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */
keepWarmEnabled?: boolean;
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
claudeSessionChain?: string[];
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
@@ -975,6 +1001,10 @@ 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. 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
// FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a
@@ -1479,12 +1509,17 @@ export class Session extends EventEmitter {
* report. It rides `toState()` for the Session Options readout and is restored on a
* Codeman restart (constructor `promptCache`) so the row is not blank until the pane's
* next render; the readout is time-aware, so a value whose `expiresAt` has passed renders
* as cold rather than as a stale warm.
* as cold rather than as a stale warm, and a future one lets the keep-warm re-arm without a turn.
*/
get promptCache(): PromptCacheStatus | null {
return this._promptCache;
}
/** Whether the current launch carries the statusLine exporter (`MuxSession.statusLine`); undefined when unknown. */
get statusLineExporter(): StatusLineExporter | undefined {
return this._muxSession?.statusLine;
}
/**
* Replace the prompt-cache report; true when it differs from the previous one. Stamps
* `compactedAt` when this report ENTERS the window in which Claude Code reads
@@ -1513,6 +1548,66 @@ export class Session extends EventEmitter {
return changed;
}
/** Prompt-cache keep-warm opt-in; undefined means the app-wide default applies. */
get keepWarmEnabled(): boolean | undefined {
return this._keepWarmEnabled;
}
setKeepWarmEnabled(enabled: boolean | undefined): void {
this._keepWarmEnabled = enabled;
}
/** What the keep-warm service last published for this session, or null before its first evaluation. */
get keepWarm(): KeepWarmStatus | null {
return this._keepWarm;
}
/** Replace the keep-warm status; true when it differs from the previous one. */
setKeepWarmStatus(status: KeepWarmStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this._keepWarm);
this._keepWarm = status;
return changed;
}
/**
* True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle
* prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the
* response viewer's conversation correlation needs it) but not `lastRealPromptAt`. The turn's
* own idle prompt ends it (`endKeepAliveTurn()`), and so does any other submit: a `fromUser`
* write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook for
* another prompt, so a prompt right after a nudge counts and alerts normally.
*/
get keepAliveTurnActive(): boolean {
return Date.now() < this._keepAliveTurnUntil;
}
beginKeepAliveTurn(windowMs: number): void {
this._keepAliveTurnUntil = Date.now() + windowMs;
}
endKeepAliveTurn(): void {
this._keepAliveTurnUntil = 0;
}
/** Last keystroke a person sent (`fromUser` write), epoch ms; 0 when none this process. */
get lastHumanInputAt(): number {
return this._lastHumanInputAt;
}
/**
* Last submit that was not the keep-alive's own, from any writer or the UserPromptSubmit hook
* (a person typing in an attached tmux client reaches Codeman only through the hook); epoch
* ms, 0 if none. The keep-warm cap counts from it; persisted so a restart keeps it.
*/
get lastRealPromptAt(): number {
return this._lastRealPromptAt;
}
/** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */
get idleCompactionOptOut(): boolean {
return this._muxSession?.idleCompactionOff === true;
}
/**
* Check if the session's process tree has active child processes beyond Claude itself.
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
@@ -2008,7 +2103,9 @@ export class Session extends EventEmitter {
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
promptCache: this._promptCache ?? undefined,
statusLineExporter: this._muxSession?.statusLine,
statusLineExporter: this.statusLineExporter,
keepWarmEnabled: this._keepWarmEnabled,
keepWarm: this._keepWarm ?? undefined,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
@@ -2057,6 +2154,7 @@ export class Session extends EventEmitter {
paneExit: this._paneExit ?? undefined,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
lastSubmitAt: this._lastSubmitAt || undefined,
lastRealPromptAt: this._lastRealPromptAt || undefined,
// Only a chain the CLI's own hooks vouched for is persisted, and only when
// the pane actually moved conversation. Its LAST entry is the live one, so
// it is also what restores `claudeSessionId` across a restart — `start()`
@@ -2425,6 +2523,8 @@ export class Session extends EventEmitter {
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
effort: this._effort,
advisorModel: this._advisorModel,
keepWarm: this._keepWarmEnabled,
spawnedByAgent: !!this._parentSessionId,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
@@ -2904,6 +3004,8 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides,
effort: this._effort,
advisorModel: this._advisorModel,
keepWarm: this._keepWarmEnabled,
spawnedByAgent: !!this._parentSessionId,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
@@ -4650,9 +4752,16 @@ export class Session extends EventEmitter {
*/
private _trackSubmit(data: string, options: SessionWriteOptions): string[] {
const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : [];
if (data.includes('\r') || data.includes('\n')) {
this._lastSubmitAt = Date.now();
const hasEnter = data.includes('\r') || data.includes('\n');
if (options.fromUser) {
this._lastHumanInputAt = Date.now();
// A person is here: whatever turn follows is theirs, not the keep-alive's.
this._keepAliveTurnUntil = 0;
} else if (hasEnter && !isKeepAliveWrite(data)) {
// Anything else submitted (an agent, respawn, auto-resume) starts a turn of its own.
this._keepAliveTurnUntil = 0;
}
if (hasEnter) this._stampSubmit();
return submitted;
}
@@ -4678,9 +4787,20 @@ export class Session extends EventEmitter {
* `_trackSubmit` only sees input that flows through Codeman's write path, so
* a pane the user drives by attaching to tmux directly never stamped this and
* `lastSubmitAt` stayed 0 for its whole life.
*
* `prompt` is the hook's own prompt text: anything but the keep-alive's ends the keep-alive
* window, because a person typing in an attached tmux client reaches Codeman only here, and
* their prompt must count as real and alert normally.
*/
markPromptSubmitted(): void {
markPromptSubmitted(prompt?: string): void {
if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0;
this._stampSubmit();
}
/** Every submit moves `lastSubmitAt`; only one outside a keep-alive turn moves `lastRealPromptAt`. */
private _stampSubmit(): void {
this._lastSubmitAt = Date.now();
if (!this.keepAliveTurnActive) this._lastRealPromptAt = this._lastSubmitAt;
}
/**
+68 -7
View File
@@ -67,12 +67,15 @@ import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
import {
buildSpawnCommandFromRegistry,
configSetenvValues,
launchGatePasses,
legacyConfigForMode,
} from './session-cli-registry-bridge.js';
import { keepWarmOn } from './prompt-cache-keepwarm.js';
import type { CliEntry } from './config/cli-registry/types.js';
import {
resolveStatusLineCliCommand,
readPlanUsageTelemetryEnabled,
readKeepWarmSettings,
findEffectiveUserStatusLineCommand,
} from './hooks-config.js';
import {
@@ -1095,6 +1098,28 @@ export function buildCodexCommand(config?: CodexConfig): string {
return buildSpawnCommandFromRegistry(entry, { mode: 'codex', sessionId: '', codexConfig: config }) ?? 'codex';
}
/**
* Whether a launch carries `idleCompaction: false` in its one `--settings` JSON: keep-warm on
* at launch (`keepWarmOn`), the statusLine exporter injected (without its `prompt_cache` reports
* keep-warm cannot run, and the opt-out alone would only cost the user Claude Code's own idle
* compaction), and the CLI's `idleCompactionSetting` gate passed, fail-closed like `--name`:
* an unknown version means no key. Pure; `claudeCliVersion` as in buildSpawnCommand.
*/
export function decideIdleCompactionOptOut(o: {
mode: SessionMode;
statusLine: StatusLineExporter | undefined;
keepWarm: boolean | undefined;
keepWarmDefault: boolean;
spawnedByAgent: boolean | undefined;
claudeCliVersion?: string | null;
}): boolean {
return (
o.statusLine === 'injected' &&
keepWarmOn(o.keepWarm, o.keepWarmDefault, !!o.spawnedByAgent) &&
launchGatePasses(o.mode, 'idleCompactionSetting', o.claudeCliVersion)
);
}
export function buildSpawnCommand(options: {
mode: SessionMode;
sessionId: string;
@@ -1116,6 +1141,8 @@ export function buildSpawnCommand(options: {
advisorModel?: string;
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
statusLineCommand?: string;
/** Add `idleCompaction: false` to the launch's one `--settings` JSON (see decideIdleCompactionOptOut). */
idleCompactionOff?: boolean;
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
sessionName?: string;
/**
@@ -2291,6 +2318,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
}
/**
* Whether this launch carries `idleCompaction: false` (decideIdleCompactionOptOut), with the
* keep-warm app default read fresh like the plan-usage switch above. Logged when keep-warm is
* on but the version gate keeps the key off, so a session that may still idle-compact says why.
*/
private async _resolveIdleCompactionOptOut(
mode: SessionMode,
statusLine: StatusLineExporter | undefined,
keepWarm: boolean | undefined,
spawnedByAgent: boolean | undefined
): Promise<boolean> {
if (statusLine !== 'injected') return false; // no settings read for a launch that can never carry it
const { keepWarmDefault } = await readKeepWarmSettings();
const off = decideIdleCompactionOptOut({ mode, statusLine, keepWarm, keepWarmDefault, spawnedByAgent });
if (!off && keepWarmOn(keepWarm, keepWarmDefault, !!spawnedByAgent)) {
console.log(
`[TmuxManager] Keep-warm is on but Claude Code's version is unknown or predates the idleCompaction setting; launching without the opt-out`
);
}
return off;
}
/**
* Export the user's own REAL statusLine command (found by
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
@@ -2351,6 +2400,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
envOverrides,
effort,
advisorModel,
keepWarm,
spawnedByAgent,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
@@ -2414,6 +2465,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
!!(remote || docker)
);
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
// The user's own REAL statusLine, if any (walked via Claude Code's own
// settings precedence) — exported below so the shared exporter script
// can wrap it. Only worth discovering when we're actually injecting.
@@ -2438,6 +2490,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
effort,
advisorModel,
statusLineCommand,
idleCompactionOff,
sessionName: cliName,
});
@@ -2583,6 +2636,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
attached: false,
name,
statusLine,
idleCompactionOff,
};
this.sessions.set(sessionId, session);
@@ -2693,6 +2747,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
unsetEnvKeys,
effort,
advisorModel,
keepWarm,
spawnedByAgent,
remote,
docker,
cliName,
@@ -2713,6 +2769,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
!!(remote || docker)
);
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
const baseCmd = buildSpawnCommand({
@@ -2734,6 +2791,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
effort,
advisorModel,
statusLineCommand,
idleCompactionOff,
sessionName: cliName,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -2776,8 +2834,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.clearPaneExit(muxName);
const pid = this.getPanePid(muxName);
if (pid) session.pid = pid;
if (session.statusLine !== statusLine) {
if (session.statusLine !== statusLine || session.idleCompactionOff !== idleCompactionOff) {
session.statusLine = statusLine;
session.idleCompactionOff = idleCompactionOff;
this.saveSessions();
}
return pid;
@@ -4163,17 +4222,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,
});
+41 -1
View File
@@ -797,6 +797,33 @@ export interface PromptCacheStatus {
*/
export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
/**
* What the prompt-cache keep-warm (`prompt-cache-keepwarm.ts`) is doing for a session, for
* the Session Options readout. Ephemeral: published through `toState()` and the
* `session:keepWarm` event, never restored.
*/
export interface KeepWarmStatus {
/** Effective opt-in: the session's own flag, else the app-wide default (see `keepWarmOn`). */
enabled: boolean;
/**
* Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). False
* on a session switched on after launch: Claude Code's own idle compaction may still run
* until its next launch.
*/
idleCompactionOff: boolean;
/** `armed` = waiting for the due time, `waiting` = due but deferred by a guard, `nudged` = sent, waiting for the next cache report. */
state: 'off' | 'armed' | 'waiting' | 'nudged' | 'stopped';
/** Next evaluation, epoch ms (armed, waiting, nudged). */
nextCheckAt?: number;
/** Why it is waiting or stopped, in words. */
reason?: string;
/** Nudges sent this session so far. */
nudges: number;
lastNudgeAt?: number;
/** When the keep-warm window after the last real prompt closes, epoch ms. */
budgetEndsAt?: number;
}
export interface SessionState {
/** Unique session identifier */
id: string;
@@ -819,7 +846,9 @@ export interface SessionState {
*
* ⚠️ UI DECORATION ONLY — it draws the lineage lines between tabs. It is never an
* ownership, permission, or lifecycle signal: a child outlives its parent, and an
* unresolvable value is dropped rather than failing the spawn.
* unresolvable value is dropped rather than failing the spawn. Its one non-visual reader
* picks a default, nothing more: a session an agent spawned does not inherit the app-wide
* prompt-cache keep-warm default (`keepWarmOn`), though its own switch still works.
*/
parentSessionId?: string;
/** ID of currently assigned task, null if none */
@@ -858,6 +887,10 @@ export interface SessionState {
promptCache?: PromptCacheStatus;
/** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */
statusLineExporter?: StatusLineExporter;
/** Prompt-cache keep-warm opt-in; absent = follow the app-wide `promptCacheKeepWarm` default. */
keepWarmEnabled?: boolean;
/** What the keep-warm is doing right now; ephemeral, never restored. */
keepWarm?: KeepWarmStatus;
/** Pinned to the top of the session manager list (COD-139) */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
@@ -972,6 +1005,13 @@ export interface SessionState {
* again until the pane's own Enter is known.
*/
lastSubmitAt?: number;
/**
* Wall-clock ms of the last submit that was NOT the prompt-cache keep-alive
* (Session.lastRealPromptAt): the keep-warm cap's clock. Persisted so a restart
* does not reopen the window, which `lastSubmitAt` cannot do since every nudge
* moves it.
*/
lastRealPromptAt?: number;
/**
* Claude conversations this pane has been on, oldest first, current last.
* Written ONLY from a first-hand `UserPromptSubmit`/`Stop` hook payload —
+8 -2
View File
@@ -123,6 +123,12 @@ interface NotePromptArgs {
* `ApprovalItem.answerInTerminal`.
*/
answerInTerminal?: boolean;
/**
* An idle prompt that should open ALREADY acknowledged for a reason other than
* `watching`, e.g. `cache keep-alive` when the turn that just ended was Codeman's own
* prompt-cache nudge. Same consequence as `watching`: the item exists, only its alert is spent.
*/
preAcknowledged?: string;
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null;
/**
@@ -285,9 +291,9 @@ export class ApprovalInbox {
cwd: args.cwd,
...(args.answerInTerminal ? { answerInTerminal: true } : {}),
};
if (args.kind === 'idle' && args.watching) {
if (args.kind === 'idle' && (args.preAcknowledged || args.watching)) {
item.acknowledgedAt = item.createdAt;
item.acknowledgedReason = `watching ${args.watching}`;
item.acknowledgedReason = args.preAcknowledged ?? `watching ${args.watching}`;
}
if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context);
this.applyCapture(item, args.capture);
+1
View File
@@ -15,3 +15,4 @@ export type { AuthPort } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js';
export type { CronPort } from './cron-port.js';
export type { TabLayoutPort } from './tab-layout-port.js';
export type { KeepWarmPort } from './keep-warm-port.js';
+10
View File
@@ -0,0 +1,10 @@
/**
* @fileoverview Port for the prompt-cache keep-warm scheduler (`prompt-cache-keepwarm.ts`).
* Routes only ever trigger a re-evaluation; the rulebook and the timers stay in the service.
*/
import type { KeepWarmScheduler } from '../../prompt-cache-keepwarm.js';
export interface KeepWarmPort {
readonly keepWarm: KeepWarmScheduler;
}
+8 -4
View File
@@ -194,6 +194,7 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
[SSE_EVENTS.SESSION_PROMPT_CACHE, '_onSessionPromptCache'],
[SSE_EVENTS.SESSION_KEEP_WARM, '_onSessionKeepWarm'],
// Scheduled runs
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
@@ -3513,8 +3514,10 @@ class CodemanApp {
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Start stuck detection timer (only if no respawn running)
if (!this.respawnStatus[data.id]?.enabled) {
// Start stuck detection timer (only if no respawn running). Not at the end of Codeman's
// own keep-alive turn (`keepAlive`): the session has been waiting since the last real
// turn, whose timer is still running or has already fired.
if (!data.keepAlive && !this.respawnStatus[data.id]?.enabled) {
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
clearTimeout(this.idleTimers.get(data.id));
this.idleTimers.set(data.id, setTimeout(() => {
@@ -3536,8 +3539,9 @@ class CodemanApp {
this.sendPendingCtrlL(data.id);
if (data.id === this.activeSessionId) this._updateLocalEchoState();
}
// Clear stuck detection timer
const timer = this.idleTimers.get(data.id);
// Clear stuck detection timer, unless this is Codeman's own keep-alive turn, which is
// no sign of life from the agent's side.
const timer = data.keepAlive ? null : this.idleTimers.get(data.id);
if (timer) {
clearTimeout(timer);
this.idleTimers.delete(data.id);
+49 -1
View File
@@ -733,7 +733,7 @@ function computeConnectionLossUi(input) {
// it says why instead of "not reported yet" forever.
const PROMPT_CACHE_NOT_COLLECTED = {
'collection-off':
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings → Header & Panels, then start a new session; this one cannot pick it up.',
'workspace-statusline':
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
remote: 'Not collected for remote and Docker sessions.',
@@ -763,6 +763,51 @@ function formatPromptCacheStatus(cache, now, statusLine) {
return parts.join(' ');
}
// Session Options → Respawn → Prompt cache → Status: the keep-warm line under the cache
// readout, saying what the keep-warm service is doing (`session.keepWarm`, published by the
// server on `session:keepWarm`), compact and dot-separated. Empty when there is nothing to
// say: switched off, not published yet, or a launch without the statusLine exporter, where
// the readout's own "Not collected" sentence on the same row already says why and
// session-ui.js greys the switch. Pure: no DOM, `now` is passed in.
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 keepWarmWindow = Number.isFinite(status.budgetEndsAt)
? status.budgetEndsAt > now
? `stops ${clock(status.budgetEndsAt)}`
: 'window closed'
: '';
// Keep-warm switched on or off after launch: the `idleCompaction` opt-out rides only a launch.
const idleCompaction =
status.enabled && status.idleCompactionOff === false
? 'idle compaction on until relaunch'
: !status.enabled && status.idleCompactionOff === true
? 'idle compaction off until relaunch'
: '';
let parts;
switch (status.state) {
case 'off': {
const reason = status.reason === 'off' ? '' : status.reason;
if (!reason && !idleCompaction) return '';
parts = ['off', reason, idleCompaction];
break;
}
case 'armed':
parts = [`next ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
break;
case 'waiting':
parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
break;
case 'nudged':
parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, keepWarmWindow, idleCompaction];
break;
default:
parts = ['stopped', status.reason || 'nothing to do', idleCompaction];
}
return `Keep-alive: ${parts.filter(Boolean).join(' \u00b7 ')}`;
}
// SSE staleness policy: is this stream a zombie?
//
// An EventSource that stops delivering does not always error. A proxy that
@@ -1503,6 +1548,8 @@ if (typeof window !== 'undefined') {
};
window.CodemanPromptCache = {
format: formatPromptCacheStatus,
formatKeepWarm: formatKeepWarmStatus,
keepWarmUnavailable: (statusLine) => !!PROMPT_CACHE_NOT_COLLECTED[statusLine],
};
window.CodemanSessionOrder = {
RANK: SESSION_ACTIVITY_RANK,
@@ -1699,6 +1746,7 @@ const SSE_EVENTS = {
SESSION_RUNNING: 'session:running',
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
SESSION_PROMPT_CACHE: 'session:promptCache',
SESSION_KEEP_WARM: 'session:keepWarm',
// Scheduled runs
SCHEDULED_CREATED: 'scheduled:created',
+13
View File
@@ -962,6 +962,19 @@
'Enable Respawn': '启用重生',
'Stop Respawn': '停止重生',
'Auto-resume when usage limit resets': '使用限制重置后自动继续',
// Prompt-cache keep-warm (Session Options → Respawn, App Settings → System → Automation, session-ui.js toasts).
'Keep the cache warm while idle': '空闲时保持缓存预热',
"Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.":
'在缓存即将过期前发送一行保活消息,让您的下一条提示仍能从缓存读取。仅在空闲时运行,最长不超过“应用设置 → 系统 → 自动化”中的上限;在启动前开启还会关闭 Claude Code 自带的空闲压缩。',
'Keep prompt caches warm': '保持提示缓存预热',
"Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.":
'未在“重生”标签页中单独设置的 Claude 会话的默认值(由智能体启动的会话需单独开启):在空闲会话的提示缓存即将过期前发送一行保活消息,让下一条提示仍从缓存读取对话,而不必重新处理。',
'Keep warm for at most': '最长保持预热',
'Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.':
'会话中最后一条提示之后发送保活消息的小时数;超过之后任由缓存过期。一次保活的开销只是它所避免的重新写入的一小部分,因此几个小时的保活是值得的,整夜的空档则不值得。',
hours: '小时',
'Prompt-cache keep-warm enabled': '提示缓存预热已开启',
'Prompt-cache keep-warm disabled': '提示缓存预热已关闭',
'Auto-restart sessions when context fills up (usually not needed)': '上下文已满时自动重启会话(通常不需要)',
'Auto-Compact': '自动压缩',
'Auto-Clear': '自动清空',
+28
View File
@@ -1339,8 +1339,19 @@
<div class="set-row-text">
<span class="set-row-label">Status</span>
<span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span>
<span class="set-row-desc" id="keepWarmStatus" data-i18n-skip hidden></span>
</div>
</div>
<!-- Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts);
the default for sessions that have not chosen lives in App Settings →
Automation. Its status shares the Status row above: session-ui.js updateKeepWarmStatus. -->
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Keep the cache warm while idle</span>
<span class="set-row-desc">Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings &rarr; System &rarr; Automation; switch on before launch to also stop Claude Code's own idle compaction.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="modalKeepWarmEnabled" onchange="app.autoSaveKeepWarm()"><span class="slider"></span></label>
</div>
</div>
</div>
@@ -3257,6 +3268,23 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsImageWatcherEnabled"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="prompt cache keep warm keep-alive nudge idle expire">
<div class="set-row-text">
<span class="set-row-label">Keep prompt caches warm</span>
<span class="set-row-desc">Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsPromptCacheKeepWarm"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="prompt cache keep warm hours limit cap window">
<div class="set-row-text">
<span class="set-row-label">Keep warm for at most</span>
<span class="set-row-desc">Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.</span>
</div>
<div class="set-row-actions">
<input type="number" id="appSettingsPromptCacheKeepWarmMaxHours" class="set-num" min="0.5" max="24" step="0.5" value="2">
<span class="set-unit">hours</span>
</div>
</div>
</div>
</div>
+57
View File
@@ -2631,6 +2631,10 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
this.updateAutoResumeStatus(sessionId);
this.updatePromptCacheStatus(sessionId);
// Effective opt-in: the server publishes it in the status (session flag, else the app default).
document.getElementById('modalKeepWarmEnabled').checked =
session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false;
this.updateKeepWarmStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
@@ -2915,6 +2919,59 @@ Object.assign(CodemanApp.prototype, {
this.updatePromptCacheStatus(sessionId);
},
/**
* Session Options → Respawn → Prompt cache: the keep-warm line on the Status row (what the
* keep-warm service is doing, `session.keepWarm`, worded by `CodemanPromptCache.formatKeepWarm`,
* hidden when it has nothing to say) and the state of the "Keep the cache warm while idle" switch.
*/
updateKeepWarmStatus(sessionId) {
const el = document.getElementById('keepWarmStatus');
if (!el || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
const status = session?.keepWarm;
const text = window.CodemanPromptCache.formatKeepWarm(status, Date.now(), session?.statusLineExporter);
el.textContent = text;
el.hidden = !text;
// A launch without cache reports can never keep warm: grey the row and refuse to switch it on,
// but a switch that is already on stays reachable so it can be turned off. Same inert-switch
// pattern as the context switch in settings-ui.js _applyModelSelection().
const box = document.getElementById('modalKeepWarmEnabled');
if (!box) return;
const inert = window.CodemanPromptCache.keepWarmUnavailable(session?.statusLineExporter);
box.closest('.set-row')?.classList.toggle('set-row-disabled', inert);
box.disabled = inert && !box.checked;
},
_onSessionKeepWarm(data) {
const { sessionId, ...status } = data;
const session = this.sessions.get(sessionId);
if (!session) return;
session.keepWarm = status;
if (this.editingSessionId === sessionId) {
const box = document.getElementById('modalKeepWarmEnabled');
if (box) box.checked = status.enabled;
}
this.updateKeepWarmStatus(sessionId);
},
async autoSaveKeepWarm() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalKeepWarmEnabled').checked;
try {
// _apiPost unwraps the envelope; the route answers with the status for the NEW choice.
const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/keep-warm`, { enabled });
const session = this.sessions.get(this.editingSessionId);
if (session) {
session.keepWarmEnabled = enabled;
if (data?.keepWarm?.status) session.keepWarm = data.keepWarm.status;
}
this.updateKeepWarmStatus(this.editingSessionId);
this.showToast(`Prompt-cache keep-warm ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch (err) {
this.showToast('Failed to toggle keep-warm: ' + err.message, 'error');
}
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
+11
View File
@@ -72,6 +72,10 @@ Object.assign(CodemanApp.prototype, {
},
_onHookStop(data) {
// The end of Codeman's own keep-alive turn (prompt-cache keep-warm): nothing finished
// that a person asked for, and an idle alert still pending is theirs to spend, so this
// neither clears it nor raises "Response Complete".
if (data.acknowledgedReason) return;
// Clear all pending hooks when Claude finishes responding
if (data.sessionId) {
this.clearPendingHooks(data.sessionId);
@@ -597,6 +601,9 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
// Prompt-cache keep-warm: synced, default OFF, cap default 2 h (DEFAULT_KEEP_WARM_MAX_HOURS).
document.getElementById('appSettingsPromptCacheKeepWarm').checked = settings.promptCacheKeepWarm === true;
document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value = settings.promptCacheKeepWarmMaxHours ?? 2;
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
@@ -2760,6 +2767,10 @@ Object.assign(CodemanApp.prototype, {
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
promptCacheKeepWarm: document.getElementById('appSettingsPromptCacheKeepWarm').checked,
promptCacheKeepWarmMaxHours:
// Clamped to the schema's range: one value outside it fails the whole save.
Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)),
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
+26 -5
View File
@@ -143,7 +143,8 @@ export function registerHookEventRoutes(
// bumped only by input that flowed through Codeman's own write path, so
// it read 0 forever for those panes and every consumer of "when did this
// pane last submit" silently degraded.
session?.markPromptSubmitted();
// The prompt text tells a person's prompt from the keep-alive's (prompt-cache-keepwarm.ts).
session?.markPromptSubmitted(typeof data.prompt === 'string' ? data.prompt : undefined);
}
// Persist when the conversation actually moved: `/clear` emits no
// completion event, so without this the successor id is lost on restart
@@ -183,9 +184,21 @@ export function registerHookEventRoutes(
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
// is worth even less on a phone than in a tab.
let acknowledgedReason: string | undefined;
// A turn Codeman started itself (the prompt-cache keep-alive, prompt-cache-keepwarm.ts)
// ends with the same stop hook and idle prompt as a real one. Both open pre-acknowledged,
// so no tab, toast or push asks a person to look at a session that wants nothing from them.
const keepAliveReason =
session?.keepAliveTurnActive && (event === 'stop' || event === 'idle_prompt') ? 'cache keep-alive' : undefined;
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
// Only a human spends an idle alert, so a keep-alive turn leaves a pending idle item exactly
// as it found it: its stop does not resolve it, and its idle prompt does not replace it (a
// replacement would forget the item was unseen, or that a person had seen it). With no idle
// item pending, the keep-alive's idle prompt opens a pre-acknowledged one.
const keepsPendingIdle = keepAliveReason !== undefined && approvalInbox.getForSession(sessionId)?.kind === 'idle';
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
if (approvalKind) {
if (approvalKind === 'idle' && keepsPendingIdle) {
// Left in place: no new item, and the broadcast below carries the keep-alive reason.
} else if (approvalKind) {
const toolInput =
safeData.tool_input && typeof safeData.tool_input === 'object'
? (safeData.tool_input as Record<string, unknown>)
@@ -209,6 +222,7 @@ export function registerHookEventRoutes(
// session that is watching its own work opens acknowledged, so it never arms an
// alert nobody can answer; notePrompt() carries the whole reasoning.
watching: session.watching,
preAcknowledged: keepAliveReason,
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
// the fallback for direct-PTY sessions and the no-op test mux.
capture: () => {
@@ -219,11 +233,16 @@ export function registerHookEventRoutes(
});
approvalId = item.id;
acknowledgedReason = item.acknowledgedReason;
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
} else if (APPROVAL_RESOLVING_EVENTS.has(event) && !keepsPendingIdle) {
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
}
acknowledgedReason ??= keepAliveReason;
// The keep-alive turn's own idle prompt is the last thing it sends, so the window ends here
// rather than on a fixed clock that a slow turn could outlast.
if (keepAliveReason && event === 'idle_prompt') session?.endKeepAliveTurn();
ctx.broadcast(`hook:${event}`, {
sessionId,
timestamp: Date.now(),
@@ -259,9 +278,11 @@ export function registerHookEventRoutes(
// Claude pane; only the ones where the conversation actually moved (a /clear
// successor) carry information, and recording the rest would push a row into
// the Summary timeline and /api/search per turn and evict useful rows from
// the 1000-event FIFO (#367 merge-time fix).
// the 1000-event FIFO (#367 merge-time fix). A keep-alive turn's stop and idle prompt are
// left out too: the summary files a `stop` as a warning, which the Away digest lists under
// Needs Attention, and that turn is Codeman's own, not the agent's.
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) {
if (summaryTracker && !keepAliveReason && (event !== 'prompt_submitted' || conversationChanged)) {
summaryTracker.recordHookEvent(event, safeData);
}
+2
View File
@@ -201,6 +201,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
saved.attachmentHistory,
lastSubmitAt: saved.lastSubmitAt,
lastRealPromptAt: saved.lastRealPromptAt,
keepWarmEnabled: saved.keepWarmEnabled,
claudeSessionChain: saved.claudeSessionChain,
lastActivityAt: saved.lastActivityAt,
owner: saved.owner,
+44 -2
View File
@@ -55,6 +55,7 @@ import {
AutoClearSchema,
AutoCompactSchema,
AutoResumeSchema,
KeepWarmSchema,
PinSessionSchema,
ImageWatcherSchema,
FlickerFilterSchema,
@@ -155,7 +156,15 @@ import {
setHistoryIndexRefresher,
setHistorySessionIndex,
} from '../session-history-index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import type {
SessionPort,
EventPort,
ConfigPort,
InfraPort,
AuthPort,
TabLayoutPort,
KeepWarmPort,
} from '../ports/index.js';
import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
@@ -1024,7 +1033,7 @@ function wakeableHost(host: RemoteHost): WakeableRemote {
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort,
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
options: { remoteWake?: RemoteWakeRegistry } = {}
): RemoteWakeRegistry {
@@ -3556,6 +3565,39 @@ export function registerSessionRoutes(
};
});
// ========== Prompt-cache keep-warm ==========
// Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts). `enabled: null`
// clears the choice so the app-wide default applies again. Refused for a CLI that reports
// no cache telemetry on its statusline: without `expires_at` there is nothing to schedule.
app.post('/api/sessions/:id/keep-warm', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(KeepWarmSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
if (!getCli(session.mode)?.capabilities.statusLineTelemetry) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Prompt-cache keep-warm needs the cache telemetry this CLI does not report'
);
}
session.setKeepWarmEnabled(body.enabled ?? undefined);
// Re-evaluate BEFORE the state broadcast and the response: both carry `keepWarm`, and a
// page that received the previous status would overwrite the one the toggle just produced.
await ctx.keepWarm.schedule(id);
persistAndBroadcastSession(ctx, session);
return {
success: true,
data: {
keepWarm: {
enabled: session.keepWarmEnabled ?? null,
status: session.keepWarm,
},
},
};
});
// ========== Auto-Resume (usage-limit pause) ==========
app.post('/api/sessions/:id/auto-resume', async (req) => {
+10 -6
View File
@@ -29,10 +29,10 @@ import {
} from '../../usage-telemetry.js';
import { SessionPromptCache, SessionStatusTelemetry } from '../sse-events.js';
import { setLatestPlanUsage } from '../plan-usage-latest.js';
import type { SessionPort, EventPort } from '../ports/index.js';
import type { SessionPort, EventPort, KeepWarmPort } from '../ports/index.js';
import { getCli } from '../../config/cli-registry/index.js';
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & KeepWarmPort): void {
// Last broadcast telemetry signature per session — the statusline fires on
// every assistant message, so we only rebroadcast when the value changes.
const lastSig = new Map<string, string>();
@@ -74,10 +74,14 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
// arrive out of order; a stale one then briefly overwrites newer state until the next
// render corrects it. Accepted for a display-only readout rather than ordered here.
const promptCache = reportsStatusLine ? parsePromptCache(payload) : null;
if (promptCache && session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
ctx.persistSessionState(session);
if (promptCache) {
if (session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
ctx.persistSessionState(session);
}
// Every report moves `expires_at`, so the keep-warm re-evaluates on each one.
void ctx.keepWarm.schedule(sessionId);
}
// Plan-usage limits (account-wide) → broadcast to the header chip, when
+19 -2
View File
@@ -51,7 +51,15 @@ import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import { getRepositoryStatus } from '../repo-status.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import type {
SessionPort,
EventPort,
ConfigPort,
InfraPort,
AuthPort,
TabLayoutPort,
KeepWarmPort,
} from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
@@ -168,7 +176,7 @@ export function settingsForNonAdmin(settings: unknown): unknown {
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort
): void {
const windowStatesPath = dataPath('subagent-window-states.json');
const parentMapPath = dataPath('subagent-parents.json');
@@ -1103,6 +1111,15 @@ export function registerSystemRoutes(
// drift. Same convention as the tmuxHistoryLimit block above.
// Handle subagent tracking toggle dynamically
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
// The prompt-cache keep-warm keeps its default and cap in memory; a changed default or cap
// makes it re-read them and re-evaluate every session now rather than at its next cache report.
// Compared on `merged`, not tested for in the body: the browser sends both keys on every save.
if (
merged.promptCacheKeepWarm !== existing.promptCacheKeepWarm ||
merged.promptCacheKeepWarmMaxHours !== existing.promptCacheKeepWarmMaxHours
) {
ctx.keepWarm.rescheduleAll();
}
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
// Either the docked panel OR the floating windows keep the watcher running.
+7
View File
@@ -1483,6 +1483,10 @@ export const SettingsUpdateSchema = z
* broadcast and /api/search, which is the user's choice to make.
*/
autoNameSessions: z.boolean().optional(),
/** Prompt-cache keep-warm default for sessions that have not chosen (Session Options overrides it). */
promptCacheKeepWarm: z.boolean().optional(),
/** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */
promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(),
/**
* Spawned tabs follow their parent: a session created by another one keeps
* following its parent's tab (into its group, moving with it) until moved by
@@ -1871,6 +1875,9 @@ export const AutoResumeSchema = z.object({
enabled: z.boolean(),
});
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() });
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
export const PinSessionSchema = z.object({
pinned: z.boolean(),
+64 -1
View File
@@ -96,7 +96,12 @@ import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator } from '../plan-orchestrator.js';
import { OrchestratorLoop } from '../orchestrator-loop.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { applyWorkspaceHooks, pruneAgentSessionPreambles, removeAgentSessionPreamble } from '../hooks-config.js';
import {
applyWorkspaceHooks,
pruneAgentSessionPreambles,
readKeepWarmSettingsOrThrow,
removeAgentSessionPreamble,
} from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push';
import { SseStreamManager } from './sse-stream-manager.js';
@@ -113,6 +118,9 @@ import { intentStore } from '../intent-store.js';
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, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -317,6 +325,50 @@ export class WebServer extends EventEmitter {
private _customModelDisplacedNotified: Set<string> = new Set();
/** Cron service (assigned in setupRoutes). */
private cronService!: CronService;
// Prompt-cache keep-warm (prompt-cache-keepwarm.ts): the rulebook and timers live in the
// service; the server only lends it the facts a decision needs. A field initializer, not a
// setupRoutes() step: cleanup and listener wiring reach it on servers that never set up routes
// (the pane-exit and discard tests build a bare WebServer), and every port reads `this.*` lazily.
private readonly keepWarmService = new PromptCacheKeepWarmService({
getSession: (id) => this.sessions.get(id),
sessionIds: () => this.sessions.keys(),
readSettings: readKeepWarmSettingsOrThrow,
isAutomationActive: (id) => {
const session = this.sessions.get(id);
if (!session) return true;
return (
(this.respawnControllers.get(id)?.isRunning ?? false) ||
session.ralphTracker.loopState.active ||
session.currentTaskId !== null ||
sessionWaits.signalWaiterCount(id) > 0
);
},
hasPendingApproval: (id) =>
approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
readPane: (view) => {
const session = view as Session;
const muxName = session.muxName;
// `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(plainFrame(text));
return {
composerEmpty: composerIsEmpty(text, getCli(session.mode)?.capabilities.workDetect?.promptGlyph ?? '') ?? null,
limitShown: limit !== null && limit.resetAt > Date.now(),
};
},
publish: (id, status) => {
const session = this.sessions.get(id);
if (!session?.setKeepWarmStatus(status)) return;
this.broadcast(SseEvent.SessionKeepWarm, { sessionId: id, ...status });
// Change-only, so one line per transition names whichever guard holds it now.
if (status.state !== 'off' || status.reason !== 'off') {
console.log(`[KeepWarm] ${id.slice(0, 8)}: ${status.state}${status.reason ? ` (${status.reason})` : ''}`);
}
},
log: (message) => console.log(message),
});
/**
* Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
* only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
@@ -747,6 +799,7 @@ export class WebServer extends EventEmitter {
return {
// SessionPort
sessions: this.sessions as ReadonlyMap<string, Session>,
keepWarm: this.keepWarmService,
addSession: this.registerSessionWithLayout.bind(this),
tabLayouts: this.tabLayouts,
cleanupSession: this.cleanupSession.bind(this),
@@ -1416,6 +1469,7 @@ export class WebServer extends EventEmitter {
coordinateLayout = true
): Promise<void> {
const session = this.sessions.get(sessionId);
this.keepWarmService.remove(sessionId);
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
if (coordinateLayout && session && killMux && !pinned) {
return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () =>
@@ -1886,6 +1940,9 @@ export class WebServer extends EventEmitter {
}
private async setupSessionListeners(session: Session): Promise<void> {
// First keep-warm evaluation: publishes the effective opt-in for the Session Options
// readout even before any cache report arrives.
void this.keepWarmService.schedule(session.id);
// Idempotent: the wiring exit handler detaches ALL listeners on every PTY exit
// (removeSessionListenerRefs), so the re-attach routes (/interactive,
// /interactive-respawn, /shell) call this again to restore observability
@@ -1918,6 +1975,7 @@ export class WebServer extends EventEmitter {
private buildSessionListenerDeps() {
return {
broadcast: this.broadcast.bind(this),
keepWarmSchedule: (id: string) => void this.keepWarmService.schedule(id),
batchTerminalData: this.batchTerminalData.bind(this),
batchTaskUpdate: this.batchTaskUpdate.bind(this),
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
@@ -3611,6 +3669,10 @@ export class WebServer extends EventEmitter {
// the launch conversation until the user types again, even though
// the re-attached CLI is on a post-`/clear` one.
lastSubmitAt: savedState?.lastSubmitAt,
// The keep-warm choice and its cap's clock; with the restored `promptCache`
// below, a future `expiresAt` re-arms the timer once the pane is attached.
lastRealPromptAt: savedState?.lastRealPromptAt,
keepWarmEnabled: savedState?.keepWarmEnabled,
// Conversations this pane provably owned, oldest first. Its tail is
// the conversation the CLI was on when the server stopped, which is
// what a re-attach must point the viewer at instead of the launch id.
@@ -4063,6 +4125,7 @@ export class WebServer extends EventEmitter {
// response), so without this a 10-minute wait holds shutdown open.
sessionWaits.cancelEverything();
approvalInbox.stop();
this.keepWarmService.stop();
// Same reason as `cancelEverything` above: an in-flight wake is awaited by a request,
// and `app.close()` (the last line of this method) does not abort in-flight requests —
// so without this a restart during a wake waits out the readiness poll.
+15 -3
View File
@@ -93,6 +93,12 @@ interface SessionListenerDeps {
updateSessionName(sessionId: string, name: string): boolean;
/** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
isAutoNameEnabled(): Promise<boolean>;
/**
* Re-evaluate the prompt-cache keep-warm when the session goes idle: a turn just ended
* (its cache report may already be in), and after a restart this is the first moment the
* recovered pane is attached, so the restored report can arm its timer.
*/
keepWarmSchedule?(sessionId: string): void;
}
/**
@@ -234,7 +240,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
// kind: `working` is heuristic and can flap mid-turn, so clearing a
// pending permission/question dialog on the signal ALONE would
// false-clear real approvals.
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
// Not for Codeman's own keep-alive turn: only a human spends an idle alert.
if (!session.keepAliveTurnActive) approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
// A permission/question dialog gets the pane-VERIFIED variant instead:
// the signal only decides when to look, `verifyStillAnswerable` re-reads
// the screen and resolves only when the dialog is really gone. Without
@@ -244,7 +251,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
// open. `stop` was the first thing to clear it, which on a long turn is
// minutes away.
approvalInbox.resolveIfDialogGone(session.id);
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
// `keepAlive` tells the page this turn is Codeman's own keep-alive (stuck detection ignores it).
deps.broadcast(SseEvent.SessionWorking, {
id: session.id,
...(session.keepAliveTurnActive && { keepAlive: true }),
});
// Full state ride-along: the home screens sort the running group on
// lastSubmitAt, and without this the browser keeps the stamp it loaded
// with (a turn started after page load ranks by the PREVIOUS turn's
@@ -260,8 +271,9 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
idle: () => {
sessionWaits.notifySignal(session.id, 'idle');
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
deps.broadcast(SseEvent.SessionIdle, { id: session.id, ...(session.keepAliveTurnActive && { keepAlive: true }) });
deps.broadcastSessionStateDebounced(session.id);
deps.keepWarmSchedule?.(session.id);
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.recordIdle();
+5 -2
View File
@@ -5,10 +5,10 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 162 event constants organized by category:
* 163 event constants organized by category:
* - **Core** (1): init
* - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (24): created, updated, deleted, terminal, idle, working, ...
* - **Session lifecycle** (25): created, updated, deleted, terminal, idle, working, ...
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
@@ -122,6 +122,8 @@ export const SessionRunning = 'session:running' as const;
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
/** Prompt-cache state of one session's main conversation changed (`{ sessionId, ...PromptCacheStatus }`). */
export const SessionPromptCache = 'session:promptCache' as const;
/** The prompt-cache keep-warm's status for one session changed (`{ sessionId, ...KeepWarmStatus }`). */
export const SessionKeepWarm = 'session:keepWarm' as const;
// ─── Session: Ralph ──────────────────────────────────────────────────────────
@@ -534,6 +536,7 @@ export const SseEvent = {
SessionRunning,
SessionStatusTelemetry,
SessionPromptCache,
SessionKeepWarm,
// Session: Ralph
SessionRalphLoopUpdate,