mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
commit
f80f107445
45 files changed
+3017
-59
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '自动清空',
|
||||
|
||||
@@ -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 → 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in new issue
Block a user