mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59:33 +01:00
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:
1 parent
e63bb65b36
commit
71a51df2e2
42 files changed
+2131
-38
No files matched your search
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '自动清空',
|
||||
|
||||
@@ -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 → System → 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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user