mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29: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
@@ -329,7 +329,12 @@ const CLAUDE: CliEntry = {
|
||||
'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
],
|
||||
gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } },
|
||||
// idleCompactionSetting: the `idleCompaction` settings key first ships in 2.1.290 (2.1.289
|
||||
// already idle-compacts with no opt-out); see TmuxManager._resolveIdleCompactionOptOut.
|
||||
gates: {
|
||||
nameFlag: { minVersion: '2.1.224', failClosed: true },
|
||||
idleCompactionSetting: { minVersion: '2.1.290', failClosed: true },
|
||||
},
|
||||
// claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289).
|
||||
mcpConfig: {
|
||||
path: '.claude.json',
|
||||
|
||||
@@ -41,6 +41,7 @@ import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js';
|
||||
import { isNearStalledPath, probePath } from './utils/index.js';
|
||||
import { DEFAULT_KEEP_WARM_MAX_HOURS, type KeepWarmSettings } from './prompt-cache-keepwarm.js';
|
||||
|
||||
/**
|
||||
* Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`),
|
||||
@@ -1144,6 +1145,21 @@ export async function readPinCliSessionNameEnabled(): Promise<boolean> {
|
||||
return settings.pinCliSessionName === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function readKeepWarmSettings(): Promise<KeepWarmSettings> {
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
|
||||
const hours = settings.promptCacheKeepWarmMaxHours;
|
||||
return {
|
||||
keepWarmDefault: settings.promptCacheKeepWarm === true,
|
||||
maxHours: typeof hours === 'number' && Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_KEEP_WARM_MAX_HOURS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the statusLine command to pass as an EPHEMERAL `claude --settings`
|
||||
* CLI flag for this one process (see buildSpawnCommandFromRegistry in
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
|
||||
import type { IntentProfile, IntentPromptEntry } from './types/index.js';
|
||||
|
||||
// ========== Limits ==========
|
||||
@@ -62,6 +63,9 @@ export function isCapturablePrompt(text: string): boolean {
|
||||
if (text.startsWith('<system-reminder>')) return false;
|
||||
if (text.startsWith('Caveat: The messages below')) return false;
|
||||
if (text.startsWith('[Request interrupted')) return false;
|
||||
// Codeman's own prompt-cache keep-alive turn is not something the user meant. The exact
|
||||
// text, so a prompt of the user's that merely mentions it is still captured.
|
||||
if (text.trim() === KEEP_WARM_NUDGE) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@ export interface MuxSession {
|
||||
ralphEnabled?: boolean;
|
||||
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
|
||||
statusLine?: StatusLineExporter;
|
||||
/**
|
||||
* Whether the current launch carries `idleCompaction: false` in its `--settings` JSON (keep-warm
|
||||
* on at launch, the exporter injected, Claude Code new enough); set on every create and respawn.
|
||||
*/
|
||||
idleCompactionOff?: boolean;
|
||||
/**
|
||||
* This record was rebuilt from the tmux socket rather than from Codeman's own
|
||||
* bookkeeping, so everything on it but the name and the pid is a guess. Its
|
||||
@@ -123,6 +128,10 @@ export interface CreateSessionOptions {
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */
|
||||
advisorModel?: string;
|
||||
/** The session's own prompt-cache keep-warm choice (undefined = app default); decides the `idleCompaction` opt-out. */
|
||||
keepWarm?: boolean;
|
||||
/** Spawned by another session: it does not inherit the keep-warm app default (`keepWarmOn`). */
|
||||
spawnedByAgent?: boolean;
|
||||
/** tmux history-limit (scrollback lines) allocated when this session is created. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
@@ -176,6 +185,10 @@ export interface RespawnPaneOptions {
|
||||
effort?: EffortLevel;
|
||||
/** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */
|
||||
advisorModel?: string;
|
||||
/** The session's own prompt-cache keep-warm choice, re-read at every relaunch (see CreateSessionOptions). */
|
||||
keepWarm?: boolean;
|
||||
/** Spawned by another session (see CreateSessionOptions). */
|
||||
spawnedByAgent?: boolean;
|
||||
/** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */
|
||||
historyLimit?: number;
|
||||
/** Remote execution metadata for local tmux sessions wrapping SSH */
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* @fileoverview Prompt-cache keep-warm: a one-line keep-alive turn shortly before an idle
|
||||
* claude session's prompt cache would expire, so the next real prompt reads the conversation
|
||||
* from cache instead of re-processing it at the cache-write rate.
|
||||
*
|
||||
* Why it exists: Claude Code gives a subscription's main conversation a one-hour cache and
|
||||
* lets it expire after an hour idle. Measured on real sessions, nearly every gap over an
|
||||
* hour went cold and the first turn back re-wrote the whole context at 2x; a keep-alive turn
|
||||
* costs a cache read plus a one-word reply, a small fraction of the miss it prevents, and the
|
||||
* payoff is concentrated in gaps of one to four hours, which is why there is a cap after the
|
||||
* last real prompt rather than an indefinite loop.
|
||||
*
|
||||
* Codeman owns the idle policy for these sessions (Discussion #554): a launch with keep-warm
|
||||
* on also carries `idleCompaction: false` in its one `--settings` JSON, so Claude Code's own
|
||||
* idle compaction (which fires at 90% of the TTL, a minute before the nudge) cannot replace
|
||||
* the conversation with a summary first. That is decided once per launch in
|
||||
* `TmuxManager._resolveIdleCompactionOptOut()` and recorded on the mux record; a session
|
||||
* switched on after launch is kept warm best-effort and says so.
|
||||
*
|
||||
* Where the facts come from: `Session.promptCache` (the statusline's `prompt_cache` object,
|
||||
* see usage-telemetry.ts) carries `expires_at`, the TTL and the tokens at stake, so nothing
|
||||
* here infers the lifetime from idle detection or the plan tier.
|
||||
*
|
||||
* Shape: `decideKeepWarm()` is the pure rulebook (every guard in one place, tested as a
|
||||
* table); `PromptCacheKeepWarmService` owns the per-session timer and the ports it needs
|
||||
* from the server. It never touches the session except through `beginKeepAliveTurn()` and
|
||||
* `writeViaMux()`: the window `beginKeepAliveTurn()` opens is what keeps the nudge off the
|
||||
* real-prompt clock and opens its stop hook and idle prompt pre-acknowledged
|
||||
* (hook-event-routes.ts). The nudge is still a submit, so it moves `lastSubmitAt`.
|
||||
*
|
||||
* @module prompt-cache-keepwarm
|
||||
*/
|
||||
|
||||
import type { KeepWarmStatus, PromptCacheStatus, StatusLineExporter } from './types/index.js';
|
||||
|
||||
/** The nudge. Under 100 chars on purpose: a longer burst reads as a paste to Claude Code. */
|
||||
export const KEEP_WARM_NUDGE = 'Codeman cache keep-alive: no action needed, reply with just "ok".';
|
||||
/** Nudge this long before `expires_at`. The nudge's own request is what refreshes the timer. */
|
||||
export const KEEP_WARM_LEAD_MS = 5 * 60_000;
|
||||
/** Re-check cadence while a transient guard (working, dialog, draft in the composer) defers. */
|
||||
export const KEEP_WARM_RETRY_MS = 60_000;
|
||||
/** Give up this close to expiry: a turn takes seconds to land and the Enter may be re-pressed. */
|
||||
export const KEEP_WARM_LAST_CHANCE_MS = 60_000;
|
||||
/** Below this many tokens at stake a cold rewrite is cheap and a turn is not worth it. */
|
||||
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;
|
||||
/** 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. */
|
||||
const MAX_TIMER_MS = 2 ** 31 - 1;
|
||||
|
||||
/**
|
||||
* Whether keep-warm is on for a session: its own choice, else the app-wide default. A session
|
||||
* an agent spawned (`parentSessionId`) does not inherit the default: a worker that finished
|
||||
* its task is idle exactly like one that will be prompted again, and most never are, so only
|
||||
* its own switch keeps it warm. One function because the launch (the `idleCompaction`
|
||||
* opt-out) and the service must agree.
|
||||
*/
|
||||
export function keepWarmOn(choice: boolean | undefined, appDefault: boolean, spawnedByAgent: boolean): boolean {
|
||||
return choice ?? (appDefault && !spawnedByAgent);
|
||||
}
|
||||
|
||||
export interface KeepWarmFacts {
|
||||
now: number;
|
||||
/** The session's own choice; undefined = the app-wide default applies. */
|
||||
choice: boolean | undefined;
|
||||
appDefault: boolean;
|
||||
/** Spawned by another session (`parentSessionId`); see `keepWarmOn`. */
|
||||
spawnedByAgent: boolean;
|
||||
/** Whether this launch carries the statusLine exporter; undefined when unknown. */
|
||||
statusLine?: StatusLineExporter;
|
||||
cache: PromptCacheStatus | null | undefined;
|
||||
/** Idle by Codeman's own probe and not mid-turn. */
|
||||
idle: boolean;
|
||||
/** A pid and no recorded agent exit (`paneExit`: an exited agent's pane keeps its pid). */
|
||||
paneAlive: boolean;
|
||||
/** Auto-resume's pause, or a usage-limit message on screen whose reset is still ahead. */
|
||||
limitPaused: boolean;
|
||||
/** Respawn running, a Ralph loop active, a task assigned, or an agent long-polling the session. */
|
||||
automationActive: boolean;
|
||||
/** A permission, question or elicitation dialog is open (never the idle item). */
|
||||
pendingApproval: boolean;
|
||||
/** The composer line holds no text; null when the pane could not be read. */
|
||||
composerEmpty: boolean | null;
|
||||
/** Last keystroke a person sent, epoch ms; 0 when none seen. */
|
||||
lastHumanInputAt: number;
|
||||
/** Start of the keep-warm window: the last real (non-keep-alive) prompt, else the session start. */
|
||||
budgetAnchorAt: number;
|
||||
maxKeepWarmMs: number;
|
||||
}
|
||||
|
||||
export type KeepWarmDecision =
|
||||
| { action: 'nudge' }
|
||||
| { action: 'wait'; until: number; reason: string }
|
||||
| { action: 'stop'; reason: string };
|
||||
|
||||
const stop = (reason: string): KeepWarmDecision => ({ action: 'stop', reason });
|
||||
const wait = (until: number, reason: string): KeepWarmDecision => ({ action: 'wait', until, reason });
|
||||
|
||||
/**
|
||||
* The rulebook. Hard stops first (nothing to do until the next cache report or a flag
|
||||
* change), then the schedule, then the transient guards that defer by `KEEP_WARM_RETRY_MS`
|
||||
* and never past the last-chance line. Pure: `now` is passed in.
|
||||
*/
|
||||
export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision {
|
||||
if (!keepWarmOn(f.choice, f.appDefault, f.spawnedByAgent)) {
|
||||
return stop(
|
||||
f.choice === undefined && f.appDefault
|
||||
? 'an agent-spawned session follows the app-wide default only when switched on for itself'
|
||||
: 'off'
|
||||
);
|
||||
}
|
||||
// Only a launch that carries the exporter ever reports `prompt_cache`; the readout names the
|
||||
// cause from `statusLineExporter`. This is also what stops a CLI without cache telemetry.
|
||||
if (f.statusLine !== 'injected') return stop(`no cache reports on this launch (${f.statusLine ?? 'unknown'})`);
|
||||
const c = f.cache;
|
||||
if (!c) return stop('no cache report yet');
|
||||
if (!c.warm || !c.expiresAt) return stop('cache is cold; the next prompt rebuilds it');
|
||||
// Compacted this gap (`/compact`, or Claude Code's own idle compaction on a launch without
|
||||
// the opt-out): the full context is already gone, so keeping the summary warm buys little.
|
||||
// The stamp is dropped at the next request, which re-opens the next gap.
|
||||
if (c.compactedAt != null) return stop('the context was compacted; resumes after the next prompt');
|
||||
if (c.ttl === '5m') {
|
||||
return stop('5-minute cache lifetime; a nudge every four minutes costs more than the miss it prevents');
|
||||
}
|
||||
if ((c.recacheTokensIfCold ?? 0) < KEEP_WARM_MIN_TOKENS) return stop('context too small to be worth a turn');
|
||||
if (!f.paneAlive) return stop('no live pane');
|
||||
if (f.limitPaused) return stop('usage-limit pause');
|
||||
if (f.automationActive) return stop('respawn, Ralph or an agent is driving the session');
|
||||
if (f.now >= f.budgetAnchorAt + f.maxKeepWarmMs) return stop('keep-warm window after the last prompt has passed');
|
||||
const dueAt = c.expiresAt - KEEP_WARM_LEAD_MS;
|
||||
if (f.now < dueAt) return wait(dueAt, 'not due yet');
|
||||
const lastChance = c.expiresAt - KEEP_WARM_LAST_CHANCE_MS;
|
||||
if (f.now >= lastChance) return stop('too close to expiry to land a turn');
|
||||
const retryAt = Math.min(f.now + KEEP_WARM_RETRY_MS, lastChance);
|
||||
if (!f.idle) return wait(retryAt, 'session is working');
|
||||
if (f.pendingApproval) return wait(retryAt, 'a dialog is open');
|
||||
if (f.composerEmpty === null) return stop('cannot read the composer');
|
||||
if (!f.composerEmpty) return wait(retryAt, 'the composer holds text');
|
||||
if (f.lastHumanInputAt && f.now - f.lastHumanInputAt < KEEP_WARM_RECENT_INPUT_MS) {
|
||||
return wait(retryAt, 'someone typed a moment ago');
|
||||
}
|
||||
return { action: 'nudge' };
|
||||
}
|
||||
|
||||
/** What the service needs from a session; `Session` satisfies it structurally. */
|
||||
export interface KeepWarmSessionView {
|
||||
readonly id: string;
|
||||
readonly createdAt: number;
|
||||
readonly pid: number | null;
|
||||
/** Set when the agent exited in a pane that stays open (`remain-on-exit`); the pid stays. */
|
||||
readonly paneExit?: unknown;
|
||||
readonly parentSessionId?: string;
|
||||
readonly promptCache: PromptCacheStatus | null;
|
||||
readonly statusLineExporter?: StatusLineExporter;
|
||||
/** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */
|
||||
readonly idleCompactionOptOut: boolean;
|
||||
readonly keepWarmEnabled: boolean | undefined;
|
||||
readonly isWorking: boolean;
|
||||
readonly isLimitPaused: boolean;
|
||||
readonly lastHumanInputAt: number;
|
||||
/** Last submit that was not the keep-alive's own (persisted): the cap's clock. */
|
||||
readonly lastRealPromptAt: number;
|
||||
isIdle(): boolean;
|
||||
beginKeepAliveTurn(windowMs: number): void;
|
||||
writeViaMux(data: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface KeepWarmSettings {
|
||||
/** App-wide default for sessions that have not chosen (`promptCacheKeepWarm`). */
|
||||
keepWarmDefault: boolean;
|
||||
/** Cap in hours after the last real prompt (`promptCacheKeepWarmMaxHours`). */
|
||||
maxHours: number;
|
||||
}
|
||||
|
||||
export interface KeepWarmPorts {
|
||||
getSession(id: string): KeepWarmSessionView | undefined;
|
||||
sessionIds(): Iterable<string>;
|
||||
readSettings(): Promise<KeepWarmSettings>;
|
||||
isAutomationActive(id: string): boolean;
|
||||
hasPendingApproval(id: string): boolean;
|
||||
/**
|
||||
* Read the pane once a nudge is otherwise due: whether the composer is empty (null when it
|
||||
* cannot be read), and whether a usage-limit message with a reset still ahead is on screen
|
||||
* (auto-resume's own pause tracking only runs while auto-resume is on).
|
||||
*/
|
||||
readPane(session: KeepWarmSessionView): { composerEmpty: boolean | null; limitShown: boolean };
|
||||
/** Store and broadcast the status (the server dedupes against the session's last one). */
|
||||
publish(id: string, status: KeepWarmStatus): void;
|
||||
log?(message: string): void;
|
||||
}
|
||||
|
||||
/** The scheduler the routes see: one method per trigger, nothing else. */
|
||||
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). */
|
||||
rescheduleAll(): void;
|
||||
/** The session is gone. */
|
||||
remove(sessionId: string): void;
|
||||
}
|
||||
|
||||
export class PromptCacheKeepWarmService implements KeepWarmScheduler {
|
||||
private readonly timers = new Map<string, NodeJS.Timeout>();
|
||||
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>();
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly ports: KeepWarmPorts) {}
|
||||
|
||||
schedule(sessionId: string): Promise<void> {
|
||||
if (this.stopped) return Promise.resolve();
|
||||
return this.evaluate(sessionId).catch((err) => {
|
||||
this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
rescheduleAll(): void {
|
||||
for (const id of this.ports.sessionIds()) void this.schedule(id);
|
||||
}
|
||||
|
||||
remove(sessionId: string): void {
|
||||
this.clearTimer(sessionId);
|
||||
this.tallies.delete(sessionId);
|
||||
this.inFlight.delete(sessionId);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
for (const id of [...this.timers.keys()]) this.clearTimer(id);
|
||||
}
|
||||
|
||||
private clearTimer(id: string): void {
|
||||
const t = this.timers.get(id);
|
||||
if (t) clearTimeout(t);
|
||||
this.timers.delete(id);
|
||||
}
|
||||
|
||||
private arm(id: string, at: number): void {
|
||||
this.clearTimer(id);
|
||||
const t = setTimeout(() => void this.schedule(id), Math.min(Math.max(0, at - Date.now()), MAX_TIMER_MS));
|
||||
t.unref?.();
|
||||
this.timers.set(id, t);
|
||||
}
|
||||
|
||||
private async evaluate(id: string): Promise<void> {
|
||||
if (this.stopped || this.inFlight.has(id)) return;
|
||||
const token = Symbol(id);
|
||||
this.inFlight.set(id, token);
|
||||
// A session removed (or the service stopped) during an await gets nothing after it: no
|
||||
// nudge, no tally, no timer, no status.
|
||||
const cancelled = () => this.stopped || this.inFlight.get(id) !== token;
|
||||
try {
|
||||
this.clearTimer(id);
|
||||
const session = this.ports.getSession(id);
|
||||
if (!session) return;
|
||||
const settings = await this.ports.readSettings();
|
||||
if (cancelled()) return;
|
||||
const tally = this.tallies.get(id) ?? { nudges: 0 };
|
||||
const facts: KeepWarmFacts = {
|
||||
now: Date.now(),
|
||||
choice: session.keepWarmEnabled,
|
||||
appDefault: settings.keepWarmDefault,
|
||||
spawnedByAgent: !!session.parentSessionId,
|
||||
statusLine: session.statusLineExporter,
|
||||
cache: session.promptCache,
|
||||
idle: session.isIdle() && !session.isWorking,
|
||||
paneAlive: session.pid !== null && !session.paneExit,
|
||||
limitPaused: session.isLimitPaused,
|
||||
automationActive: this.ports.isAutomationActive(id),
|
||||
pendingApproval: this.ports.hasPendingApproval(id),
|
||||
// Optimistic until everything else says "due": the pane read is the one costly fact.
|
||||
composerEmpty: true,
|
||||
lastHumanInputAt: session.lastHumanInputAt,
|
||||
budgetAnchorAt: session.lastRealPromptAt || session.createdAt,
|
||||
maxKeepWarmMs: settings.maxHours * 3_600_000,
|
||||
};
|
||||
let decision = decideKeepWarm(facts);
|
||||
if (decision.action === 'nudge') {
|
||||
const pane = this.ports.readPane(session);
|
||||
decision = decideKeepWarm({
|
||||
...facts,
|
||||
composerEmpty: pane.composerEmpty,
|
||||
limitPaused: facts.limitPaused || pane.limitShown,
|
||||
});
|
||||
}
|
||||
const enabled = keepWarmOn(facts.choice, facts.appDefault, facts.spawnedByAgent);
|
||||
const base = {
|
||||
enabled,
|
||||
idleCompactionOff: session.idleCompactionOptOut,
|
||||
nudges: tally.nudges,
|
||||
lastNudgeAt: tally.lastNudgeAt,
|
||||
budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs,
|
||||
};
|
||||
if (decision.action === 'nudge') {
|
||||
session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS);
|
||||
const ok = await session.writeViaMux(`${KEEP_WARM_NUDGE}\r`);
|
||||
if (cancelled()) return;
|
||||
if (ok) {
|
||||
tally.nudges += 1;
|
||||
tally.lastNudgeAt = Date.now();
|
||||
this.tallies.set(id, tally);
|
||||
}
|
||||
this.ports.log?.(
|
||||
`[KeepWarm] ${ok ? 'nudged' : 'failed to nudge'} ${id.slice(0, 8)} with ${facts.cache?.recacheTokensIfCold ?? '?'} tokens at stake (nudge ${tally.nudges})`
|
||||
);
|
||||
// The nudge's own cache report re-schedules; this timer is the fallback if none arrives.
|
||||
const nextCheckAt = Date.now() + KEEP_WARM_LEAD_MS;
|
||||
this.arm(id, nextCheckAt);
|
||||
this.ports.publish(id, {
|
||||
...base,
|
||||
nudges: tally.nudges,
|
||||
lastNudgeAt: tally.lastNudgeAt,
|
||||
state: ok ? 'nudged' : 'waiting',
|
||||
nextCheckAt,
|
||||
reason: ok ? undefined : 'the keep-alive could not be written',
|
||||
});
|
||||
} else if (decision.action === 'wait') {
|
||||
this.arm(id, decision.until);
|
||||
this.ports.publish(id, {
|
||||
...base,
|
||||
state: decision.reason === 'not due yet' ? 'armed' : 'waiting',
|
||||
nextCheckAt: decision.until,
|
||||
reason: decision.reason === 'not due yet' ? undefined : decision.reason,
|
||||
});
|
||||
} else {
|
||||
this.ports.publish(id, { ...base, state: enabled ? 'stopped' : 'off', reason: decision.reason });
|
||||
}
|
||||
} finally {
|
||||
if (this.inFlight.get(id) === token) this.inFlight.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,12 @@ export interface SpawnBridgeOptions {
|
||||
* rendered as two independent params.
|
||||
*/
|
||||
statusLineCommand?: string;
|
||||
/**
|
||||
* Add `idleCompaction: false` to the same `--settings` JSON: Codeman's prompt-cache keep-warm
|
||||
* owns the idle policy for this launch. Decided (keep-warm on, exporter injected, version gate
|
||||
* passed) by TmuxManager._resolveIdleCompactionOptOut, which records it on the mux record.
|
||||
*/
|
||||
idleCompactionOff?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,6 +174,20 @@ function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null
|
||||
return passed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `mode`'s `capabilities.gates[gate]` passes, through the same fail-closed resolution
|
||||
* the launch template uses (an unknown version passes no gate). For a launch-time decision that
|
||||
* is also recorded, like the `idleCompaction` opt-out. `claudeCliVersion` as in
|
||||
* SpawnBridgeOptions: undefined probes the local CLI, null means unknown.
|
||||
*/
|
||||
export function launchGatePasses(mode: string, gate: string, claudeCliVersion?: string | null): boolean {
|
||||
const entry = getCli(mode);
|
||||
if (!entry?.capabilities.gates[gate]) return false;
|
||||
return resolveGatesPassed(entry, () =>
|
||||
claudeCliVersion !== undefined ? claudeCliVersion : getClaudeCliVersion()
|
||||
).has(gate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a
|
||||
* `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall
|
||||
@@ -208,10 +228,15 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
||||
// rendering them as independent params would let the last one silently win. Claude-only in
|
||||
// practice: only claude's launch template renders this engine value, so another CLI's
|
||||
// session carrying an advisorModel launches exactly as before.
|
||||
// Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor
|
||||
// byte-identical to one from before the advisor existed.
|
||||
// Key order (ultracode, advisorModel, statusLine, idleCompaction) keeps a launch without an
|
||||
// advisor or keep-warm byte-identical to one from before either existed.
|
||||
const advisorSettings = buildAdvisorSettings(options.advisorModel);
|
||||
if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) {
|
||||
if (
|
||||
(effortFlag === '--settings' && effortValue) ||
|
||||
advisorSettings.advisorModel ||
|
||||
options.statusLineCommand ||
|
||||
options.idleCompactionOff
|
||||
) {
|
||||
const settingsObj: Record<string, unknown> = {
|
||||
...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}),
|
||||
...advisorSettings,
|
||||
@@ -219,6 +244,7 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
|
||||
if (options.statusLineCommand) {
|
||||
settingsObj.statusLine = { type: 'command', command: options.statusLineCommand };
|
||||
}
|
||||
if (options.idleCompactionOff) settingsObj.idleCompaction = false;
|
||||
engineValues.effortSettingsJson = JSON.stringify(settingsObj);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,28 @@ export function promptStillInComposer(screen: string, prompt: string, glyph: str
|
||||
return head.length > 0 && composer.startsWith(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the composer holds nothing: the LAST `glyph` line after the glyph, and every
|
||||
* continuation line below it down to the composer's bottom rule (a draft that opens with a
|
||||
* newline leaves the glyph line itself blank). Undefined when there is no composer line or no
|
||||
* rule under it to bound the read (a shell, a dialog, a direct-PTY session): nothing can be
|
||||
* said. The prompt-cache keep-warm refuses to type unless this is true: `send-keys -l` would
|
||||
* append its nudge to whatever draft sits there, and the Enter would submit both.
|
||||
*/
|
||||
export function composerIsEmpty(screen: string, glyph: string): boolean | undefined {
|
||||
if (!glyph) return undefined;
|
||||
// The structure is read at column 0, before any trimming: Claude Code draws the glyph line and
|
||||
// both rules there and indents every continuation line, so a draft line that holds the glyph or
|
||||
// starts with a rule (` ❯`, ` ───`) can never pass for the composer's own edges.
|
||||
const lines = stripAnsi(screen).split('\n');
|
||||
let at = lines.length - 1;
|
||||
while (at >= 0 && !lines[at].startsWith(glyph)) at--;
|
||||
if (at < 0) return undefined;
|
||||
const end = lines.findIndex((l, i) => i > at && l.startsWith('\u2500'));
|
||||
if (end < 0) return undefined;
|
||||
return compact(lines[at].slice(glyph.length) + lines.slice(at + 1, end).join('')).length === 0;
|
||||
}
|
||||
|
||||
export interface SubmitVerifierDeps {
|
||||
/** The rendered pane, or null when there is none to read. */
|
||||
capture: () => string | null | undefined;
|
||||
|
||||
+107
-6
@@ -65,6 +65,8 @@ import {
|
||||
type SessionWriteOptions,
|
||||
type PaneExit,
|
||||
type PromptCacheStatus,
|
||||
type StatusLineExporter,
|
||||
type KeepWarmStatus,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
|
||||
import { claudeTranscriptExists } from './utils/claude-transcript.js';
|
||||
@@ -127,6 +129,7 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
import { SubmitVerifier } from './session-submit-verifier.js';
|
||||
import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js';
|
||||
import { compileVersionRegex } from './config/cli-registry/patterns.js';
|
||||
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
|
||||
import {
|
||||
@@ -597,6 +600,16 @@ export class Session extends EventEmitter {
|
||||
private _watching: string | null = null;
|
||||
/** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */
|
||||
private _promptCache: PromptCacheStatus | null = null;
|
||||
/** Prompt-cache keep-warm opt-in; undefined = follow the app-wide default. Persisted. */
|
||||
private _keepWarmEnabled: boolean | undefined;
|
||||
/** Last status the keep-warm service published for this session; ephemeral. */
|
||||
private _keepWarm: KeepWarmStatus | null = null;
|
||||
/** Until when the current turn counts as Codeman's own keep-alive (hooks open pre-acknowledged). */
|
||||
private _keepAliveTurnUntil = 0;
|
||||
/** Last keystroke a person sent through a `fromUser` write. */
|
||||
private _lastHumanInputAt = 0;
|
||||
/** Last submit that was not the keep-alive's own; the keep-warm cap's clock. Persisted. */
|
||||
private _lastRealPromptAt = 0;
|
||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||
private _workingLineRe: RegExp | undefined = undefined;
|
||||
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
|
||||
@@ -804,7 +817,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// The session that spawned this one (tab lineage lines). Resolved by the create
|
||||
// route before it reaches here, so this is always either an id that existed at
|
||||
// create time or undefined. Decoration only — see SessionState.parentSessionId.
|
||||
// create time or undefined. Decoration only (plus the keep-warm default); see SessionState.parentSessionId.
|
||||
private readonly _parentSessionId?: string;
|
||||
|
||||
// Session color for visual differentiation
|
||||
@@ -899,6 +912,10 @@ export class Session extends EventEmitter {
|
||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
|
||||
lastSubmitAt?: number;
|
||||
/** Restored last non-keep-alive submit (see `lastRealPromptAt`). */
|
||||
lastRealPromptAt?: number;
|
||||
/** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */
|
||||
keepWarmEnabled?: boolean;
|
||||
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
|
||||
claudeSessionChain?: string[];
|
||||
/** Restored agent-exit observation for this session's pane (see `paneExit`). */
|
||||
@@ -975,6 +992,9 @@ export class Session extends EventEmitter {
|
||||
// response viewer re-derive the live conversation without waiting for the
|
||||
// user to type again.
|
||||
this._lastSubmitAt = config.lastSubmitAt ?? 0;
|
||||
// Restored so a restart neither reopens the keep-warm window nor drops the choice.
|
||||
this._lastRealPromptAt = config.lastRealPromptAt ?? 0;
|
||||
this._keepWarmEnabled = config.keepWarmEnabled;
|
||||
// Restored chain: its tail is the conversation the CLI was actually on when
|
||||
// the server stopped, which outranks the launch id seeded just above. The
|
||||
// FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a
|
||||
@@ -1479,12 +1499,17 @@ export class Session extends EventEmitter {
|
||||
* report. It rides `toState()` for the Session Options readout and is restored on a
|
||||
* Codeman restart (constructor `promptCache`) so the row is not blank until the pane's
|
||||
* next render; the readout is time-aware, so a value whose `expiresAt` has passed renders
|
||||
* as cold rather than as a stale warm.
|
||||
* as cold rather than as a stale warm, and a future one lets the keep-warm re-arm without a turn.
|
||||
*/
|
||||
get promptCache(): PromptCacheStatus | null {
|
||||
return this._promptCache;
|
||||
}
|
||||
|
||||
/** Whether the current launch carries the statusLine exporter (`MuxSession.statusLine`); undefined when unknown. */
|
||||
get statusLineExporter(): StatusLineExporter | undefined {
|
||||
return this._muxSession?.statusLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the prompt-cache report; true when it differs from the previous one. Stamps
|
||||
* `compactedAt` when this report ENTERS the window in which Claude Code reads
|
||||
@@ -1513,6 +1538,60 @@ export class Session extends EventEmitter {
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Prompt-cache keep-warm opt-in; undefined means the app-wide default applies. */
|
||||
get keepWarmEnabled(): boolean | undefined {
|
||||
return this._keepWarmEnabled;
|
||||
}
|
||||
|
||||
setKeepWarmEnabled(enabled: boolean | undefined): void {
|
||||
this._keepWarmEnabled = enabled;
|
||||
}
|
||||
|
||||
/** What the keep-warm service last published for this session, or null before its first evaluation. */
|
||||
get keepWarm(): KeepWarmStatus | null {
|
||||
return this._keepWarm;
|
||||
}
|
||||
|
||||
/** Replace the keep-warm status; true when it differs from the previous one. */
|
||||
setKeepWarmStatus(status: KeepWarmStatus): boolean {
|
||||
const changed = JSON.stringify(status) !== JSON.stringify(this._keepWarm);
|
||||
this._keepWarm = status;
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
get keepAliveTurnActive(): boolean {
|
||||
return Date.now() < this._keepAliveTurnUntil;
|
||||
}
|
||||
|
||||
beginKeepAliveTurn(windowMs: number): void {
|
||||
this._keepAliveTurnUntil = Date.now() + windowMs;
|
||||
}
|
||||
|
||||
/** Last keystroke a person sent (`fromUser` write), epoch ms; 0 when none this process. */
|
||||
get lastHumanInputAt(): number {
|
||||
return this._lastHumanInputAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last submit that was not the keep-alive's own, from any writer or the UserPromptSubmit hook
|
||||
* (a person typing in an attached tmux client reaches Codeman only through the hook); epoch
|
||||
* ms, 0 if none. The keep-warm cap counts from it; persisted so a restart keeps it.
|
||||
*/
|
||||
get lastRealPromptAt(): number {
|
||||
return this._lastRealPromptAt;
|
||||
}
|
||||
|
||||
/** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */
|
||||
get idleCompactionOptOut(): boolean {
|
||||
return this._muxSession?.idleCompactionOff === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||
@@ -2008,7 +2087,9 @@ export class Session extends EventEmitter {
|
||||
autoResumeEnabled: this._autoOps.autoResumeEnabled,
|
||||
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
|
||||
promptCache: this._promptCache ?? undefined,
|
||||
statusLineExporter: this._muxSession?.statusLine,
|
||||
statusLineExporter: this.statusLineExporter,
|
||||
keepWarmEnabled: this._keepWarmEnabled,
|
||||
keepWarm: this._keepWarm ?? undefined,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
pinned: this._pinned || undefined,
|
||||
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
|
||||
@@ -2057,6 +2138,7 @@ export class Session extends EventEmitter {
|
||||
paneExit: this._paneExit ?? undefined,
|
||||
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
|
||||
lastSubmitAt: this._lastSubmitAt || undefined,
|
||||
lastRealPromptAt: this._lastRealPromptAt || undefined,
|
||||
// Only a chain the CLI's own hooks vouched for is persisted, and only when
|
||||
// the pane actually moved conversation. Its LAST entry is the live one, so
|
||||
// it is also what restores `claudeSessionId` across a restart — `start()`
|
||||
@@ -2425,6 +2507,8 @@ export class Session extends EventEmitter {
|
||||
unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined,
|
||||
effort: this._effort,
|
||||
advisorModel: this._advisorModel,
|
||||
keepWarm: this._keepWarmEnabled,
|
||||
spawnedByAgent: !!this._parentSessionId,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
@@ -2904,6 +2988,8 @@ export class Session extends EventEmitter {
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
advisorModel: this._advisorModel,
|
||||
keepWarm: this._keepWarmEnabled,
|
||||
spawnedByAgent: !!this._parentSessionId,
|
||||
historyLimit: this._tmuxHistoryLimit,
|
||||
remote: this._remote,
|
||||
docker: this._docker,
|
||||
@@ -4650,9 +4736,13 @@ export class Session extends EventEmitter {
|
||||
*/
|
||||
private _trackSubmit(data: string, options: SessionWriteOptions): string[] {
|
||||
const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : [];
|
||||
if (data.includes('\r') || data.includes('\n')) {
|
||||
this._lastSubmitAt = Date.now();
|
||||
const hasEnter = data.includes('\r') || data.includes('\n');
|
||||
if (options.fromUser) {
|
||||
this._lastHumanInputAt = Date.now();
|
||||
// A person is here: whatever turn follows is theirs, not the keep-alive's.
|
||||
this._keepAliveTurnUntil = 0;
|
||||
}
|
||||
if (hasEnter) this._stampSubmit();
|
||||
return submitted;
|
||||
}
|
||||
|
||||
@@ -4678,9 +4768,20 @@ export class Session extends EventEmitter {
|
||||
* `_trackSubmit` only sees input that flows through Codeman's write path, so
|
||||
* a pane the user drives by attaching to tmux directly never stamped this and
|
||||
* `lastSubmitAt` stayed 0 for its whole life.
|
||||
*
|
||||
* `prompt` is the hook's own prompt text: anything but the keep-alive's ends the keep-alive
|
||||
* window, because a person typing in an attached tmux client reaches Codeman only here, and
|
||||
* their prompt must count as real and alert normally.
|
||||
*/
|
||||
markPromptSubmitted(): void {
|
||||
markPromptSubmitted(prompt?: string): void {
|
||||
if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0;
|
||||
this._stampSubmit();
|
||||
}
|
||||
|
||||
/** Every submit moves `lastSubmitAt`; only one outside a keep-alive turn moves `lastRealPromptAt`. */
|
||||
private _stampSubmit(): void {
|
||||
this._lastSubmitAt = Date.now();
|
||||
if (!this.keepAliveTurnActive) this._lastRealPromptAt = this._lastSubmitAt;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+60
-1
@@ -67,12 +67,15 @@ import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
|
||||
import {
|
||||
buildSpawnCommandFromRegistry,
|
||||
configSetenvValues,
|
||||
launchGatePasses,
|
||||
legacyConfigForMode,
|
||||
} from './session-cli-registry-bridge.js';
|
||||
import { keepWarmOn } from './prompt-cache-keepwarm.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import {
|
||||
resolveStatusLineCliCommand,
|
||||
readPlanUsageTelemetryEnabled,
|
||||
readKeepWarmSettings,
|
||||
findEffectiveUserStatusLineCommand,
|
||||
} from './hooks-config.js';
|
||||
import {
|
||||
@@ -1070,6 +1073,28 @@ export function buildCodexCommand(config?: CodexConfig): string {
|
||||
return buildSpawnCommandFromRegistry(entry, { mode: 'codex', sessionId: '', codexConfig: config }) ?? 'codex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a launch carries `idleCompaction: false` in its one `--settings` JSON: keep-warm on
|
||||
* at launch (`keepWarmOn`), the statusLine exporter injected (without its `prompt_cache` reports
|
||||
* keep-warm cannot run, and the opt-out alone would only cost the user Claude Code's own idle
|
||||
* compaction), and the CLI's `idleCompactionSetting` gate passed, fail-closed like `--name`:
|
||||
* an unknown version means no key. Pure; `claudeCliVersion` as in buildSpawnCommand.
|
||||
*/
|
||||
export function decideIdleCompactionOptOut(o: {
|
||||
mode: SessionMode;
|
||||
statusLine: StatusLineExporter | undefined;
|
||||
keepWarm: boolean | undefined;
|
||||
keepWarmDefault: boolean;
|
||||
spawnedByAgent: boolean | undefined;
|
||||
claudeCliVersion?: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
o.statusLine === 'injected' &&
|
||||
keepWarmOn(o.keepWarm, o.keepWarmDefault, !!o.spawnedByAgent) &&
|
||||
launchGatePasses(o.mode, 'idleCompactionSetting', o.claudeCliVersion)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSpawnCommand(options: {
|
||||
mode: SessionMode;
|
||||
sessionId: string;
|
||||
@@ -1091,6 +1116,8 @@ export function buildSpawnCommand(options: {
|
||||
advisorModel?: string;
|
||||
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
|
||||
statusLineCommand?: string;
|
||||
/** Add `idleCompaction: false` to the launch's one `--settings` JSON (see decideIdleCompactionOptOut). */
|
||||
idleCompactionOff?: boolean;
|
||||
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
|
||||
sessionName?: string;
|
||||
/**
|
||||
@@ -2266,6 +2293,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this launch carries `idleCompaction: false` (decideIdleCompactionOptOut), with the
|
||||
* keep-warm app default read fresh like the plan-usage switch above. Logged when keep-warm is
|
||||
* on but the version gate keeps the key off, so a session that may still idle-compact says why.
|
||||
*/
|
||||
private async _resolveIdleCompactionOptOut(
|
||||
mode: SessionMode,
|
||||
statusLine: StatusLineExporter | undefined,
|
||||
keepWarm: boolean | undefined,
|
||||
spawnedByAgent: boolean | undefined
|
||||
): Promise<boolean> {
|
||||
if (statusLine !== 'injected') return false; // no settings read for a launch that can never carry it
|
||||
const { keepWarmDefault } = await readKeepWarmSettings();
|
||||
const off = decideIdleCompactionOptOut({ mode, statusLine, keepWarm, keepWarmDefault, spawnedByAgent });
|
||||
if (!off && keepWarmOn(keepWarm, keepWarmDefault, !!spawnedByAgent)) {
|
||||
console.log(
|
||||
`[TmuxManager] Keep-warm is on but Claude Code's version is unknown or predates the idleCompaction setting; launching without the opt-out`
|
||||
);
|
||||
}
|
||||
return off;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the user's own REAL statusLine command (found by
|
||||
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
|
||||
@@ -2326,6 +2375,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
keepWarm,
|
||||
spawnedByAgent,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
@@ -2389,6 +2440,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
);
|
||||
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
|
||||
// The user's own REAL statusLine, if any (walked via Claude Code's own
|
||||
// settings precedence) — exported below so the shared exporter script
|
||||
// can wrap it. Only worth discovering when we're actually injecting.
|
||||
@@ -2413,6 +2465,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
idleCompactionOff,
|
||||
sessionName: cliName,
|
||||
});
|
||||
|
||||
@@ -2554,6 +2607,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
attached: false,
|
||||
name,
|
||||
statusLine,
|
||||
idleCompactionOff,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
@@ -2664,6 +2718,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
unsetEnvKeys,
|
||||
effort,
|
||||
advisorModel,
|
||||
keepWarm,
|
||||
spawnedByAgent,
|
||||
remote,
|
||||
docker,
|
||||
cliName,
|
||||
@@ -2684,6 +2740,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
);
|
||||
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
|
||||
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
|
||||
|
||||
const baseCmd = buildSpawnCommand({
|
||||
@@ -2705,6 +2762,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
idleCompactionOff,
|
||||
sessionName: cliName,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
@@ -2747,8 +2805,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.clearPaneExit(muxName);
|
||||
const pid = this.getPanePid(muxName);
|
||||
if (pid) session.pid = pid;
|
||||
if (session.statusLine !== statusLine) {
|
||||
if (session.statusLine !== statusLine || session.idleCompactionOff !== idleCompactionOff) {
|
||||
session.statusLine = statusLine;
|
||||
session.idleCompactionOff = idleCompactionOff;
|
||||
this.saveSessions();
|
||||
}
|
||||
return pid;
|
||||
|
||||
+41
-1
@@ -797,6 +797,33 @@ export interface PromptCacheStatus {
|
||||
*/
|
||||
export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
|
||||
|
||||
/**
|
||||
* What the prompt-cache keep-warm (`prompt-cache-keepwarm.ts`) is doing for a session, for
|
||||
* the Session Options readout. Ephemeral: published through `toState()` and the
|
||||
* `session:keepWarm` event, never restored.
|
||||
*/
|
||||
export interface KeepWarmStatus {
|
||||
/** Effective opt-in: the session's own flag, else the app-wide default (see `keepWarmOn`). */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). False
|
||||
* on a session switched on after launch: Claude Code's own idle compaction may still run
|
||||
* until its next launch.
|
||||
*/
|
||||
idleCompactionOff: boolean;
|
||||
/** `armed` = waiting for the due time, `waiting` = due but deferred by a guard, `nudged` = sent, waiting for the next cache report. */
|
||||
state: 'off' | 'armed' | 'waiting' | 'nudged' | 'stopped';
|
||||
/** Next evaluation, epoch ms (armed, waiting, nudged). */
|
||||
nextCheckAt?: number;
|
||||
/** Why it is waiting or stopped, in words. */
|
||||
reason?: string;
|
||||
/** Nudges sent this session so far. */
|
||||
nudges: number;
|
||||
lastNudgeAt?: number;
|
||||
/** When the keep-warm window after the last real prompt closes, epoch ms. */
|
||||
budgetEndsAt?: number;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
/** Unique session identifier */
|
||||
id: string;
|
||||
@@ -819,7 +846,9 @@ export interface SessionState {
|
||||
*
|
||||
* ⚠️ UI DECORATION ONLY — it draws the lineage lines between tabs. It is never an
|
||||
* ownership, permission, or lifecycle signal: a child outlives its parent, and an
|
||||
* unresolvable value is dropped rather than failing the spawn.
|
||||
* unresolvable value is dropped rather than failing the spawn. Its one non-visual reader
|
||||
* picks a default, nothing more: a session an agent spawned does not inherit the app-wide
|
||||
* prompt-cache keep-warm default (`keepWarmOn`), though its own switch still works.
|
||||
*/
|
||||
parentSessionId?: string;
|
||||
/** ID of currently assigned task, null if none */
|
||||
@@ -858,6 +887,10 @@ export interface SessionState {
|
||||
promptCache?: PromptCacheStatus;
|
||||
/** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */
|
||||
statusLineExporter?: StatusLineExporter;
|
||||
/** Prompt-cache keep-warm opt-in; absent = follow the app-wide `promptCacheKeepWarm` default. */
|
||||
keepWarmEnabled?: boolean;
|
||||
/** What the keep-warm is doing right now; ephemeral, never restored. */
|
||||
keepWarm?: KeepWarmStatus;
|
||||
/** Pinned to the top of the session manager list (COD-139) */
|
||||
pinned?: boolean;
|
||||
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
|
||||
@@ -972,6 +1005,13 @@ export interface SessionState {
|
||||
* again until the pane's own Enter is known.
|
||||
*/
|
||||
lastSubmitAt?: number;
|
||||
/**
|
||||
* Wall-clock ms of the last submit that was NOT the prompt-cache keep-alive
|
||||
* (Session.lastRealPromptAt): the keep-warm cap's clock. Persisted so a restart
|
||||
* does not reopen the window, which `lastSubmitAt` cannot do since every nudge
|
||||
* moves it.
|
||||
*/
|
||||
lastRealPromptAt?: number;
|
||||
/**
|
||||
* Claude conversations this pane has been on, oldest first, current last.
|
||||
* Written ONLY from a first-hand `UserPromptSubmit`/`Stop` hook payload —
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -143,7 +143,8 @@ export function registerHookEventRoutes(
|
||||
// bumped only by input that flowed through Codeman's own write path, so
|
||||
// it read 0 forever for those panes and every consumer of "when did this
|
||||
// pane last submit" silently degraded.
|
||||
session?.markPromptSubmitted();
|
||||
// The prompt text tells a person's prompt from the keep-alive's (prompt-cache-keepwarm.ts).
|
||||
session?.markPromptSubmitted(typeof data.prompt === 'string' ? data.prompt : undefined);
|
||||
}
|
||||
// Persist when the conversation actually moved: `/clear` emits no
|
||||
// completion event, so without this the successor id is lost on restart
|
||||
@@ -183,6 +184,11 @@ export function registerHookEventRoutes(
|
||||
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
|
||||
// is worth even less on a phone than in a tab.
|
||||
let acknowledgedReason: string | undefined;
|
||||
// A turn Codeman started itself (the prompt-cache keep-alive, prompt-cache-keepwarm.ts)
|
||||
// ends with the same stop hook and idle prompt as a real one. Both open pre-acknowledged,
|
||||
// so no tab, toast or push asks a person to look at a session that wants nothing from them.
|
||||
const keepAliveReason =
|
||||
session?.keepAliveTurnActive && (event === 'stop' || event === 'idle_prompt') ? 'cache keep-alive' : undefined;
|
||||
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
|
||||
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
if (approvalKind) {
|
||||
@@ -209,6 +215,7 @@ export function registerHookEventRoutes(
|
||||
// session that is watching its own work opens acknowledged, so it never arms an
|
||||
// alert nobody can answer; notePrompt() carries the whole reasoning.
|
||||
watching: session.watching,
|
||||
preAcknowledged: keepAliveReason,
|
||||
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
|
||||
// the fallback for direct-PTY sessions and the no-op test mux.
|
||||
capture: () => {
|
||||
@@ -224,6 +231,8 @@ export function registerHookEventRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgedReason ??= keepAliveReason;
|
||||
|
||||
ctx.broadcast(`hook:${event}`, {
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -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,11 @@ export function registerSystemRoutes(
|
||||
// drift. Same convention as the tmuxHistoryLimit block above.
|
||||
// Handle subagent tracking toggle dynamically
|
||||
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
// The prompt-cache keep-warm reads its default and cap fresh on every decision; a changed
|
||||
// default or cap re-evaluates every session now rather than at its next cache report.
|
||||
if ('promptCacheKeepWarm' in settingsToStore || 'promptCacheKeepWarmMaxHours' in settingsToStore) {
|
||||
ctx.keepWarm.rescheduleAll();
|
||||
}
|
||||
|
||||
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
|
||||
// Either the docked panel OR the floating windows keep the watcher running.
|
||||
|
||||
@@ -1483,6 +1483,10 @@ export const SettingsUpdateSchema = z
|
||||
* broadcast and /api/search, which is the user's choice to make.
|
||||
*/
|
||||
autoNameSessions: z.boolean().optional(),
|
||||
/** Prompt-cache keep-warm default for sessions that have not chosen (Session Options overrides it). */
|
||||
promptCacheKeepWarm: z.boolean().optional(),
|
||||
/** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */
|
||||
promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(),
|
||||
/**
|
||||
* Spawned tabs follow their parent: a session created by another one keeps
|
||||
* following its parent's tab (into its group, moving with it) until moved by
|
||||
@@ -1867,6 +1871,9 @@ export const AutoCompactSchema = z.object({
|
||||
});
|
||||
|
||||
/** POST /api/sessions/:id/auto-resume */
|
||||
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
|
||||
export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() });
|
||||
|
||||
export const AutoResumeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
+63
-1
@@ -96,7 +96,12 @@ import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { applyWorkspaceHooks, pruneAgentSessionPreambles, removeAgentSessionPreamble } from '../hooks-config.js';
|
||||
import {
|
||||
applyWorkspaceHooks,
|
||||
pruneAgentSessionPreambles,
|
||||
readKeepWarmSettings,
|
||||
removeAgentSessionPreamble,
|
||||
} from '../hooks-config.js';
|
||||
import { PushSubscriptionStore } from '../push-store.js';
|
||||
import webpush from 'web-push';
|
||||
import { SseStreamManager } from './sse-stream-manager.js';
|
||||
@@ -113,6 +118,9 @@ import { intentStore } from '../intent-store.js';
|
||||
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
|
||||
import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js';
|
||||
import { composerIsEmpty } from '../session-submit-verifier.js';
|
||||
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
|
||||
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
|
||||
import {
|
||||
wireRespawnListeners,
|
||||
@@ -317,6 +325,49 @@ export class WebServer extends EventEmitter {
|
||||
private _customModelDisplacedNotified: Set<string> = new Set();
|
||||
/** Cron service (assigned in setupRoutes). */
|
||||
private cronService!: CronService;
|
||||
// Prompt-cache keep-warm (prompt-cache-keepwarm.ts): the rulebook and timers live in the
|
||||
// service; the server only lends it the facts a decision needs. A field initializer, not a
|
||||
// setupRoutes() step: cleanup and listener wiring reach it on servers that never set up routes
|
||||
// (the pane-exit and discard tests build a bare WebServer), and every port reads `this.*` lazily.
|
||||
private readonly keepWarmService = new PromptCacheKeepWarmService({
|
||||
getSession: (id) => this.sessions.get(id),
|
||||
sessionIds: () => this.sessions.keys(),
|
||||
readSettings: readKeepWarmSettings,
|
||||
isAutomationActive: (id) => {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return true;
|
||||
return (
|
||||
(this.respawnControllers.get(id)?.isRunning ?? false) ||
|
||||
session.ralphTracker.loopState.active ||
|
||||
session.currentTaskId !== null ||
|
||||
sessionWaits.signalWaiterCount(id) > 0
|
||||
);
|
||||
},
|
||||
hasPendingApproval: (id) =>
|
||||
approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
|
||||
readPane: (view) => {
|
||||
const session = view as Session;
|
||||
const muxName = session.muxName;
|
||||
// `capture-pane -p`: the visible frame as plain text.
|
||||
const text = muxName ? (this.mux.capturePaneText?.(muxName) ?? null) : null;
|
||||
if (!text) return { composerEmpty: null, limitShown: false };
|
||||
const limit = detectUsageLimitPause(text);
|
||||
return {
|
||||
composerEmpty: composerIsEmpty(text, getCli(session.mode)?.capabilities.workDetect?.promptGlyph ?? '') ?? null,
|
||||
limitShown: limit !== null && limit.resetAt > Date.now(),
|
||||
};
|
||||
},
|
||||
publish: (id, status) => {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session?.setKeepWarmStatus(status)) return;
|
||||
this.broadcast(SseEvent.SessionKeepWarm, { sessionId: id, ...status });
|
||||
// Change-only, so one line per transition names whichever guard holds it now.
|
||||
if (status.state !== 'off' || status.reason !== 'off') {
|
||||
console.log(`[KeepWarm] ${id.slice(0, 8)}: ${status.state}${status.reason ? ` (${status.reason})` : ''}`);
|
||||
}
|
||||
},
|
||||
log: (message) => console.log(message),
|
||||
});
|
||||
/**
|
||||
* Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
|
||||
* only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
|
||||
@@ -747,6 +798,7 @@ export class WebServer extends EventEmitter {
|
||||
return {
|
||||
// SessionPort
|
||||
sessions: this.sessions as ReadonlyMap<string, Session>,
|
||||
keepWarm: this.keepWarmService,
|
||||
addSession: this.registerSessionWithLayout.bind(this),
|
||||
tabLayouts: this.tabLayouts,
|
||||
cleanupSession: this.cleanupSession.bind(this),
|
||||
@@ -1416,6 +1468,7 @@ export class WebServer extends EventEmitter {
|
||||
coordinateLayout = true
|
||||
): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
this.keepWarmService.remove(sessionId);
|
||||
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
|
||||
if (coordinateLayout && session && killMux && !pinned) {
|
||||
return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () =>
|
||||
@@ -1886,6 +1939,9 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
private async setupSessionListeners(session: Session): Promise<void> {
|
||||
// First keep-warm evaluation: publishes the effective opt-in for the Session Options
|
||||
// readout even before any cache report arrives.
|
||||
void this.keepWarmService.schedule(session.id);
|
||||
// Idempotent: the wiring exit handler detaches ALL listeners on every PTY exit
|
||||
// (removeSessionListenerRefs), so the re-attach routes (/interactive,
|
||||
// /interactive-respawn, /shell) call this again to restore observability
|
||||
@@ -1918,6 +1974,7 @@ export class WebServer extends EventEmitter {
|
||||
private buildSessionListenerDeps() {
|
||||
return {
|
||||
broadcast: this.broadcast.bind(this),
|
||||
keepWarmSchedule: (id: string) => void this.keepWarmService.schedule(id),
|
||||
batchTerminalData: this.batchTerminalData.bind(this),
|
||||
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
||||
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
||||
@@ -3611,6 +3668,10 @@ export class WebServer extends EventEmitter {
|
||||
// the launch conversation until the user types again, even though
|
||||
// the re-attached CLI is on a post-`/clear` one.
|
||||
lastSubmitAt: savedState?.lastSubmitAt,
|
||||
// The keep-warm choice and its cap's clock; with the restored `promptCache`
|
||||
// below, a future `expiresAt` re-arms the timer once the pane is attached.
|
||||
lastRealPromptAt: savedState?.lastRealPromptAt,
|
||||
keepWarmEnabled: savedState?.keepWarmEnabled,
|
||||
// Conversations this pane provably owned, oldest first. Its tail is
|
||||
// the conversation the CLI was on when the server stopped, which is
|
||||
// what a re-attach must point the viewer at instead of the launch id.
|
||||
@@ -4063,6 +4124,7 @@ export class WebServer extends EventEmitter {
|
||||
// response), so without this a 10-minute wait holds shutdown open.
|
||||
sessionWaits.cancelEverything();
|
||||
approvalInbox.stop();
|
||||
this.keepWarmService.stop();
|
||||
// Same reason as `cancelEverything` above: an in-flight wake is awaited by a request,
|
||||
// and `app.close()` (the last line of this method) does not abort in-flight requests —
|
||||
// so without this a restart during a wake waits out the readiness poll.
|
||||
|
||||
@@ -93,6 +93,12 @@ interface SessionListenerDeps {
|
||||
updateSessionName(sessionId: string, name: string): boolean;
|
||||
/** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
|
||||
isAutoNameEnabled(): Promise<boolean>;
|
||||
/**
|
||||
* Re-evaluate the prompt-cache keep-warm when the session goes idle: a turn just ended
|
||||
* (its cache report may already be in), and after a restart this is the first moment the
|
||||
* recovered pane is attached, so the restored report can arm its timer.
|
||||
*/
|
||||
keepWarmSchedule?(sessionId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,6 +268,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
sessionWaits.notifySignal(session.id, 'idle');
|
||||
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.keepWarmSchedule?.(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordIdle();
|
||||
|
||||
@@ -5,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