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

Claude Code lets a subscription's one-hour prompt cache expire after an hour idle, and the first turn back re-writes the whole conversation at the cache-write rate; nearly every gap over an hour goes cold, while the saving is concentrated in gaps of one to four hours. Codeman now sends one short keep-alive turn five minutes before the expiry Claude Code reports on its statusline, so the next real prompt still reads the conversation from cache, and stops two hours (configurable) after the last real prompt.

Codeman owns the idle policy for these sessions, as agreed in Discussion #554: a launch with keep-warm on also carries idleCompaction: false in its one --settings JSON, so Claude Code's own idle compaction cannot replace the conversation with a summary a minute before the nudge. It is decided once per launch (keep-warm on, the statusLine exporter injected, Claude Code 2.1.290 or newer, an unknown version fails closed like --name), recorded on the mux record, and picked up by any relaunch; a launch with keep-warm off is byte-identical. A session switched on after launch is kept warm best-effort and the readout says so.

Every guard lives in one pure rulebook: the opt-in (an agent-spawned session does not inherit the app default), a launch that reports cache state, a warm 1h cache with enough tokens at stake and no compaction this gap, a live pane, no usage-limit pause, no respawn, Ralph, task or waiting agent, idle, no open dialog, an empty composer and no recent keystroke. The nudge is a submit and moves lastSubmitAt, which the response viewer's conversation correlation needs; the cap counts from its own persisted lastRealPromptAt instead. Read My Mind drops the exact nudge text, and the turn's stop hook and idle prompt open pre-acknowledged so nothing alerts. The last cache report and the clock survive a restart, so the timer re-arms.
This commit is contained in:
JD committed 2026-10-18 15:19:08 -04:00
1 parent e63bb65b36
commit 71a51df2e2
42 files changed
+2131 -38

No files matched your search

+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;
}
+1
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'],
+43 -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,45 @@ 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 window = Number.isFinite(status.budgetEndsAt)
? status.budgetEndsAt > now
? `stops ${clock(status.budgetEndsAt)}`
: 'window closed'
: '';
// Keep-warm switched on after launch: the `idleCompaction` opt-out rides only a launch.
const idleCompaction =
status.enabled && status.idleCompactionOff === false ? 'idle compaction on until relaunch' : '';
let parts;
switch (status.state) {
case 'off':
if (!status.reason || status.reason === 'off') return '';
parts = ['off', status.reason];
break;
case 'armed':
parts = [`next ${clock(status.nextCheckAt)}`, window, idleCompaction];
break;
case 'waiting':
parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, window, idleCompaction];
break;
case 'nudged':
parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, window, 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 +1542,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 +1740,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
@@ -1334,8 +1334,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>
@@ -3252,6 +3263,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;
+6
View File
@@ -597,6 +597,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 +2763,9 @@ 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:
parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2,
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
+10 -1
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,6 +184,11 @@ 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];
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
if (approvalKind) {
@@ -209,6 +215,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: () => {
@@ -224,6 +231,8 @@ export function registerHookEventRoutes(
}
}
acknowledgedReason ??= keepAliveReason;
ctx.broadcast(`hook:${event}`, {
sessionId,
timestamp: Date.now(),
+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
+15 -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,11 @@ 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 reads its default and cap fresh on every decision; a changed
// default or cap re-evaluates every session now rather than at its next cache report.
if ('promptCacheKeepWarm' in settingsToStore || 'promptCacheKeepWarmMaxHours' in settingsToStore) {
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
@@ -1867,6 +1871,9 @@ export const AutoCompactSchema = z.object({
});
/** POST /api/sessions/:id/auto-resume */
/** `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() });
export const AutoResumeSchema = z.object({
enabled: z.boolean(),
});
+63 -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,
readKeepWarmSettings,
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 } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -317,6 +325,49 @@ 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: readKeepWarmSettings,
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`: the visible frame as plain text.
const text = muxName ? (this.mux.capturePaneText?.(muxName) ?? null) : null;
if (!text) return { composerEmpty: null, limitShown: false };
const limit = detectUsageLimitPause(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 +798,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 +1468,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 +1939,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 +1974,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 +3668,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 +4124,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.
+7
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;
}
/**
@@ -262,6 +268,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
sessionWaits.notifySignal(session.id, 'idle');
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
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,