feat: auto-compact and auto-clear from the statusLine context size

Both checks compared getTotalTokens() against the threshold. That
counter leaves out cache reads, so it stays far below the real context,
and the 500k cap ruled out thresholds on a 1M window.

POST /api/status-telemetry now hands context_window.total_input_tokens
and context_window_size to Session.noteContextReport(), which runs both
checks. The decision is a pure function, decideAutoThreshold(). The
counter stays the fallback when no report has arrived.

After a /compact or /clear the pre-command report is ignored by value,
since it stays the newest number until the command finishes. The first
differing report came later, so a swallowed command fires again instead
of locking the automation off. A counter summed from JSON usage never
drops after a compact, so it counts from its value at the last one. The
pane parser's counter is absolute and only moves past its high, so a
move clears that baseline and it fires as it did before.

Thresholds go up to the reported window, 1M when unknown, and a higher
value stops at the window. MAX_SESSION_TOKENS follows, and the pane
parser's M-suffix guard checks that cap instead of a fixed 0.5.

Refs #585

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-11-06 13:19:36 +01:00
1 parent fc25bf5f83
commit 84a6896cae
16 files changed
+579 -45

No files matched your search

+2
View File
@@ -438,6 +438,8 @@ The title is templated into the served HTML on first byte, so it's correct from
| **110k tokens** | Auto `/compact` | Context summarized, work continues |
| **140k tokens** | Auto `/clear` | Fresh start with `/init` |
Both are opt-in per session, and the thresholds go up to the model's window. Claude sessions are measured by the context size on the statusline, cache reads included; everything else by Codeman's token counter.
### Tab Alerts
<p align="center">
+8
View File
@@ -183,6 +183,14 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits.
### Auto-compact and auto-clear thresholds
**The trigger reads the context size, and the decision is pure** (`decideAutoThreshold` in `src/session-auto-ops.ts`). `POST /api/status-telemetry` hands `context_window.total_input_tokens` to `Session.noteContextReport()`, which runs both checks; that call is the only thing that runs them on a report, because the token paths only fire when the counter moves. A report wins over the token counter, which leaves out cache reads. The counter stays the fallback for every launch without the exporter: non-Claude CLIs, plan-usage collection off, remote and Docker launches, and workspaces with their own statusline.
**After a /compact or /clear, the report from before the command is ignored by value, not by time.** A report carries the usage of the last API response, so that number stays the newest one until the command finishes. The first report that differs came from the finished command (0 on Claude Code 2.1.319) or from a later response. If the command was swallowed, that report is higher and the threshold fires again, so nothing can lock the automation off. A threshold below what a compact leaves behind fires again on the next response, like the counter always did. A counter summed from JSON usage never drops after a compact, so it counts from its value at the last one. The pane parser's counter is the absolute number on screen and only moves past its highest value, so any move clears that baseline (`noteAbsoluteCounter()`). Neither value survives a restart.
**Thresholds go up to the reported window** (`_maxThreshold()`, 1M when unknown), and a higher value stops at the window when it is set. A threshold set before the window was known can sit above it and never fire; Claude Code's own compaction still does.
### Prompt-cache keep-warm
**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage 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 (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its Line truncated
+131 -32
View File
@@ -1,10 +1,11 @@
/**
* @fileoverview Auto-compact, auto-clear, and auto-resume automation for Session.
*
* Monitors token counts and triggers /compact or /clear commands when
* configurable thresholds are reached. Waits for Claude to be idle
* before sending commands, with retry logic and mutual exclusion
* (compact and clear never run simultaneously).
* Monitors the context size and triggers /compact or /clear commands when
* configurable thresholds are reached. The size comes from the statusline report
* when the session has one and from the session's token counter otherwise (see
* decideAutoThreshold). Waits for Claude to be idle before sending commands, with
* retry logic and mutual exclusion (compact and clear never run simultaneously).
*
* Also implements auto-resume on usage limit ("token pause" control):
* when enabled and Claude stops on a usage-limit message ("5-hour limit
@@ -114,8 +115,8 @@ const RESUME_PROMPT = 'continue';
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
const MIN_AUTO_THRESHOLD = 1000;
/** Maximum valid threshold for auto-clear/compact (500k tokens) */
const MAX_AUTO_THRESHOLD = 500_000;
/** Maximum valid threshold for auto-clear/compact when the model's window is not reported (1M tokens) */
const MAX_AUTO_THRESHOLD = 1_000_000;
/** Default auto-clear threshold when invalid value provided */
const DEFAULT_AUTO_CLEAR_THRESHOLD = 140_000;
@@ -123,6 +124,61 @@ const DEFAULT_AUTO_CLEAR_THRESHOLD = 140_000;
/** Default auto-compact threshold when invalid value provided */
const DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000;
/** The context size Claude Code last reported on its statusline. */
export interface ContextReport {
/** Tokens in the context window at the last API response, cache reads and writes included. */
tokens: number;
/** The model's window (200k, or 1M with extended context); absent when not reported. */
windowSize?: number;
}
export interface AutoThresholdInput {
threshold: number;
/** The newest statusline report, or null when none has arrived. */
report: ContextReport | null;
/** The newest report's size when the last /compact or /clear went out, or null. */
reportTokensAtCommand: number | null;
/** The session's token counter. */
counterTokens: number;
/** The counter's value at the last /compact: it never drops by itself after one. */
counterBaseline: number;
}
export interface AutoThresholdDecision {
trigger: boolean;
tokens: number;
source: 'statusline' | 'counter';
}
/**
* Whether the context has reached a threshold. Pure, so every case is testable without a session.
*
* A report wins over the counter: the counter leaves out cache reads, so on a long conversation
* it is a small fraction of the real context. The counter stays as the fallback, because the
* exporter only runs for Claude, only while plan-usage collection is on, and never on a remote or
* Docker launch or in a workspace with its own statusline.
*
* A report carries the usage of the last API response, so after a /compact or /clear the number
* from before the command stays the newest one until the command finishes, and it is re-posted on
* every render. It is ignored by VALUE rather than for a length of time: the first report that
* differs came from the finished command (0 on Claude Code 2.1.319) or from a later API response.
* If the command was swallowed that report is higher and the threshold fires again, so nothing
* can lock auto-compact off, and no timer has to guess how long a compact takes (601k tokens took
* 39 s, four times the cooldown).
*
* A counter summed from JSON usage never drops after a compact, so it counts from the value at
* the last one. The pane parser's counter is absolute, see noteAbsoluteCounter().
*/
export function decideAutoThreshold(input: AutoThresholdInput): AutoThresholdDecision {
const { threshold, report, reportTokensAtCommand, counterTokens, counterBaseline } = input;
if (report) {
const stale = report.tokens === reportTokensAtCommand;
return { trigger: !stale && report.tokens >= threshold, tokens: report.tokens, source: 'statusline' };
}
const tokens = Math.max(0, counterTokens - counterBaseline);
return { trigger: tokens >= threshold, tokens, source: 'counter' };
}
/**
* Callbacks required by SessionAutoOps to interact with the parent Session.
*/
@@ -135,6 +191,8 @@ interface AutoOpsCallbacks {
isStopped: () => boolean;
/** Get current total token count (input + output) */
getTotalTokens: () => number;
/** The newest statusline context report, or null when none has arrived */
getContextReport: () => ContextReport | null;
/** Get session ID for logging */
getSessionId: () => string;
}
@@ -156,6 +214,8 @@ export class SessionAutoOps extends EventEmitter {
private _autoCompactEnabled: boolean = false;
private _autoCompactPrompt: string = '';
private _isCompacting: boolean = false;
private _reportTokensAtCommand: number | null = null;
private _counterBaseline: number = 0;
private _autoCompactTimer: NodeJS.Timeout | null = null;
// Auto-clear state
@@ -206,14 +266,7 @@ export class SessionAutoOps extends EventEmitter {
setAutoCompact(enabled: boolean, threshold?: number, prompt?: string): void {
this._autoCompactEnabled = enabled;
if (threshold !== undefined) {
if (threshold < MIN_AUTO_THRESHOLD || threshold > MAX_AUTO_THRESHOLD) {
console.warn(
`[SessionAutoOps ${this.callbacks.getSessionId()}] Invalid autoCompact threshold ${threshold}, must be between ${MIN_AUTO_THRESHOLD} and ${MAX_AUTO_THRESHOLD}. Using default ${DEFAULT_AUTO_COMPACT_THRESHOLD}.`
);
this._autoCompactThreshold = DEFAULT_AUTO_COMPACT_THRESHOLD;
} else {
this._autoCompactThreshold = threshold;
}
this._autoCompactThreshold = this._resolveThreshold('autoCompact', threshold, DEFAULT_AUTO_COMPACT_THRESHOLD);
}
if (prompt !== undefined) {
this._autoCompactPrompt = prompt;
@@ -239,14 +292,7 @@ export class SessionAutoOps extends EventEmitter {
setAutoClear(enabled: boolean, threshold?: number): void {
this._autoClearEnabled = enabled;
if (threshold !== undefined) {
if (threshold < MIN_AUTO_THRESHOLD || threshold > MAX_AUTO_THRESHOLD) {
console.warn(
`[SessionAutoOps ${this.callbacks.getSessionId()}] Invalid autoClear threshold ${threshold}, must be between ${MIN_AUTO_THRESHOLD} and ${MAX_AUTO_THRESHOLD}. Using default ${DEFAULT_AUTO_CLEAR_THRESHOLD}.`
);
this._autoClearThreshold = DEFAULT_AUTO_CLEAR_THRESHOLD;
} else {
this._autoClearThreshold = threshold;
}
this._autoClearThreshold = this._resolveThreshold('autoClear', threshold, DEFAULT_AUTO_CLEAR_THRESHOLD);
}
}
@@ -393,19 +439,65 @@ export class SessionAutoOps extends EventEmitter {
// Threshold checks
// ============================================================================
/** The highest threshold that can ever fire: the reported window, or 1M when it is unknown. */
/**
* Check if auto-compact should be triggered based on current token count.
* Called after token count updates.
* A value above the window stops at the window rather than falling to the default: a threshold
* saved under a larger window comes back on every save of the options, and the default would
* compact far earlier than anyone asked for.
*/
private _resolveThreshold(name: string, threshold: number, fallback: number): number {
const id = this.callbacks.getSessionId();
if (threshold < MIN_AUTO_THRESHOLD) {
console.warn(
`[SessionAutoOps ${id}] Invalid ${name} threshold ${threshold}, minimum is ${MIN_AUTO_THRESHOLD}. Using default ${fallback}.`
);
return fallback;
}
const max = this._maxThreshold();
if (threshold > max) {
console.warn(`[SessionAutoOps ${id}] ${name} threshold ${threshold} is above the window, using ${max}.`);
return max;
}
return threshold;
}
/**
* The pane parser's counter holds the absolute number on screen and only moves past its highest
* value so far, so a move means the context grew back past the last compact. Counting that from
* the baseline would ask for twice the threshold, which a 200k window never reaches.
*/
noteAbsoluteCounter(): void {
this._counterBaseline = 0;
}
private _maxThreshold(): number {
return this.callbacks.getContextReport()?.windowSize ?? MAX_AUTO_THRESHOLD;
}
private _decide(threshold: number): AutoThresholdDecision {
return decideAutoThreshold({
threshold,
report: this.callbacks.getContextReport(),
reportTokensAtCommand: this._reportTokensAtCommand,
counterTokens: this.callbacks.getTotalTokens(),
counterBaseline: this._counterBaseline,
});
}
/**
* Check if auto-compact should be triggered based on the current context size.
* Called after token count updates and after each statusline report.
*/
checkAutoCompact(): void {
if (this.callbacks.isStopped()) return;
if (!this._autoCompactEnabled || this._isCompacting || this._isClearing) return;
const totalTokens = this.callbacks.getTotalTokens();
if (totalTokens >= this._autoCompactThreshold) {
const decision = this._decide(this._autoCompactThreshold);
if (decision.trigger) {
const totalTokens = decision.tokens;
this._isCompacting = true;
console.log(
`[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`
`[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens (${decision.source}) >= ${this._autoCompactThreshold} threshold`
);
const action = async () => {
@@ -414,6 +506,9 @@ export class SessionAutoOps extends EventEmitter {
const compactCmd = this._autoCompactPrompt
? `/compact ${toSingleLine(this._autoCompactPrompt)}\r`
: '/compact\r';
// Read here, not at the trigger: the idle wait can outlast several reports.
this._reportTokensAtCommand = this.callbacks.getContextReport()?.tokens ?? null;
this._counterBaseline = this.callbacks.getTotalTokens();
await this.callbacks.writeCommand(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
@@ -446,21 +541,25 @@ export class SessionAutoOps extends EventEmitter {
}
/**
* Check if auto-clear should be triggered based on current token count.
* Called after token count updates.
* Check if auto-clear should be triggered based on the current context size.
* Called after token count updates and after each statusline report.
*/
checkAutoClear(): void {
if (this.callbacks.isStopped()) return;
if (!this._autoClearEnabled || this._isClearing || this._isCompacting) return;
const totalTokens = this.callbacks.getTotalTokens();
if (totalTokens >= this._autoClearThreshold) {
const decision = this._decide(this._autoClearThreshold);
if (decision.trigger) {
const totalTokens = decision.tokens;
this._isClearing = true;
console.log(
`[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`
`[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens (${decision.source}) >= ${this._autoClearThreshold} threshold`
);
const action = async () => {
this._reportTokensAtCommand = this.callbacks.getContextReport()?.tokens ?? null;
// The session zeroes its counter on 'autoClear', so the counter restarts from 0.
this._counterBaseline = 0;
await this.callbacks.writeCommand('/clear\r');
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
};
+21 -6
View File
@@ -153,7 +153,7 @@ import {
buildMuxAttachEnv,
buildShellEnv,
} from './session-cli-builder.js';
import { SessionAutoOps } from './session-auto-ops.js';
import { SessionAutoOps, type ContextReport } from './session-auto-ops.js';
import { detectUsageLimitPause } from './usage-limit-patterns.js';
import { SessionTaskCache } from './session-task-cache.js';
import { InteractivePtyExitBreaker } from './session-pty-exit-breaker.js';
@@ -663,6 +663,7 @@ 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;
private _contextReport: ContextReport | 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. */
@@ -1272,6 +1273,7 @@ export class Session extends EventEmitter {
isWorking: () => this._isWorking,
isStopped: () => this._isStopped,
getTotalTokens: () => this._totalInputTokens + this._totalOutputTokens,
getContextReport: () => this._contextReport,
getSessionId: () => this.id,
});
this._autoOps.on('autoCompact', (data) => this.emit('autoCompact', data));
@@ -1467,6 +1469,7 @@ export class Session extends EventEmitter {
// rather than show the previous conversation's "warm until" against the new one; the
// next statusline render repopulates. A same-id reattach returned above, untouched.
this._promptCache = null;
this._contextReport = null;
}
/**
@@ -1812,6 +1815,17 @@ export class Session extends EventEmitter {
return changed;
}
/**
* Take the context size from a statusline report and run the threshold checks on it. The
* token paths above cannot do this: they only run when the counter moves, and the counter
* does not see the cached context.
*/
noteContextReport(report: ContextReport): void {
this._contextReport = report;
this._autoOps.checkAutoCompact();
this._autoOps.checkAutoClear();
}
/** Prompt-cache keep-warm opt-in; undefined means the app-wide default applies. */
get keepWarmEnabled(): boolean | undefined {
return this._keepWarmEnabled;
@@ -2440,6 +2454,7 @@ export class Session extends EventEmitter {
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
promptCache: this._promptCache ?? undefined,
contextWindowSize: this._contextReport?.windowSize,
statusLineExporter: this.statusLineExporter,
keepWarmEnabled: this._keepWarmEnabled,
keepWarm: this._keepWarm ?? undefined,
@@ -4953,9 +4968,9 @@ export class Session extends EventEmitter {
// Matches patterns like "123.4k tokens", "5234 tokens", "1.2M tokens"
//
// SAFETY LIMITS:
// - Max tokens per session: 500k (Claude's context is ~200k)
// - Max tokens per session: MAX_SESSION_TOKENS (the largest context Claude reports is 1M)
// - Max delta per update: 100k (prevents sudden jumps from parsing errors)
// - Rejects "M" suffix values > 0.5 (500k) to prevent false matches
// - Rejects "M" suffix values above that maximum to prevent false matches
private parseTokensFromStatusLine(cleanData: string): void {
// Quick pre-check: skip expensive regex if "token" not present (performance optimization)
if (!cleanData.includes('token')) return;
@@ -4973,9 +4988,8 @@ export class Session extends EventEmitter {
if (suffix === 'k') {
tokenCount *= 1000;
} else if (suffix === 'm') {
// Safety: Reject M values that would result in > 500k tokens
// Claude's context window is ~200k, so anything claiming millions is likely a false match
if (tokenCount > 0.5) {
// Safety: anything claiming more than the largest context is likely a false match
if (tokenCount * 1000000 > MAX_SESSION_TOKENS) {
console.warn(
`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`
);
@@ -5011,6 +5025,7 @@ export class Session extends EventEmitter {
this._totalOutputTokens += Math.round(delta * 0.4);
// Check if we should auto-compact or auto-clear
this._autoOps.noteAbsoluteCounter();
this._autoOps.checkAutoCompact();
this._autoOps.checkAutoClear();
}
+2
View File
@@ -1006,6 +1006,8 @@ export interface SessionState {
* `expiresAt` has passed reads as cold rather than as a stale warm.
*/
promptCache?: PromptCacheStatus;
/** The model's context window in tokens from the last statusline report; absent until one arrives. */
contextWindowSize?: number;
/** 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. */
+12 -1
View File
@@ -68,7 +68,12 @@ export interface RawStatuslinePayload {
five_hour?: { used_percentage?: number; resets_at?: number };
seven_day?: { used_percentage?: number; resets_at?: number };
};
context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: number };
context_window?: {
used_percentage?: number;
total_input_tokens?: number;
total_output_tokens?: number;
context_window_size?: number;
};
cost?: { total_cost_usd?: number };
model?: { display_name?: string };
/** Main-conversation prompt-cache statistics (Claude Code v2.1.251+); absent before the first API response. */
@@ -259,6 +264,8 @@ export interface SessionStatus {
inputTokens?: number;
outputTokens?: number;
contextUsedPercentage?: number;
/** The model's context window in tokens (200k, or 1M with extended context). */
contextWindowSize?: number;
/** Prompt-cache state of the main conversation, when the statusline reports it. */
cache?: PromptCacheStatus;
}
@@ -287,6 +294,10 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess
if (typeof cw?.used_percentage === 'number') {
s.contextUsedPercentage = clampPct(cw.used_percentage);
}
const windowSize = cw?.context_window_size;
if (typeof windowSize === 'number' && Number.isFinite(windowSize) && windowSize > 0) {
s.contextWindowSize = windowSize;
}
const cache = parsePromptCache(data);
if (cache) s.cache = cache;
return Object.keys(s).length ? s : null;
+3 -3
View File
@@ -1,13 +1,13 @@
/**
* @fileoverview Token validation constants.
*
* Claude's context window is ~200k tokens, so 500k is a generous upper bound.
* Claude's context window is 200k tokens, or 1M with extended context.
*
* @module utils/token-validation
*/
/**
* Maximum tokens allowed per session.
* Claude's context is ~200k, so 500k is a safe upper bound for validation.
* The largest context Claude reports today is 1M, the upper bound for validation.
*/
export const MAX_SESSION_TOKENS = 500_000;
export const MAX_SESSION_TOKENS = 1_000_000;
+2 -2
View File
@@ -1627,7 +1627,7 @@
<span class="set-row-desc">Summarizes the context at the threshold while preserving key information.</span>
</div>
<div class="set-row-actions">
<input type="number" id="modalAutoCompactThreshold" class="set-num set-num-wide" value="110000" min="10000" max="500000" step="10000" onchange="app.autoSaveAutoCompact()">
<input type="number" id="modalAutoCompactThreshold" class="set-num set-num-wide" value="110000" min="10000" max="1000000" step="10000" onchange="app.autoSaveAutoCompact()">
<span class="set-unit">tokens</span>
<label class="switch switch-sm"><input type="checkbox" id="modalAutoCompactEnabled" onchange="app.autoSaveAutoCompact()"><span class="slider"></span></label>
</div>
@@ -1645,7 +1645,7 @@
<span class="set-row-desc">Full context reset at the threshold. Keep it higher than auto-compact.</span>
</div>
<div class="set-row-actions">
<input type="number" id="modalAutoClearThreshold" class="set-num set-num-wide" value="140000" min="10000" max="500000" step="10000" onchange="app.autoSaveAutoClear()">
<input type="number" id="modalAutoClearThreshold" class="set-num set-num-wide" value="140000" min="10000" max="1000000" step="10000" onchange="app.autoSaveAutoClear()">
<span class="set-unit">tokens</span>
<label class="switch switch-sm"><input type="checkbox" id="modalAutoClearEnabled" onchange="app.autoSaveAutoClear()"><span class="slider"></span></label>
</div>
+5 -1
View File
@@ -2814,7 +2814,11 @@ Object.assign(CodemanApp.prototype, {
// Populate respawn config from saved state
this.loadSavedRespawnConfig(sessionId);
// Populate auto-compact/clear from session state
// Populate auto-compact/clear from session state. A threshold above the model's window
// can never fire, so the inputs stop there once the statusline has reported it.
const maxThreshold = session.contextWindowSize ?? 1000000;
document.getElementById('modalAutoCompactThreshold').max = maxThreshold;
document.getElementById('modalAutoClearThreshold').max = maxThreshold;
document.getElementById('modalAutoCompactEnabled').checked = session.autoCompactEnabled ?? false;
document.getElementById('modalAutoCompactThreshold').value = session.autoCompactThreshold ?? 110000;
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
@@ -65,6 +65,13 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
session.noteReportedModel('statusline', payload?.model?.display_name);
}
// The context size drives auto-compact and auto-clear. A render without one (the exporter
// can post before the first API response) keeps the last report instead of wiping it.
const status = reportsStatusLine ? parseSessionStatus(payload) : null;
if (session && status?.inputTokens !== undefined) {
session.noteContextReport({ tokens: status.inputTokens, windowSize: status.contextWindowSize });
}
// Prompt-cache state of the main conversation (Claude Code v2.1.251+): kept on the
// session for the Session Options readout and `toState()`, broadcast only when the
// report changes (the statusline fires on every assistant message). The broadcast
+1
View File
@@ -733,6 +733,7 @@ export const StatusTelemetrySchema = z.object({
used_percentage: z.number().nullish(),
total_input_tokens: z.number().nullish(),
total_output_tokens: z.number().nullish(),
context_window_size: z.number().nullish(),
})
.nullish(),
cost: z.object({ total_cost_usd: z.number().nullish() }).nullish(),
+3
View File
@@ -489,6 +489,9 @@ export class MockSession extends EventEmitter {
/** Mirrors Session.ownEnvValue, same default. */
ownEnvValue = vi.fn((_key: string): string | undefined => undefined);
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
/** Mirrors Session.noteContextReport: the statusline route hands over the context size. */
noteContextReport = vi.fn();
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
noteReportedModel = vi.fn((source: 'statusline' | 'screen', raw: unknown): boolean => {
@@ -52,6 +52,35 @@ describe('POST /api/status-telemetry', () => {
});
});
describe('context report (auto-compact and auto-clear)', () => {
const noted = () => h.ctx._session.noteContextReport as ReturnType<typeof vi.fn>;
const context = (cw: Record<string, unknown>) => ({ sessionId: SID, data: { context_window: cw } });
it('hands the context size and the window to the session', async () => {
const res = await post(context({ total_input_tokens: 562411, context_window_size: 1000000 }));
expect(res.statusCode).toBe(200);
expect(noted()).toHaveBeenCalledWith({ tokens: 562411, windowSize: 1000000 });
});
it('accepts a null window and passes the tokens on without one', async () => {
const res = await post(context({ total_input_tokens: 5, context_window_size: null }));
expect(res.statusCode).toBe(200);
expect(noted()).toHaveBeenCalledWith({ tokens: 5, windowSize: undefined });
});
it('keeps the last report when a render carries no context size', async () => {
await post(context({ used_percentage: 10 }));
await post({ sessionId: SID, data: {} });
expect(noted()).not.toHaveBeenCalled();
});
it('takes no report from a CLI without a statusline exporter', async () => {
h.ctx._session.mode = 'shell';
await post(context({ total_input_tokens: 562411 }));
expect(noted()).not.toHaveBeenCalled();
});
});
it('does not broadcast for an unknown session; returns an EMPTY footer, never a brand word', async () => {
const res = await post({ sessionId: 'does-not-exist', data: REAL });
expect(res.statusCode).toBe(200);
+343
View File
@@ -0,0 +1,343 @@
/**
* The auto-compact and auto-clear trigger: which number it reads, and what it does after a
* command went out. `decideAutoThreshold` is pure, so its cases need no session. The class tests
* run the real SessionAutoOps on fake timers to pin the part the function cannot see: when the
* values it compares against are taken. The last block pins the wiring from a statusline report
* to the checks on a real Session.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SessionAutoOps,
decideAutoThreshold,
type AutoThresholdInput,
type ContextReport,
} from '../src/session-auto-ops.js';
import { Session } from '../src/session.js';
const input = (over: Partial<AutoThresholdInput>): AutoThresholdInput => ({
threshold: 100_000,
report: null,
reportTokensAtCommand: null,
counterTokens: 0,
counterBaseline: 0,
...over,
});
describe('decideAutoThreshold', () => {
describe('with a statusline report', () => {
it('fires at the threshold and not below it', () => {
expect(decideAutoThreshold(input({ report: { tokens: 100_000 } }))).toEqual({
trigger: true,
tokens: 100_000,
source: 'statusline',
});
expect(decideAutoThreshold(input({ report: { tokens: 99_999 } })).trigger).toBe(false);
});
it('wins over the counter in both directions', () => {
// The real case: the counter misses the cached context.
expect(decideAutoThreshold(input({ report: { tokens: 243_000 }, counterTokens: 81_000 })).trigger).toBe(true);
expect(decideAutoThreshold(input({ report: { tokens: 50_000 }, counterTokens: 200_000 })).trigger).toBe(false);
});
it('treats a report of 0 as a report, not as a missing one', () => {
const decision = decideAutoThreshold(input({ report: { tokens: 0 }, counterTokens: 200_000 }));
expect(decision).toMatchObject({ trigger: false, source: 'statusline' });
});
});
describe('without a report', () => {
it('falls back to the counter', () => {
expect(decideAutoThreshold(input({ counterTokens: 100_000 }))).toEqual({
trigger: true,
tokens: 100_000,
source: 'counter',
});
expect(decideAutoThreshold(input({ counterTokens: 99_999 })).trigger).toBe(false);
});
it('counts from the value at the last compact, since the counter never drops', () => {
const after = { counterBaseline: 150_000 };
expect(decideAutoThreshold(input({ ...after, counterTokens: 150_000 })).trigger).toBe(false);
expect(decideAutoThreshold(input({ ...after, counterTokens: 249_999 })).trigger).toBe(false);
expect(decideAutoThreshold(input({ ...after, counterTokens: 250_000 }))).toMatchObject({
trigger: true,
tokens: 100_000,
});
});
it('never goes negative when the counter was reset below the baseline', () => {
expect(decideAutoThreshold(input({ counterBaseline: 150_000, counterTokens: 10 })).tokens).toBe(0);
});
});
describe('after a command went out', () => {
it('ignores the report from before the command, however long it stays the newest', () => {
const decision = decideAutoThreshold(input({ report: { tokens: 600_000 }, reportTokensAtCommand: 600_000 }));
expect(decision.trigger).toBe(false);
});
it('does not fire on the lower report the compact leaves behind', () => {
const decision = decideAutoThreshold(input({ report: { tokens: 9_000 }, reportTokensAtCommand: 600_000 }));
expect(decision.trigger).toBe(false);
});
it('fires again on a higher report, so a swallowed /compact cannot lock it off', () => {
const decision = decideAutoThreshold(input({ report: { tokens: 601_000 }, reportTokensAtCommand: 600_000 }));
expect(decision.trigger).toBe(true);
});
it('still ignores an out-of-order post of the old number after the lower report', () => {
const lower = decideAutoThreshold(input({ report: { tokens: 9_000 }, reportTokensAtCommand: 600_000 }));
const late = decideAutoThreshold(input({ report: { tokens: 600_000 }, reportTokensAtCommand: 600_000 }));
expect([lower.trigger, late.trigger]).toEqual([false, false]);
});
});
});
describe('SessionAutoOps thresholds', () => {
let report: ContextReport | null;
let counter: number;
let writes: string[];
let ops: SessionAutoOps;
const settle = () => vi.advanceTimersByTimeAsync(60_000);
beforeEach(() => {
vi.useFakeTimers();
report = null;
counter = 0;
writes = [];
ops = new SessionAutoOps({
writeCommand: async (cmd) => {
writes.push(cmd);
return true;
},
isWorking: () => false,
isStopped: () => false,
getTotalTokens: () => counter,
getContextReport: () => report,
getSessionId: () => 'test',
});
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
ops.destroy();
vi.restoreAllMocks();
vi.useRealTimers();
});
it('compacts once on a report, and not again while that report stays the newest', async () => {
ops.setAutoCompact(true, 100_000);
report = { tokens: 600_000 };
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r']);
// Past the cooldown, as a compact of this size is: the old number is re-posted on a render.
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r']);
});
it('takes the number from the moment the command goes out, not from the trigger', async () => {
let working = true;
ops = new SessionAutoOps({
writeCommand: async (cmd) => {
writes.push(cmd);
return true;
},
isWorking: () => working,
isStopped: () => false,
getTotalTokens: () => counter,
getContextReport: () => report,
getSessionId: () => 'test',
});
ops.setAutoCompact(true, 100_000);
report = { tokens: 600_000 };
ops.checkAutoCompact();
await vi.advanceTimersByTimeAsync(5_000);
expect(writes).toEqual([]);
report = { tokens: 650_000 }; // the turn it waited for finished and reported
working = false;
await settle();
expect(writes).toEqual(['/compact\r']);
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r']);
});
it('compacts again when the number moves on, so a swallowed /compact heals itself', async () => {
ops.setAutoCompact(true, 100_000);
report = { tokens: 600_000 };
ops.checkAutoCompact();
await settle();
report = { tokens: 601_000 };
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r', '/compact\r']);
});
it('does not let the stale report trigger an auto-clear after a compact', async () => {
ops.setAutoCompact(true, 100_000);
ops.setAutoClear(true, 140_000);
report = { tokens: 600_000 };
ops.checkAutoCompact();
await settle();
ops.checkAutoClear();
await settle();
expect(writes).toEqual(['/compact\r']);
});
it('clears once on a report, and not again while that report stays the newest', async () => {
ops.setAutoClear(true, 140_000);
report = { tokens: 600_000 };
ops.checkAutoClear();
await settle();
ops.checkAutoClear();
await settle();
expect(writes).toEqual(['/clear\r']);
});
it('on the summed counter, does not compact again until it has grown by the threshold', async () => {
ops.setAutoCompact(true, 100_000);
counter = 100_000;
ops.checkAutoCompact();
await settle();
counter = 150_000;
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r']);
counter = 200_000;
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r', '/compact\r']);
});
it('on the pane counter, compacts again as soon as the number passes its last high', async () => {
ops.setAutoCompact(true, 150_000);
counter = 150_000;
ops.noteAbsoluteCounter();
ops.checkAutoCompact();
await settle();
counter = 150_001; // the pane fell after the compact, and only this move reaches the check
ops.noteAbsoluteCounter();
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r', '/compact\r']);
});
it('on the counter, restarts from 0 after a clear', async () => {
ops.setAutoCompact(true, 100_000);
ops.setAutoClear(true, 140_000);
counter = 100_000;
ops.checkAutoCompact();
await settle();
counter = 240_000;
ops.checkAutoClear();
await settle();
expect(writes).toEqual(['/compact\r', '/clear\r']);
counter = 100_000; // the session zeroed it on 'autoClear' and it grew back
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r', '/clear\r', '/compact\r']);
});
describe('threshold cap', () => {
it('allows up to 1M while the window is unknown', () => {
ops.setAutoCompact(true, 1_000_000);
ops.setAutoClear(true, 1_000_000);
expect(ops.autoCompactThreshold).toBe(1_000_000);
expect(ops.autoClearThreshold).toBe(1_000_000);
});
it('stops at 1M while the window is unknown', () => {
ops.setAutoCompact(true, 1_000_001);
expect(ops.autoCompactThreshold).toBe(1_000_000);
});
it('falls to the default below the minimum', () => {
ops.setAutoCompact(true, 1);
ops.setAutoClear(true, 1);
expect(ops.autoCompactThreshold).toBe(110_000);
expect(ops.autoClearThreshold).toBe(140_000);
});
it('follows the reported window', () => {
report = { tokens: 1, windowSize: 200_000 };
ops.setAutoCompact(true, 200_000);
expect(ops.autoCompactThreshold).toBe(200_000);
ops.setAutoCompact(true, 200_001);
expect(ops.autoCompactThreshold).toBe(200_000);
ops.setAutoClear(true, 200_001);
expect(ops.autoClearThreshold).toBe(200_000);
});
});
});
describe('Session.noteContextReport', () => {
const session = () => new Session({ workingDir: '/tmp', mode: 'claude' } as ConstructorParameters<typeof Session>[0]);
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('runs the checks on the report, the counter being far below it', async () => {
vi.useFakeTimers();
vi.spyOn(console, 'log').mockImplementation(() => {});
const s = session();
const write = vi.spyOn(s, 'writeViaMux').mockResolvedValue(true);
s.setAutoCompact(true, 100_000);
s.noteContextReport({ tokens: 243_000, windowSize: 1_000_000 });
await vi.advanceTimersByTimeAsync(60_000);
expect(write).toHaveBeenCalledWith('/compact\r');
expect(s.toState().contextWindowSize).toBe(1_000_000);
s.setAutoCompact(false);
});
});
describe('the pane token parser under the raised cap', () => {
type Parser = { parseTokensFromStatusLine(text: string): void };
const total = (s: Session) => s.toState().inputTokens + s.toState().outputTokens;
it('takes an M value up to the cap, so the counter can reach a threshold above 500k', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const s = new Session({ workingDir: '/tmp', mode: 'claude' } as ConstructorParameters<typeof Session>[0]);
const parse = (text: string) => (s as never as Parser).parseTokensFromStatusLine(text);
for (const k of [100, 200, 300, 400, 500]) parse(`${k}k tokens`);
parse('0.6M tokens');
expect(total(s)).toBe(600_000);
parse('1.1M tokens');
expect(total(s)).toBe(600_000);
vi.restoreAllMocks();
});
it('marks the counter absolute only when the number moves', () => {
const s = new Session({ workingDir: '/tmp', mode: 'claude' } as ConstructorParameters<typeof Session>[0]);
const parse = (text: string) => (s as never as Parser).parseTokensFromStatusLine(text);
const ops = (s as never as { _autoOps: SessionAutoOps })._autoOps;
const note = vi.spyOn(ops, 'noteAbsoluteCounter');
parse('50k tokens');
expect(note).toHaveBeenCalledTimes(1);
parse('20k tokens'); // the pane after a compact: the counter stays, so nothing runs
expect(note).toHaveBeenCalledTimes(1);
vi.restoreAllMocks();
});
});
+1
View File
@@ -36,6 +36,7 @@ describe('SessionAutoOps auto-resume', () => {
isWorking: () => working,
isStopped: () => stopped,
getTotalTokens: () => 0,
getContextReport: () => null,
getSessionId: () => 'test-session',
});
});
+9
View File
@@ -188,6 +188,15 @@ describe('parsePromptCache', () => {
});
describe('parseSessionStatus', () => {
it('reads the context window size, and drops a value that is no size', () => {
const size = (context_window_size: number) => parseSessionStatus({ context_window: { context_window_size } });
expect(size(1000000)?.contextWindowSize).toBe(1000000);
expect(parseSessionStatus({ context_window: {} })).toBeNull();
expect(size(0)).toBeNull();
expect(size(-1)).toBeNull();
expect(size(Number.POSITIVE_INFINITY)).toBeNull();
});
it('carries the prompt-cache state into the footer status', () => {
expect(parseSessionStatus({ ...REAL, ...WARM_CACHE })?.cache).toEqual(parsePromptCache(WARM_CACHE));
// prompt_cache alone IS session status (a cold flip arrives with nothing else changed)