mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
feat(sessions): keep an idle session's prompt cache warm with a guarded keep-alive turn
Claude Code lets a subscription's one-hour prompt cache expire after an hour idle, and the first turn back re-writes the whole conversation at the cache-write rate; nearly every gap over an hour goes cold, while the saving is concentrated in gaps of one to four hours. Codeman now sends one short keep-alive turn five minutes before the expiry Claude Code reports on its statusline, so the next real prompt still reads the conversation from cache, and stops two hours (configurable) after the last real prompt. Codeman owns the idle policy for these sessions, as agreed in Discussion #554: a launch with keep-warm on also carries idleCompaction: false in its one --settings JSON, so Claude Code's own idle compaction cannot replace the conversation with a summary a minute before the nudge. It is decided once per launch (keep-warm on, the statusLine exporter injected, Claude Code 2.1.290 or newer, an unknown version fails closed like --name), recorded on the mux record, and picked up by any relaunch; a launch with keep-warm off is byte-identical. A session switched on after launch is kept warm best-effort and the readout says so. Every guard lives in one pure rulebook: the opt-in (an agent-spawned session does not inherit the app default), a launch that reports cache state, a warm 1h cache with enough tokens at stake and no compaction this gap, a live pane, no usage-limit pause, no respawn, Ralph, task or waiting agent, idle, no open dialog, an empty composer and no recent keystroke. The nudge is a submit and moves lastSubmitAt, which the response viewer's conversation correlation needs; the cap counts from its own persisted lastRealPromptAt instead. Read My Mind drops the exact nudge text, and the turn's stop hook and idle prompt open pre-acknowledged so nothing alerts. The last cache report and the clock survive a restart, so the timer re-arms.
This commit is contained in:
1 parent
e63bb65b36
commit
71a51df2e2
42 files changed
+2131
-38
No files matched your search
+60
-1
@@ -67,12 +67,15 @@ import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
|
||||
import {
|
||||
buildSpawnCommandFromRegistry,
|
||||
configSetenvValues,
|
||||
launchGatePasses,
|
||||
legacyConfigForMode,
|
||||
} from './session-cli-registry-bridge.js';
|
||||
import { keepWarmOn } from './prompt-cache-keepwarm.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import {
|
||||
resolveStatusLineCliCommand,
|
||||
readPlanUsageTelemetryEnabled,
|
||||
readKeepWarmSettings,
|
||||
findEffectiveUserStatusLineCommand,
|
||||
} from './hooks-config.js';
|
||||
import {
|
||||
@@ -1070,6 +1073,28 @@ export function buildCodexCommand(config?: CodexConfig): string {
|
||||
return buildSpawnCommandFromRegistry(entry, { mode: 'codex', sessionId: '', codexConfig: config }) ?? 'codex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a launch carries `idleCompaction: false` in its one `--settings` JSON: keep-warm on
|
||||
* at launch (`keepWarmOn`), the statusLine exporter injected (without its `prompt_cache` reports
|
||||
* keep-warm cannot run, and the opt-out alone would only cost the user Claude Code's own idle
|
||||
* compaction), and the CLI's `idleCompactionSetting` gate passed, fail-closed like `--name`:
|
||||
* an unknown version means no key. Pure; `claudeCliVersion` as in buildSpawnCommand.
|
||||
*/
|
||||
export function decideIdleCompactionOptOut(o: {
|
||||
mode: SessionMode;
|
||||
statusLine: StatusLineExporter | undefined;
|
||||
keepWarm: boolean | undefined;
|
||||
keepWarmDefault: boolean;
|
||||
spawnedByAgent: boolean | undefined;
|
||||
claudeCliVersion?: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
o.statusLine === 'injected' &&
|
||||
keepWarmOn(o.keepWarm, o.keepWarmDefault, !!o.spawnedByAgent) &&
|
||||
launchGatePasses(o.mode, 'idleCompactionSetting', o.claudeCliVersion)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSpawnCommand(options: {
|
||||
mode: SessionMode;
|
||||
sessionId: string;
|
||||
@@ -1091,6 +1116,8 @@ export function buildSpawnCommand(options: {
|
||||
advisorModel?: string;
|
||||
/** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */
|
||||
statusLineCommand?: string;
|
||||
/** Add `idleCompaction: false` to the launch's one `--settings` JSON (see decideIdleCompactionOptOut). */
|
||||
idleCompactionOff?: boolean;
|
||||
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
|
||||
sessionName?: string;
|
||||
/**
|
||||
@@ -2266,6 +2293,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this launch carries `idleCompaction: false` (decideIdleCompactionOptOut), with the
|
||||
* keep-warm app default read fresh like the plan-usage switch above. Logged when keep-warm is
|
||||
* on but the version gate keeps the key off, so a session that may still idle-compact says why.
|
||||
*/
|
||||
private async _resolveIdleCompactionOptOut(
|
||||
mode: SessionMode,
|
||||
statusLine: StatusLineExporter | undefined,
|
||||
keepWarm: boolean | undefined,
|
||||
spawnedByAgent: boolean | undefined
|
||||
): Promise<boolean> {
|
||||
if (statusLine !== 'injected') return false; // no settings read for a launch that can never carry it
|
||||
const { keepWarmDefault } = await readKeepWarmSettings();
|
||||
const off = decideIdleCompactionOptOut({ mode, statusLine, keepWarm, keepWarmDefault, spawnedByAgent });
|
||||
if (!off && keepWarmOn(keepWarm, keepWarmDefault, !!spawnedByAgent)) {
|
||||
console.log(
|
||||
`[TmuxManager] Keep-warm is on but Claude Code's version is unknown or predates the idleCompaction setting; launching without the opt-out`
|
||||
);
|
||||
}
|
||||
return off;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the user's own REAL statusLine command (found by
|
||||
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
|
||||
@@ -2326,6 +2375,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
envOverrides,
|
||||
effort,
|
||||
advisorModel,
|
||||
keepWarm,
|
||||
spawnedByAgent,
|
||||
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
|
||||
remote,
|
||||
docker,
|
||||
@@ -2389,6 +2440,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
);
|
||||
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
|
||||
// The user's own REAL statusLine, if any (walked via Claude Code's own
|
||||
// settings precedence) — exported below so the shared exporter script
|
||||
// can wrap it. Only worth discovering when we're actually injecting.
|
||||
@@ -2413,6 +2465,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
idleCompactionOff,
|
||||
sessionName: cliName,
|
||||
});
|
||||
|
||||
@@ -2554,6 +2607,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
attached: false,
|
||||
name,
|
||||
statusLine,
|
||||
idleCompactionOff,
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
@@ -2664,6 +2718,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
unsetEnvKeys,
|
||||
effort,
|
||||
advisorModel,
|
||||
keepWarm,
|
||||
spawnedByAgent,
|
||||
remote,
|
||||
docker,
|
||||
cliName,
|
||||
@@ -2684,6 +2740,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
workingDir,
|
||||
!!(remote || docker)
|
||||
);
|
||||
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
|
||||
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
|
||||
|
||||
const baseCmd = buildSpawnCommand({
|
||||
@@ -2705,6 +2762,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
effort,
|
||||
advisorModel,
|
||||
statusLineCommand,
|
||||
idleCompactionOff,
|
||||
sessionName: cliName,
|
||||
});
|
||||
const config = niceConfig || DEFAULT_NICE_CONFIG;
|
||||
@@ -2747,8 +2805,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
this.clearPaneExit(muxName);
|
||||
const pid = this.getPanePid(muxName);
|
||||
if (pid) session.pid = pid;
|
||||
if (session.statusLine !== statusLine) {
|
||||
if (session.statusLine !== statusLine || session.idleCompactionOff !== idleCompactionOff) {
|
||||
session.statusLine = statusLine;
|
||||
session.idleCompactionOff = idleCompactionOff;
|
||||
this.saveSessions();
|
||||
}
|
||||
return pid;
|
||||
|
||||
Reference in new issue
Block a user