mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge pull request #666: feat(sessions): keep an idle session's prompt cache warm with a guarded keep-alive turn
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f80f107445
45 files changed
+3017
-59
No files matched your search
@@ -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,41 @@ 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} at every claude create and respawn, which decides the
|
||||
* launch's `idleCompaction` opt-out.
|
||||
*/
|
||||
export async function readKeepWarmSettings(): Promise<KeepWarmSettings> {
|
||||
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,
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -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, the pid and, when tmux
|
||||
@@ -124,6 +129,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 */
|
||||
@@ -177,6 +186,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 */
|
||||
@@ -416,11 +429,12 @@ export interface TerminalMultiplexer extends EventEmitter {
|
||||
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
|
||||
|
||||
/**
|
||||
* Plain text of the visible frame: no styles, no cursor query, no repaint
|
||||
* reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle
|
||||
* Plain text of the visible frame: no cursor query, no repaint
|
||||
* reconstruction, and no styles unless `styles` asks for them (SGR only, lines
|
||||
* still `\n`-separated). Deliberately cheaper than `capturePaneBuffer` because idle
|
||||
* detection calls it on a timer: it only needs to read what the CLI is
|
||||
* currently rendering, never to replay it into an xterm. Returns null when the
|
||||
* pane cannot be read.
|
||||
*/
|
||||
capturePaneText?(muxName: string, paneTarget?: string): string | null;
|
||||
capturePaneText?(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null;
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* @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;
|
||||
/**
|
||||
* 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. */
|
||||
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.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');
|
||||
// Transient, and nothing else re-triggers an idle session: an agent's long-poll waiter ends
|
||||
// without a turn, and so does a capture that failed once.
|
||||
if (f.automationActive) return wait(retryAt, 'respawn, Ralph or an agent is driving the session');
|
||||
if (f.composerEmpty === null) return wait(retryAt, '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' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a session could be nudged once the settings are known: its own switch is not off, its
|
||||
* launch reports the cache, and the last report is warm. Only such a session has anything to
|
||||
* lose while the settings cannot be read, so only it retries on its own.
|
||||
*/
|
||||
function couldNudge(s: KeepWarmSessionView): boolean {
|
||||
return (
|
||||
s.keepWarmEnabled !== false &&
|
||||
s.statusLineExporter === 'injected' &&
|
||||
!!s.promptCache?.warm &&
|
||||
!!s.promptCache.expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** Close the window `beginKeepAliveTurn()` opened, when the nudge never went out. */
|
||||
endKeepAliveTurn(): 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-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;
|
||||
}
|
||||
|
||||
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>();
|
||||
/** The evaluation running per session, and the one follow-up queued behind it. */
|
||||
private readonly running = new Map<string, Promise<void>>();
|
||||
private readonly followUps = new Map<string, Promise<void>>();
|
||||
/**
|
||||
* 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;
|
||||
/** The last settings read failure logged, so one that persists is logged once; cleared by a good read. */
|
||||
private settingsFailure: string | undefined;
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly ports: KeepWarmPorts) {}
|
||||
|
||||
schedule(sessionId: string): Promise<void> {
|
||||
if (this.stopped) return Promise.resolve();
|
||||
const running = this.running.get(sessionId);
|
||||
if (running) {
|
||||
// A trigger landing mid-evaluation (a toggle while the nudge is written, a settings change)
|
||||
// carries what that evaluation read too early, so one follow-up runs after it, shared by
|
||||
// every trigger that arrives meanwhile.
|
||||
let next = this.followUps.get(sessionId);
|
||||
if (!next) {
|
||||
next = running.then(() => {
|
||||
// `remove()` drops the queued follow-up: a deleted session gets no evaluation.
|
||||
if (this.followUps.get(sessionId) !== next) return;
|
||||
this.followUps.delete(sessionId);
|
||||
return this.schedule(sessionId);
|
||||
});
|
||||
this.followUps.set(sessionId, next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
const run = this.evaluate(sessionId)
|
||||
.catch((err) => {
|
||||
this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
// A failed write or pane read is transient, and an idle session gets no other trigger
|
||||
// until its next turn. (A failed settings read is handled in evaluate().)
|
||||
// Not after `remove()`, which drops this run: the session is being deleted.
|
||||
if (!this.stopped && this.running.get(sessionId) === run) this.arm(sessionId, Date.now() + KEEP_WARM_RETRY_MS);
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.running.get(sessionId) === run) this.running.delete(sessionId);
|
||||
});
|
||||
this.running.set(sessionId, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
rescheduleAll(): void {
|
||||
this.settings = undefined;
|
||||
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);
|
||||
this.running.delete(sessionId);
|
||||
this.followUps.delete(sessionId);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
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);
|
||||
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;
|
||||
let settings: KeepWarmSettings;
|
||||
try {
|
||||
settings = await this.readSettings();
|
||||
} catch (err) {
|
||||
// A read that lands mid-write is retried. A settings.json that stays broken (a hand edit
|
||||
// with a trailing comma) fails every read until it is fixed, so it is logged once rather
|
||||
// than once a minute per session, and only a session that could nudge retries.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message !== this.settingsFailure) {
|
||||
this.settingsFailure = message;
|
||||
this.ports.log?.(`[KeepWarm] ${id.slice(0, 8)}: cannot read the settings: ${message}`);
|
||||
}
|
||||
if (!cancelled() && couldNudge(session)) this.arm(id, Date.now() + KEEP_WARM_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
this.settingsFailure = undefined;
|
||||
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,
|
||||
// Off, the window means nothing, and it moves with every real prompt: carrying it would
|
||||
// broadcast a change on every turn of every session.
|
||||
...(enabled && { 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})`
|
||||
);
|
||||
// Sent: the nudge's own cache report re-schedules, and this timer is the fallback if none
|
||||
// arrives. Not sent: no turn is running, so the window closes now rather than at its
|
||||
// ceiling, and the retry lands inside the last minutes before the expiry, where a nudge
|
||||
// can still go out (a lead time later would be past it).
|
||||
let nextCheckAt = Date.now() + KEEP_WARM_LEAD_MS;
|
||||
if (!ok) {
|
||||
session.endKeepAliveTurn();
|
||||
const expiresAt = facts.cache?.expiresAt ?? 0;
|
||||
nextCheckAt = Math.min(Date.now() + KEEP_WARM_RETRY_MS, expiresAt - KEEP_WARM_LAST_CHANCE_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,85 @@ export function promptStillInComposer(screen: string, prompt: string, glyph: str
|
||||
return head.length > 0 && composer.startsWith(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* A styled frame as plain text. SGR goes first with its own pattern because tmux writes
|
||||
* colon sub-parameters (`ESC[4:3m`, a curly underline), which `stripAnsi` leaves behind.
|
||||
*/
|
||||
export function plainFrame(styled: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return stripAnsi(styled.replace(/\x1b\[[0-9;:]*m/g, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* `screen` (a `capture-pane -p -e` frame) without the text Claude Code draws in an empty
|
||||
* composer that nobody typed: its prompt suggestion, drawn faint (SGR 2), and the suggestion's
|
||||
* first letter, which sits in the reverse-video cursor cell (SGR 7) right before the faint rest.
|
||||
* A typed draft is never faint, so a cursor cell followed by anything else is kept. Every line
|
||||
* break is kept, so line `n` here is line `n` of the plain frame, and plain text passes through
|
||||
* unchanged. Other escapes are left for `stripAnsi`.
|
||||
*/
|
||||
function withoutSuggestion(screen: string): string {
|
||||
let faint = false;
|
||||
let inverse = false;
|
||||
let cursorCell = '';
|
||||
let out = '';
|
||||
// eslint-disable-next-line no-control-regex
|
||||
for (const m of screen.matchAll(/\x1b\[([0-9;:]*)m|[\s\S]/gu)) {
|
||||
if (m[1] !== undefined) {
|
||||
const params = m[1].split(';');
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
const p = params[i];
|
||||
if (p === '' || p === '0') faint = inverse = false;
|
||||
else if (p === '2') faint = true;
|
||||
else if (p === '22') faint = false;
|
||||
else if (p === '7') inverse = true;
|
||||
else if (p === '27') inverse = false;
|
||||
// An extended colour's own arguments (`38;5;n`, `38;2;r;g;b`) are not attributes.
|
||||
else if (p === '38' || p === '48' || p === '58') i += params[i + 1] === '5' ? 2 : params[i + 1] === '2' ? 4 : 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const ch = m[0];
|
||||
if (ch !== '\n' && faint) {
|
||||
cursorCell = '';
|
||||
continue;
|
||||
}
|
||||
out += cursorCell;
|
||||
cursorCell = '';
|
||||
if (ch !== '\n' && inverse) cursorCell = ch;
|
||||
else out += ch;
|
||||
}
|
||||
return out + cursorCell;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: its paste would land
|
||||
* in whatever draft sits there, and the Enter would submit both.
|
||||
*
|
||||
* Pass a STYLED frame (`capture-pane -p -e`): Claude Code's prompt suggestion (on by default)
|
||||
* fills an empty composer with faint text, and only the styling tells it from a typed draft.
|
||||
* The paste replaces a suggestion rather than joining it (measured on 2.1.300). A plain frame
|
||||
* still parses, but reads a suggestion as a draft.
|
||||
*/
|
||||
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 = plainFrame(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;
|
||||
const typed = plainFrame(withoutSuggestion(screen)).split('\n');
|
||||
return compact(typed[at].slice(glyph.length) + typed.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;
|
||||
|
||||
+126
-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 {
|
||||
@@ -453,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)`.
|
||||
@@ -597,6 +609,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 +826,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 +921,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 +1001,10 @@ 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. A state.json
|
||||
// from before the field has only `lastSubmitAt`, and every submit then was a real one.
|
||||
this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 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 +1509,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 +1548,66 @@ 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`. 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;
|
||||
}
|
||||
|
||||
beginKeepAliveTurn(windowMs: number): void {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +2103,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 +2154,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 +2523,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 +3004,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 +4752,16 @@ 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;
|
||||
} 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;
|
||||
}
|
||||
|
||||
@@ -4678,9 +4787,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+68
-7
@@ -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 {
|
||||
@@ -1095,6 +1098,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;
|
||||
@@ -1116,6 +1141,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;
|
||||
/**
|
||||
@@ -2291,6 +2318,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
|
||||
@@ -2351,6 +2400,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
keepWarm,
|
||||
spawnedByAgent,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
@@ -2414,6 +2465,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.
|
||||
@@ -2438,6 +2490,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
idleCompactionOff,
|
||||
sessionName: cliName,
|
||||
});
|
||||
|
||||
@@ -2583,6 +2636,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
attached: false,
|
||||
name,
|
||||
statusLine,
|
||||
idleCompactionOff,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
@@ -2693,6 +2747,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
unsetEnvKeys,
|
||||
effort,
|
||||
advisorModel,
|
||||
keepWarm,
|
||||
spawnedByAgent,
|
||||
remote,
|
||||
docker,
|
||||
cliName,
|
||||
@@ -2713,6 +2769,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({
|
||||
@@ -2734,6 +2791,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
idleCompactionOff,
|
||||
sessionName: cliName,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
@@ -2776,8 +2834,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;
|
||||
@@ -4163,17 +4222,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
/**
|
||||
* Plain visible-frame text for the working/idle probe (see `session.ts`).
|
||||
*
|
||||
* One `capture-pane` and nothing else: no `-e` styles, no `display-message`
|
||||
* cursor query, no repaint reconstruction: this feeds a regex, not a
|
||||
* terminal. Returns null in tests (no tmux) so callers fall back to their
|
||||
* stream heuristics rather than reading an empty screen as "not working".
|
||||
* One `capture-pane` and nothing else: no `display-message` cursor query, no
|
||||
* repaint reconstruction: this feeds a regex, not a terminal. `styles` adds
|
||||
* `-e` for a reader that must tell faint text from typed text (the keep-warm
|
||||
* composer check); lines stay `\n`-separated either way. Returns null in tests
|
||||
* (no tmux) so callers fall back to their stream heuristics rather than
|
||||
* reading an empty screen as "not working".
|
||||
*/
|
||||
capturePaneText(muxName: string, paneTarget?: string): string | null {
|
||||
capturePaneText(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null {
|
||||
if (IS_TEST_MODE) return null;
|
||||
const target = resolveTmuxPaneTarget(muxName, paneTarget);
|
||||
if (!target) return null;
|
||||
try {
|
||||
return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, {
|
||||
return execSync(`${this.tmux()} capture-pane -p${opts?.styles ? ' -e' : ''} -t ${shellescape(target)}`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
+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'],
|
||||
@@ -3513,8 +3514,10 @@ class CodemanApp {
|
||||
this.sendPendingCtrlL(data.id);
|
||||
if (data.id === this.activeSessionId) this._updateLocalEchoState();
|
||||
}
|
||||
// Start stuck detection timer (only if no respawn running)
|
||||
if (!this.respawnStatus[data.id]?.enabled) {
|
||||
// Start stuck detection timer (only if no respawn running). Not at the end of Codeman's
|
||||
// own keep-alive turn (`keepAlive`): the session has been waiting since the last real
|
||||
// turn, whose timer is still running or has already fired.
|
||||
if (!data.keepAlive && !this.respawnStatus[data.id]?.enabled) {
|
||||
const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000;
|
||||
clearTimeout(this.idleTimers.get(data.id));
|
||||
this.idleTimers.set(data.id, setTimeout(() => {
|
||||
@@ -3536,8 +3539,9 @@ class CodemanApp {
|
||||
this.sendPendingCtrlL(data.id);
|
||||
if (data.id === this.activeSessionId) this._updateLocalEchoState();
|
||||
}
|
||||
// Clear stuck detection timer
|
||||
const timer = this.idleTimers.get(data.id);
|
||||
// Clear stuck detection timer, unless this is Codeman's own keep-alive turn, which is
|
||||
// no sign of life from the agent's side.
|
||||
const timer = data.keepAlive ? null : this.idleTimers.get(data.id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.idleTimers.delete(data.id);
|
||||
|
||||
@@ -733,7 +733,7 @@ function computeConnectionLossUi(input) {
|
||||
// it says why instead of "not reported yet" forever.
|
||||
const PROMPT_CACHE_NOT_COLLECTED = {
|
||||
'collection-off':
|
||||
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
|
||||
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings → Header & Panels, then start a new session; this one cannot pick it up.',
|
||||
'workspace-statusline':
|
||||
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
|
||||
remote: 'Not collected for remote and Docker sessions.',
|
||||
@@ -763,6 +763,51 @@ function formatPromptCacheStatus(cache, now, statusLine) {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Session Options → Respawn → Prompt cache → Status: the keep-warm line under the cache
|
||||
// readout, saying what the keep-warm service is doing (`session.keepWarm`, published by the
|
||||
// server on `session:keepWarm`), compact and dot-separated. Empty when there is nothing to
|
||||
// say: switched off, not published yet, or a launch without the statusLine exporter, where
|
||||
// the readout's own "Not collected" sentence on the same row already says why and
|
||||
// session-ui.js greys the switch. Pure: no DOM, `now` is passed in.
|
||||
function formatKeepWarmStatus(status, now, statusLine) {
|
||||
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return '';
|
||||
if (!status || typeof status.state !== 'string') return '';
|
||||
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const keepWarmWindow = Number.isFinite(status.budgetEndsAt)
|
||||
? status.budgetEndsAt > now
|
||||
? `stops ${clock(status.budgetEndsAt)}`
|
||||
: 'window closed'
|
||||
: '';
|
||||
// Keep-warm switched on or off after launch: the `idleCompaction` opt-out rides only a launch.
|
||||
const idleCompaction =
|
||||
status.enabled && status.idleCompactionOff === false
|
||||
? 'idle compaction on until relaunch'
|
||||
: !status.enabled && status.idleCompactionOff === true
|
||||
? 'idle compaction off until relaunch'
|
||||
: '';
|
||||
let parts;
|
||||
switch (status.state) {
|
||||
case 'off': {
|
||||
const reason = status.reason === 'off' ? '' : status.reason;
|
||||
if (!reason && !idleCompaction) return '';
|
||||
parts = ['off', reason, idleCompaction];
|
||||
break;
|
||||
}
|
||||
case 'armed':
|
||||
parts = [`next ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
|
||||
break;
|
||||
case 'waiting':
|
||||
parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
|
||||
break;
|
||||
case 'nudged':
|
||||
parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, keepWarmWindow, idleCompaction];
|
||||
break;
|
||||
default:
|
||||
parts = ['stopped', status.reason || 'nothing to do', idleCompaction];
|
||||
}
|
||||
return `Keep-alive: ${parts.filter(Boolean).join(' \u00b7 ')}`;
|
||||
}
|
||||
|
||||
// SSE staleness policy: is this stream a zombie?
|
||||
//
|
||||
// An EventSource that stops delivering does not always error. A proxy that
|
||||
@@ -1503,6 +1548,8 @@ if (typeof window !== 'undefined') {
|
||||
};
|
||||
window.CodemanPromptCache = {
|
||||
format: formatPromptCacheStatus,
|
||||
formatKeepWarm: formatKeepWarmStatus,
|
||||
keepWarmUnavailable: (statusLine) => !!PROMPT_CACHE_NOT_COLLECTED[statusLine],
|
||||
};
|
||||
window.CodemanSessionOrder = {
|
||||
RANK: SESSION_ACTIVITY_RANK,
|
||||
@@ -1699,6 +1746,7 @@ const SSE_EVENTS = {
|
||||
SESSION_RUNNING: 'session:running',
|
||||
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
|
||||
SESSION_PROMPT_CACHE: 'session:promptCache',
|
||||
SESSION_KEEP_WARM: 'session:keepWarm',
|
||||
|
||||
// Scheduled runs
|
||||
SCHEDULED_CREATED: 'scheduled:created',
|
||||
|
||||
@@ -962,6 +962,19 @@
|
||||
'Enable Respawn': '启用重生',
|
||||
'Stop Respawn': '停止重生',
|
||||
'Auto-resume when usage limit resets': '使用限制重置后自动继续',
|
||||
// Prompt-cache keep-warm (Session Options → Respawn, App Settings → System → Automation, session-ui.js toasts).
|
||||
'Keep the cache warm while idle': '空闲时保持缓存预热',
|
||||
"Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.":
|
||||
'在缓存即将过期前发送一行保活消息,让您的下一条提示仍能从缓存读取。仅在空闲时运行,最长不超过“应用设置 → 系统 → 自动化”中的上限;在启动前开启还会关闭 Claude Code 自带的空闲压缩。',
|
||||
'Keep prompt caches warm': '保持提示缓存预热',
|
||||
"Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.":
|
||||
'未在“重生”标签页中单独设置的 Claude 会话的默认值(由智能体启动的会话需单独开启):在空闲会话的提示缓存即将过期前发送一行保活消息,让下一条提示仍从缓存读取对话,而不必重新处理。',
|
||||
'Keep warm for at most': '最长保持预热',
|
||||
'Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.':
|
||||
'会话中最后一条提示之后发送保活消息的小时数;超过之后任由缓存过期。一次保活的开销只是它所避免的重新写入的一小部分,因此几个小时的保活是值得的,整夜的空档则不值得。',
|
||||
hours: '小时',
|
||||
'Prompt-cache keep-warm enabled': '提示缓存预热已开启',
|
||||
'Prompt-cache keep-warm disabled': '提示缓存预热已关闭',
|
||||
'Auto-restart sessions when context fills up (usually not needed)': '上下文已满时自动重启会话(通常不需要)',
|
||||
'Auto-Compact': '自动压缩',
|
||||
'Auto-Clear': '自动清空',
|
||||
|
||||
@@ -1339,8 +1339,19 @@
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Status</span>
|
||||
<span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span>
|
||||
<span class="set-row-desc" id="keepWarmStatus" data-i18n-skip hidden></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts);
|
||||
the default for sessions that have not chosen lives in App Settings →
|
||||
Automation. Its status shares the Status row above: session-ui.js updateKeepWarmStatus. -->
|
||||
<div class="set-row">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep the cache warm while idle</span>
|
||||
<span class="set-row-desc">Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="modalKeepWarmEnabled" onchange="app.autoSaveKeepWarm()"><span class="slider"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3257,6 +3268,23 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsImageWatcherEnabled"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="prompt cache keep warm keep-alive nudge idle expire">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep prompt caches warm</span>
|
||||
<span class="set-row-desc">Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsPromptCacheKeepWarm"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="prompt cache keep warm hours limit cap window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Keep warm for at most</span>
|
||||
<span class="set-row-desc">Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.</span>
|
||||
</div>
|
||||
<div class="set-row-actions">
|
||||
<input type="number" id="appSettingsPromptCacheKeepWarmMaxHours" class="set-num" min="0.5" max="24" step="0.5" value="2">
|
||||
<span class="set-unit">hours</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2631,6 +2631,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
|
||||
this.updateAutoResumeStatus(sessionId);
|
||||
this.updatePromptCacheStatus(sessionId);
|
||||
// Effective opt-in: the server publishes it in the status (session flag, else the app default).
|
||||
document.getElementById('modalKeepWarmEnabled').checked =
|
||||
session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false;
|
||||
this.updateKeepWarmStatus(sessionId);
|
||||
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
|
||||
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
|
||||
|
||||
@@ -2915,6 +2919,59 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.updatePromptCacheStatus(sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Session Options → Respawn → Prompt cache: the keep-warm line on the Status row (what the
|
||||
* keep-warm service is doing, `session.keepWarm`, worded by `CodemanPromptCache.formatKeepWarm`,
|
||||
* hidden when it has nothing to say) and the state of the "Keep the cache warm while idle" switch.
|
||||
*/
|
||||
updateKeepWarmStatus(sessionId) {
|
||||
const el = document.getElementById('keepWarmStatus');
|
||||
if (!el || this.editingSessionId !== sessionId) return;
|
||||
const session = this.sessions.get(sessionId);
|
||||
const status = session?.keepWarm;
|
||||
const text = window.CodemanPromptCache.formatKeepWarm(status, Date.now(), session?.statusLineExporter);
|
||||
el.textContent = text;
|
||||
el.hidden = !text;
|
||||
// A launch without cache reports can never keep warm: grey the row and refuse to switch it on,
|
||||
// but a switch that is already on stays reachable so it can be turned off. Same inert-switch
|
||||
// pattern as the context switch in settings-ui.js _applyModelSelection().
|
||||
const box = document.getElementById('modalKeepWarmEnabled');
|
||||
if (!box) return;
|
||||
const inert = window.CodemanPromptCache.keepWarmUnavailable(session?.statusLineExporter);
|
||||
box.closest('.set-row')?.classList.toggle('set-row-disabled', inert);
|
||||
box.disabled = inert && !box.checked;
|
||||
},
|
||||
|
||||
_onSessionKeepWarm(data) {
|
||||
const { sessionId, ...status } = data;
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
session.keepWarm = status;
|
||||
if (this.editingSessionId === sessionId) {
|
||||
const box = document.getElementById('modalKeepWarmEnabled');
|
||||
if (box) box.checked = status.enabled;
|
||||
}
|
||||
this.updateKeepWarmStatus(sessionId);
|
||||
},
|
||||
|
||||
async autoSaveKeepWarm() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalKeepWarmEnabled').checked;
|
||||
try {
|
||||
// _apiPost unwraps the envelope; the route answers with the status for the NEW choice.
|
||||
const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/keep-warm`, { enabled });
|
||||
const session = this.sessions.get(this.editingSessionId);
|
||||
if (session) {
|
||||
session.keepWarmEnabled = enabled;
|
||||
if (data?.keepWarm?.status) session.keepWarm = data.keepWarm.status;
|
||||
}
|
||||
this.updateKeepWarmStatus(this.editingSessionId);
|
||||
this.showToast(`Prompt-cache keep-warm ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
||||
} catch (err) {
|
||||
this.showToast('Failed to toggle keep-warm: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async toggleSessionImageWatcher() {
|
||||
if (!this.editingSessionId) return;
|
||||
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
|
||||
|
||||
@@ -72,6 +72,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
_onHookStop(data) {
|
||||
// The end of Codeman's own keep-alive turn (prompt-cache keep-warm): nothing finished
|
||||
// that a person asked for, and an idle alert still pending is theirs to spend, so this
|
||||
// neither clears it nor raises "Response Complete".
|
||||
if (data.acknowledgedReason) return;
|
||||
// Clear all pending hooks when Claude finishes responding
|
||||
if (data.sessionId) {
|
||||
this.clearPendingHooks(data.sessionId);
|
||||
@@ -597,6 +601,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
|
||||
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
|
||||
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
|
||||
// Prompt-cache keep-warm: synced, default OFF, cap default 2 h (DEFAULT_KEEP_WARM_MAX_HOURS).
|
||||
document.getElementById('appSettingsPromptCacheKeepWarm').checked = settings.promptCacheKeepWarm === true;
|
||||
document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value = settings.promptCacheKeepWarmMaxHours ?? 2;
|
||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||
this.loadTunnelStatus();
|
||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||
@@ -2760,6 +2767,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||
promptCacheKeepWarm: document.getElementById('appSettingsPromptCacheKeepWarm').checked,
|
||||
promptCacheKeepWarmMaxHours:
|
||||
// Clamped to the schema's range: one value outside it fails the whole save.
|
||||
Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)),
|
||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
|
||||
|
||||
@@ -143,7 +143,8 @@ export function registerHookEventRoutes(
|
||||
// bumped only by input that flowed through Codeman's own write path, so
|
||||
// it read 0 forever for those panes and every consumer of "when did this
|
||||
// pane last submit" silently degraded.
|
||||
session?.markPromptSubmitted();
|
||||
// The prompt text tells a person's prompt from the keep-alive's (prompt-cache-keepwarm.ts).
|
||||
session?.markPromptSubmitted(typeof data.prompt === 'string' ? data.prompt : undefined);
|
||||
}
|
||||
// Persist when the conversation actually moved: `/clear` emits no
|
||||
// completion event, so without this the successor id is lost on restart
|
||||
@@ -183,9 +184,21 @@ export function registerHookEventRoutes(
|
||||
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
|
||||
// is worth even less on a phone than in a tab.
|
||||
let acknowledgedReason: string | undefined;
|
||||
// A turn Codeman started itself (the prompt-cache keep-alive, prompt-cache-keepwarm.ts)
|
||||
// ends with the same stop hook and idle prompt as a real one. Both open pre-acknowledged,
|
||||
// so no tab, toast or push asks a person to look at a session that wants nothing from them.
|
||||
const keepAliveReason =
|
||||
session?.keepAliveTurnActive && (event === 'stop' || event === 'idle_prompt') ? 'cache keep-alive' : undefined;
|
||||
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
|
||||
// Only a human spends an idle alert, so a keep-alive turn leaves a pending idle item exactly
|
||||
// as it found it: its stop does not resolve it, and its idle prompt does not replace it (a
|
||||
// replacement would forget the item was unseen, or that a person had seen it). With no idle
|
||||
// item pending, the keep-alive's idle prompt opens a pre-acknowledged one.
|
||||
const keepsPendingIdle = keepAliveReason !== undefined && approvalInbox.getForSession(sessionId)?.kind === 'idle';
|
||||
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
if (approvalKind) {
|
||||
if (approvalKind === 'idle' && keepsPendingIdle) {
|
||||
// Left in place: no new item, and the broadcast below carries the keep-alive reason.
|
||||
} else if (approvalKind) {
|
||||
const toolInput =
|
||||
safeData.tool_input && typeof safeData.tool_input === 'object'
|
||||
? (safeData.tool_input as Record<string, unknown>)
|
||||
@@ -209,6 +222,7 @@ export function registerHookEventRoutes(
|
||||
// session that is watching its own work opens acknowledged, so it never arms an
|
||||
// alert nobody can answer; notePrompt() carries the whole reasoning.
|
||||
watching: session.watching,
|
||||
preAcknowledged: keepAliveReason,
|
||||
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
|
||||
// the fallback for direct-PTY sessions and the no-op test mux.
|
||||
capture: () => {
|
||||
@@ -219,11 +233,16 @@ export function registerHookEventRoutes(
|
||||
});
|
||||
approvalId = item.id;
|
||||
acknowledgedReason = item.acknowledgedReason;
|
||||
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
|
||||
} else if (APPROVAL_RESOLVING_EVENTS.has(event) && !keepsPendingIdle) {
|
||||
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgedReason ??= keepAliveReason;
|
||||
// The keep-alive turn's own idle prompt is the last thing it sends, so the window ends here
|
||||
// rather than on a fixed clock that a slow turn could outlast.
|
||||
if (keepAliveReason && event === 'idle_prompt') session?.endKeepAliveTurn();
|
||||
|
||||
ctx.broadcast(`hook:${event}`, {
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
@@ -259,9 +278,11 @@ export function registerHookEventRoutes(
|
||||
// Claude pane; only the ones where the conversation actually moved (a /clear
|
||||
// successor) carry information, and recording the rest would push a row into
|
||||
// the Summary timeline and /api/search per turn and evict useful rows from
|
||||
// the 1000-event FIFO (#367 merge-time fix).
|
||||
// the 1000-event FIFO (#367 merge-time fix). A keep-alive turn's stop and idle prompt are
|
||||
// left out too: the summary files a `stop` as a warning, which the Away digest lists under
|
||||
// Needs Attention, and that turn is Codeman's own, not the agent's.
|
||||
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
|
||||
if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) {
|
||||
if (summaryTracker && !keepAliveReason && (event !== 'prompt_submitted' || conversationChanged)) {
|
||||
summaryTracker.recordHookEvent(event, safeData);
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes
|
||||
(saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ??
|
||||
saved.attachmentHistory,
|
||||
lastSubmitAt: saved.lastSubmitAt,
|
||||
lastRealPromptAt: saved.lastRealPromptAt,
|
||||
keepWarmEnabled: saved.keepWarmEnabled,
|
||||
claudeSessionChain: saved.claudeSessionChain,
|
||||
lastActivityAt: saved.lastActivityAt,
|
||||
owner: saved.owner,
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
AutoClearSchema,
|
||||
AutoCompactSchema,
|
||||
AutoResumeSchema,
|
||||
KeepWarmSchema,
|
||||
PinSessionSchema,
|
||||
ImageWatcherSchema,
|
||||
FlickerFilterSchema,
|
||||
@@ -155,7 +156,15 @@ import {
|
||||
setHistoryIndexRefresher,
|
||||
setHistorySessionIndex,
|
||||
} from '../session-history-index.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
|
||||
import type {
|
||||
SessionPort,
|
||||
EventPort,
|
||||
ConfigPort,
|
||||
InfraPort,
|
||||
AuthPort,
|
||||
TabLayoutPort,
|
||||
KeepWarmPort,
|
||||
} from '../ports/index.js';
|
||||
import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
@@ -1024,7 +1033,7 @@ function wakeableHost(host: RemoteHost): WakeableRemote {
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort,
|
||||
/** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */
|
||||
options: { remoteWake?: RemoteWakeRegistry } = {}
|
||||
): RemoteWakeRegistry {
|
||||
@@ -3556,6 +3565,39 @@ export function registerSessionRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Prompt-cache keep-warm ==========
|
||||
|
||||
// Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts). `enabled: null`
|
||||
// clears the choice so the app-wide default applies again. Refused for a CLI that reports
|
||||
// no cache telemetry on its statusline: without `expires_at` there is nothing to schedule.
|
||||
app.post('/api/sessions/:id/keep-warm', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = parseBody(KeepWarmSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
if (!getCli(session.mode)?.capabilities.statusLineTelemetry) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Prompt-cache keep-warm needs the cache telemetry this CLI does not report'
|
||||
);
|
||||
}
|
||||
|
||||
session.setKeepWarmEnabled(body.enabled ?? undefined);
|
||||
// Re-evaluate BEFORE the state broadcast and the response: both carry `keepWarm`, and a
|
||||
// page that received the previous status would overwrite the one the toggle just produced.
|
||||
await ctx.keepWarm.schedule(id);
|
||||
persistAndBroadcastSession(ctx, session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
keepWarm: {
|
||||
enabled: session.keepWarmEnabled ?? null,
|
||||
status: session.keepWarm,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ========== Auto-Resume (usage-limit pause) ==========
|
||||
|
||||
app.post('/api/sessions/:id/auto-resume', async (req) => {
|
||||
|
||||
@@ -29,10 +29,10 @@ import {
|
||||
} from '../../usage-telemetry.js';
|
||||
import { SessionPromptCache, SessionStatusTelemetry } from '../sse-events.js';
|
||||
import { setLatestPlanUsage } from '../plan-usage-latest.js';
|
||||
import type { SessionPort, EventPort } from '../ports/index.js';
|
||||
import type { SessionPort, EventPort, KeepWarmPort } from '../ports/index.js';
|
||||
import { getCli } from '../../config/cli-registry/index.js';
|
||||
|
||||
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & KeepWarmPort): void {
|
||||
// Last broadcast telemetry signature per session — the statusline fires on
|
||||
// every assistant message, so we only rebroadcast when the value changes.
|
||||
const lastSig = new Map<string, string>();
|
||||
@@ -74,10 +74,14 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
||||
// arrive out of order; a stale one then briefly overwrites newer state until the next
|
||||
// render corrects it. Accepted for a display-only readout rather than ordered here.
|
||||
const promptCache = reportsStatusLine ? parsePromptCache(payload) : null;
|
||||
if (promptCache && session?.setPromptCache(promptCache)) {
|
||||
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
|
||||
// Persisted on change, like a model change, so a crash restores the latest report.
|
||||
ctx.persistSessionState(session);
|
||||
if (promptCache) {
|
||||
if (session?.setPromptCache(promptCache)) {
|
||||
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
|
||||
// Persisted on change, like a model change, so a crash restores the latest report.
|
||||
ctx.persistSessionState(session);
|
||||
}
|
||||
// Every report moves `expires_at`, so the keep-warm re-evaluates on each one.
|
||||
void ctx.keepWarm.schedule(sessionId);
|
||||
}
|
||||
|
||||
// Plan-usage limits (account-wide) → broadcast to the header chip, when
|
||||
|
||||
@@ -51,7 +51,15 @@ import { SseEvent } from '../sse-events.js';
|
||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
||||
|
||||
import { getRepositoryStatus } from '../repo-status.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
|
||||
import type {
|
||||
SessionPort,
|
||||
EventPort,
|
||||
ConfigPort,
|
||||
InfraPort,
|
||||
AuthPort,
|
||||
TabLayoutPort,
|
||||
KeepWarmPort,
|
||||
} from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
|
||||
@@ -168,7 +176,7 @@ export function settingsForNonAdmin(settings: unknown): unknown {
|
||||
|
||||
export function registerSystemRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort
|
||||
): void {
|
||||
const windowStatesPath = dataPath('subagent-window-states.json');
|
||||
const parentMapPath = dataPath('subagent-parents.json');
|
||||
@@ -1103,6 +1111,15 @@ export function registerSystemRoutes(
|
||||
// drift. Same convention as the tmuxHistoryLimit block above.
|
||||
// Handle subagent tracking toggle dynamically
|
||||
toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher');
|
||||
// The prompt-cache keep-warm keeps its default and cap in memory; a changed default or cap
|
||||
// makes it re-read them and re-evaluate every session now rather than at its next cache report.
|
||||
// Compared on `merged`, not tested for in the body: the browser sends both keys on every save.
|
||||
if (
|
||||
merged.promptCacheKeepWarm !== existing.promptCacheKeepWarm ||
|
||||
merged.promptCacheKeepWarmMaxHours !== existing.promptCacheKeepWarmMaxHours
|
||||
) {
|
||||
ctx.keepWarm.rescheduleAll();
|
||||
}
|
||||
|
||||
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
|
||||
// Either the docked panel OR the floating windows keep the watcher running.
|
||||
|
||||
@@ -1483,6 +1483,10 @@ export const SettingsUpdateSchema = z
|
||||
* broadcast and /api/search, which is the user's choice to make.
|
||||
*/
|
||||
autoNameSessions: z.boolean().optional(),
|
||||
/** Prompt-cache keep-warm default for sessions that have not chosen (Session Options overrides it). */
|
||||
promptCacheKeepWarm: z.boolean().optional(),
|
||||
/** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */
|
||||
promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(),
|
||||
/**
|
||||
* Spawned tabs follow their parent: a session created by another one keeps
|
||||
* following its parent's tab (into its group, moving with it) until moved by
|
||||
@@ -1871,6 +1875,9 @@ export const AutoResumeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */
|
||||
export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() });
|
||||
|
||||
/** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */
|
||||
export const PinSessionSchema = z.object({
|
||||
pinned: z.boolean(),
|
||||
|
||||
+64
-1
@@ -96,7 +96,12 @@ import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||
import { getLifecycleLog } from '../session-lifecycle-log.js';
|
||||
import { applyWorkspaceHooks, pruneAgentSessionPreambles, removeAgentSessionPreamble } from '../hooks-config.js';
|
||||
import {
|
||||
applyWorkspaceHooks,
|
||||
pruneAgentSessionPreambles,
|
||||
readKeepWarmSettingsOrThrow,
|
||||
removeAgentSessionPreamble,
|
||||
} from '../hooks-config.js';
|
||||
import { PushSubscriptionStore } from '../push-store.js';
|
||||
import webpush from 'web-push';
|
||||
import { SseStreamManager } from './sse-stream-manager.js';
|
||||
@@ -113,6 +118,9 @@ import { intentStore } from '../intent-store.js';
|
||||
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js';
|
||||
import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js';
|
||||
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
|
||||
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
|
||||
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
|
||||
import {
|
||||
wireRespawnListeners,
|
||||
@@ -317,6 +325,50 @@ export class WebServer extends EventEmitter {
|
||||
private _customModelDisplacedNotified: Set<string> = new Set();
|
||||
/** Cron service (assigned in setupRoutes). */
|
||||
private cronService!: CronService;
|
||||
// Prompt-cache keep-warm (prompt-cache-keepwarm.ts): the rulebook and timers live in the
|
||||
// service; the server only lends it the facts a decision needs. A field initializer, not a
|
||||
// setupRoutes() step: cleanup and listener wiring reach it on servers that never set up routes
|
||||
// (the pane-exit and discard tests build a bare WebServer), and every port reads `this.*` lazily.
|
||||
private readonly keepWarmService = new PromptCacheKeepWarmService({
|
||||
getSession: (id) => this.sessions.get(id),
|
||||
sessionIds: () => this.sessions.keys(),
|
||||
readSettings: readKeepWarmSettingsOrThrow,
|
||||
isAutomationActive: (id) => {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return true;
|
||||
return (
|
||||
(this.respawnControllers.get(id)?.isRunning ?? false) ||
|
||||
session.ralphTracker.loopState.active ||
|
||||
session.currentTaskId !== null ||
|
||||
sessionWaits.signalWaiterCount(id) > 0
|
||||
);
|
||||
},
|
||||
hasPendingApproval: (id) =>
|
||||
approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
|
||||
readPane: (view) => {
|
||||
const session = view as Session;
|
||||
const muxName = session.muxName;
|
||||
// `capture-pane -p -e`: the visible frame with its styles, which is how `composerIsEmpty()`
|
||||
// tells Claude Code's faint prompt suggestion from a typed draft.
|
||||
const text = muxName ? (this.mux.capturePaneText?.(muxName, undefined, { styles: true }) ?? null) : null;
|
||||
if (!text) return { composerEmpty: null, limitShown: false };
|
||||
const limit = detectUsageLimitPause(plainFrame(text));
|
||||
return {
|
||||
composerEmpty: composerIsEmpty(text, getCli(session.mode)?.capabilities.workDetect?.promptGlyph ?? '') ?? null,
|
||||
limitShown: limit !== null && limit.resetAt > Date.now(),
|
||||
};
|
||||
},
|
||||
publish: (id, status) => {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session?.setKeepWarmStatus(status)) return;
|
||||
this.broadcast(SseEvent.SessionKeepWarm, { sessionId: id, ...status });
|
||||
// Change-only, so one line per transition names whichever guard holds it now.
|
||||
if (status.state !== 'off' || status.reason !== 'off') {
|
||||
console.log(`[KeepWarm] ${id.slice(0, 8)}: ${status.state}${status.reason ? ` (${status.reason})` : ''}`);
|
||||
}
|
||||
},
|
||||
log: (message) => console.log(message),
|
||||
});
|
||||
/**
|
||||
* Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME
|
||||
* only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would
|
||||
@@ -747,6 +799,7 @@ export class WebServer extends EventEmitter {
|
||||
return {
|
||||
// SessionPort
|
||||
sessions: this.sessions as ReadonlyMap<string, Session>,
|
||||
keepWarm: this.keepWarmService,
|
||||
addSession: this.registerSessionWithLayout.bind(this),
|
||||
tabLayouts: this.tabLayouts,
|
||||
cleanupSession: this.cleanupSession.bind(this),
|
||||
@@ -1416,6 +1469,7 @@ export class WebServer extends EventEmitter {
|
||||
coordinateLayout = true
|
||||
): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
this.keepWarmService.remove(sessionId);
|
||||
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
|
||||
if (coordinateLayout && session && killMux && !pinned) {
|
||||
return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () =>
|
||||
@@ -1886,6 +1940,9 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
private async setupSessionListeners(session: Session): Promise<void> {
|
||||
// First keep-warm evaluation: publishes the effective opt-in for the Session Options
|
||||
// readout even before any cache report arrives.
|
||||
void this.keepWarmService.schedule(session.id);
|
||||
// Idempotent: the wiring exit handler detaches ALL listeners on every PTY exit
|
||||
// (removeSessionListenerRefs), so the re-attach routes (/interactive,
|
||||
// /interactive-respawn, /shell) call this again to restore observability
|
||||
@@ -1918,6 +1975,7 @@ export class WebServer extends EventEmitter {
|
||||
private buildSessionListenerDeps() {
|
||||
return {
|
||||
broadcast: this.broadcast.bind(this),
|
||||
keepWarmSchedule: (id: string) => void this.keepWarmService.schedule(id),
|
||||
batchTerminalData: this.batchTerminalData.bind(this),
|
||||
batchTaskUpdate: this.batchTaskUpdate.bind(this),
|
||||
broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this),
|
||||
@@ -3611,6 +3669,10 @@ export class WebServer extends EventEmitter {
|
||||
// the launch conversation until the user types again, even though
|
||||
// the re-attached CLI is on a post-`/clear` one.
|
||||
lastSubmitAt: savedState?.lastSubmitAt,
|
||||
// The keep-warm choice and its cap's clock; with the restored `promptCache`
|
||||
// below, a future `expiresAt` re-arms the timer once the pane is attached.
|
||||
lastRealPromptAt: savedState?.lastRealPromptAt,
|
||||
keepWarmEnabled: savedState?.keepWarmEnabled,
|
||||
// Conversations this pane provably owned, oldest first. Its tail is
|
||||
// the conversation the CLI was on when the server stopped, which is
|
||||
// what a re-attach must point the viewer at instead of the launch id.
|
||||
@@ -4063,6 +4125,7 @@ export class WebServer extends EventEmitter {
|
||||
// response), so without this a 10-minute wait holds shutdown open.
|
||||
sessionWaits.cancelEverything();
|
||||
approvalInbox.stop();
|
||||
this.keepWarmService.stop();
|
||||
// Same reason as `cancelEverything` above: an in-flight wake is awaited by a request,
|
||||
// and `app.close()` (the last line of this method) does not abort in-flight requests —
|
||||
// so without this a restart during a wake waits out the readiness poll.
|
||||
|
||||
@@ -93,6 +93,12 @@ interface SessionListenerDeps {
|
||||
updateSessionName(sessionId: string, name: string): boolean;
|
||||
/** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */
|
||||
isAutoNameEnabled(): Promise<boolean>;
|
||||
/**
|
||||
* Re-evaluate the prompt-cache keep-warm when the session goes idle: a turn just ended
|
||||
* (its cache report may already be in), and after a restart this is the first moment the
|
||||
* recovered pane is attached, so the restored report can arm its timer.
|
||||
*/
|
||||
keepWarmSchedule?(sessionId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,7 +240,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
// kind: `working` is heuristic and can flap mid-turn, so clearing a
|
||||
// pending permission/question dialog on the signal ALONE would
|
||||
// false-clear real approvals.
|
||||
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||
// Not for Codeman's own keep-alive turn: only a human spends an idle alert.
|
||||
if (!session.keepAliveTurnActive) approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||
// A permission/question dialog gets the pane-VERIFIED variant instead:
|
||||
// the signal only decides when to look, `verifyStillAnswerable` re-reads
|
||||
// the screen and resolves only when the dialog is really gone. Without
|
||||
@@ -244,7 +251,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
// open. `stop` was the first thing to clear it, which on a long turn is
|
||||
// minutes away.
|
||||
approvalInbox.resolveIfDialogGone(session.id);
|
||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
// `keepAlive` tells the page this turn is Codeman's own keep-alive (stuck detection ignores it).
|
||||
deps.broadcast(SseEvent.SessionWorking, {
|
||||
id: session.id,
|
||||
...(session.keepAliveTurnActive && { keepAlive: true }),
|
||||
});
|
||||
// Full state ride-along: the home screens sort the running group on
|
||||
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
||||
// with (a turn started after page load ranks by the PREVIOUS turn's
|
||||
@@ -260,8 +271,9 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
/** Broadcasts `session:idle` — Claude finished processing, waiting for input */
|
||||
idle: () => {
|
||||
sessionWaits.notifySignal(session.id, 'idle');
|
||||
deps.broadcast(SseEvent.SessionIdle, { id: session.id });
|
||||
deps.broadcast(SseEvent.SessionIdle, { id: session.id, ...(session.keepAliveTurnActive && { keepAlive: true }) });
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
deps.keepWarmSchedule?.(session.id);
|
||||
const tracker = deps.getRunSummaryTracker(session.id);
|
||||
if (tracker) {
|
||||
tracker.recordIdle();
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* 162 event constants organized by category:
|
||||
* 163 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Transport** (1): sse:heartbeat
|
||||
* - **Session lifecycle** (24): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session lifecycle** (25): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
* - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate
|
||||
* - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded
|
||||
@@ -122,6 +122,8 @@ export const SessionRunning = 'session:running' as const;
|
||||
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
|
||||
/** Prompt-cache state of one session's main conversation changed (`{ sessionId, ...PromptCacheStatus }`). */
|
||||
export const SessionPromptCache = 'session:promptCache' as const;
|
||||
/** The prompt-cache keep-warm's status for one session changed (`{ sessionId, ...KeepWarmStatus }`). */
|
||||
export const SessionKeepWarm = 'session:keepWarm' as const;
|
||||
|
||||
// ─── Session: Ralph ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -534,6 +536,7 @@ export const SseEvent = {
|
||||
SessionRunning,
|
||||
SessionStatusTelemetry,
|
||||
SessionPromptCache,
|
||||
SessionKeepWarm,
|
||||
|
||||
// Session: Ralph
|
||||
SessionRalphLoopUpdate,
|
||||
|
||||
Reference in new issue
Block a user