fix(sessions): leave a pending idle alert to the user through a keep-alive turn

Only a human spends an idle alert, but the keep-alive turn resolved an unseen one three ways: its working transition, its stop hook, and its own idle prompt replacing the item with a pre-acknowledged one. While the turn runs, a pending idle item is now left exactly as found, the page's stop handler returns on the keep-alive reason before clearing hooks or raising Response Complete, and session:working/session:idle carry keepAlive so the stuck-detection timer neither restarts nor re-arms.

The window that marks the turn closes on its own idle prompt or any other submit instead of a fixed three minutes, which a slow turn's idle prompt (sent a minute after the turn ends) could outlast. The service keeps the default and cap in memory, read through a strict reader so a mid-write read is retried rather than kept, and the cap is clamped before the strict settings save.
This commit is contained in:
JD committed 2026-10-19 01:42:00 -04:00
1 parent 71a51df2e2
commit 5db6c633d3
22 files changed
+441 -41

No files matched your search

+25 -5
View File
@@ -1148,14 +1148,34 @@ export async function readPinCliSessionNameEnabled(): Promise<boolean> {
/**
* The prompt-cache keep-warm's app-wide default (`promptCacheKeepWarm`, absent = OFF) and its
* cap (`promptCacheKeepWarmMaxHours`, hours after the last real prompt). Read FRESH like
* {@link readPinCliSessionNameEnabled}: by the service on every evaluation, and at every claude
* create and respawn, which decides the launch's `idleCompaction` opt-out.
* {@link readPinCliSessionNameEnabled} at every claude create and respawn, which decides the
* launch's `idleCompaction` opt-out.
*/
export async function readKeepWarmSettings(): Promise<KeepWarmSettings> {
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const hours = settings.promptCacheKeepWarmMaxHours;
return keepWarmSettingsFrom(await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}));
}
/**
* The same two keys for the keep-warm service, which keeps what it reads until the next
* settings change. A missing file is the defaults, but any other read or parse failure rejects
* rather than falling back: settings.json is rewritten in place, so a read can land mid-write,
* and a fallback kept from it would hold keep-warm off until the next keep-warm settings change.
*/
export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> {
let text: string;
try {
text = await readFile(SETTINGS_PATH, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({});
throw err;
}
return keepWarmSettingsFrom(JSON.parse(text) as Record<string, unknown>);
}
function keepWarmSettingsFrom(settings: Record<string, unknown>): KeepWarmSettings {
const hours = settings?.promptCacheKeepWarmMaxHours;
return {
keepWarmDefault: settings.promptCacheKeepWarm === true,
keepWarmDefault: settings?.promptCacheKeepWarm === true,
maxHours: typeof hours === 'number' && Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_KEEP_WARM_MAX_HOURS,
};
}
+28 -4
View File
@@ -45,8 +45,13 @@ export const KEEP_WARM_LAST_CHANCE_MS = 60_000;
export const KEEP_WARM_MIN_TOKENS = 20_000;
/** A person typed this recently: they may be about to submit, which refreshes the cache for free. */
export const KEEP_WARM_RECENT_INPUT_MS = 3 * 60_000;
/** How long after sending the nudge its own stop hook and idle prompt stay pre-acknowledged. */
export const KEEP_WARM_TURN_WINDOW_MS = 3 * 60_000;
/**
* Ceiling on the keep-alive window. The window normally closes on the turn's own idle prompt
* (Claude Code sends it about a minute after the turn ends, so a slow turn must not outlast a
* short fixed window) or earlier, at any other submit; the ceiling covers a turn whose idle
* prompt never arrives, such as a workspace without Codeman's hooks.
*/
export const KEEP_WARM_TURN_WINDOW_MS = 10 * 60_000;
/** Default cap: hours after the last real prompt during which nudges are sent. */
export const DEFAULT_KEEP_WARM_MAX_HOURS = 2;
/** Longest delay `setTimeout` honours; Node fires anything longer after 1 ms. */
@@ -197,7 +202,7 @@ export interface KeepWarmPorts {
export interface KeepWarmScheduler {
/** Re-evaluate a session: after a cache report, a flag change, or a settings change. Resolves once the status is published. */
schedule(sessionId: string): Promise<void>;
/** Re-evaluate every session (the app-wide default or cap changed). */
/** Re-read the app-wide default and cap, then re-evaluate every session (either one changed). */
rescheduleAll(): void;
/** The session is gone. */
remove(sessionId: string): void;
@@ -208,6 +213,12 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
private readonly tallies = new Map<string, { nudges: number; lastNudgeAt?: number }>();
/** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */
private readonly inFlight = new Map<string, symbol>();
/**
* The default and cap, read once and kept: every statusline report schedules an evaluation,
* and reading settings.json on each would cost every install, the feature off included.
* `rescheduleAll()` drops it, and the settings PUT calls that whenever either key changes.
*/
private settings: Promise<KeepWarmSettings> | undefined;
private stopped = false;
constructor(private readonly ports: KeepWarmPorts) {}
@@ -220,6 +231,7 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
}
rescheduleAll(): void {
this.settings = undefined;
for (const id of this.ports.sessionIds()) void this.schedule(id);
}
@@ -234,6 +246,18 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
for (const id of [...this.timers.keys()]) this.clearTimer(id);
}
private readSettings(): Promise<KeepWarmSettings> {
if (!this.settings) {
const read = this.ports.readSettings();
this.settings = read;
// A failed read is not kept: the next evaluation tries again.
read.catch(() => {
if (this.settings === read) this.settings = undefined;
});
}
return this.settings;
}
private clearTimer(id: string): void {
const t = this.timers.get(id);
if (t) clearTimeout(t);
@@ -258,7 +282,7 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler {
this.clearTimer(id);
const session = this.ports.getSession(id);
if (!session) return;
const settings = await this.ports.readSettings();
const settings = await this.readSettings();
if (cancelled()) return;
const tally = this.tallies.get(id) ?? { nudges: 0 };
const facts: KeepWarmFacts = {
+20 -2
View File
@@ -456,6 +456,15 @@ export interface ClaudeMessage {
duration_ms?: number;
}
/**
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
*/
function isKeepAliveWrite(data: string): boolean {
const text = data.replace(/[\r\n]/g, '').trim();
return text === '' || text === KEEP_WARM_NUDGE;
}
/**
* Event signatures emitted by the Session class.
* Subscribe using `session.on('eventName', handler)`.
@@ -1562,8 +1571,10 @@ export class Session extends EventEmitter {
/**
* True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle
* prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the
* response viewer's conversation correlation needs it) but not `lastRealPromptAt`. Any
* `fromUser` write ends it early, so a person's prompt right after a nudge counts and alerts normally.
* response viewer's conversation correlation needs it) but not `lastRealPromptAt`. The turn's
* own idle prompt ends it (`endKeepAliveTurn()`), and so does any other submit: a `fromUser`
* write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook for
* another prompt, so a prompt right after a nudge counts and alerts normally.
*/
get keepAliveTurnActive(): boolean {
return Date.now() < this._keepAliveTurnUntil;
@@ -1573,6 +1584,10 @@ export class Session extends EventEmitter {
this._keepAliveTurnUntil = Date.now() + windowMs;
}
endKeepAliveTurn(): void {
this._keepAliveTurnUntil = 0;
}
/** Last keystroke a person sent (`fromUser` write), epoch ms; 0 when none this process. */
get lastHumanInputAt(): number {
return this._lastHumanInputAt;
@@ -4741,6 +4756,9 @@ export class Session extends EventEmitter {
this._lastHumanInputAt = Date.now();
// A person is here: whatever turn follows is theirs, not the keep-alive's.
this._keepAliveTurnUntil = 0;
} else if (hasEnter && !isKeepAliveWrite(data)) {
// Anything else submitted (an agent, respawn, auto-resume) starts a turn of its own.
this._keepAliveTurnUntil = 0;
}
if (hasEnter) this._stampSubmit();
return submitted;
+7 -4
View File
@@ -3470,8 +3470,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(() => {
@@ -3493,8 +3495,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);
+11 -5
View File
@@ -778,15 +778,21 @@ function formatKeepWarmStatus(status, now, statusLine) {
? `stops ${clock(status.budgetEndsAt)}`
: 'window closed'
: '';
// Keep-warm switched on after launch: the `idleCompaction` opt-out rides only a launch.
// 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 === false
? 'idle compaction on until relaunch'
: !status.enabled && status.idleCompactionOff === true
? 'idle compaction off until relaunch'
: '';
let parts;
switch (status.state) {
case 'off':
if (!status.reason || status.reason === 'off') return '';
parts = ['off', status.reason];
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)}`, window, idleCompaction];
break;
+6 -1
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);
@@ -2765,7 +2769,8 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
promptCacheKeepWarm: document.getElementById('appSettingsPromptCacheKeepWarm').checked,
promptCacheKeepWarmMaxHours:
parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2,
// 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,
+12 -2
View File
@@ -190,8 +190,15 @@ export function registerHookEventRoutes(
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>)
@@ -226,12 +233,15 @@ 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,
+2 -2
View File
@@ -1111,8 +1111,8 @@ export function registerSystemRoutes(
// drift. Same convention as the tmuxHistoryLimit block above.
// Handle subagent tracking toggle dynamically
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
// The prompt-cache keep-warm reads its default and cap fresh on every decision; a changed
// default or cap re-evaluates every session now rather than at its next cache report.
// 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.
if ('promptCacheKeepWarm' in settingsToStore || 'promptCacheKeepWarmMaxHours' in settingsToStore) {
ctx.keepWarm.rescheduleAll();
}
+3 -3
View File
@@ -1871,13 +1871,13 @@ export const AutoCompactSchema = z.object({
});
/** POST /api/sessions/:id/auto-resume */
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() });
export const AutoResumeSchema = z.object({
enabled: z.boolean(),
});
/** `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(),
+2 -2
View File
@@ -99,7 +99,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js';
import {
applyWorkspaceHooks,
pruneAgentSessionPreambles,
readKeepWarmSettings,
readKeepWarmSettingsOrThrow,
removeAgentSessionPreamble,
} from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
@@ -332,7 +332,7 @@ export class WebServer extends EventEmitter {
private readonly keepWarmService = new PromptCacheKeepWarmService({
getSession: (id) => this.sessions.get(id),
sessionIds: () => this.sessions.keys(),
readSettings: readKeepWarmSettings,
readSettings: readKeepWarmSettingsOrThrow,
isAutomationActive: (id) => {
const session = this.sessions.get(id);
if (!session) return true;
+8 -3
View File
@@ -240,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
@@ -250,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
@@ -266,7 +271,7 @@ 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);