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

+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,