diff --git a/README.md b/README.md index 8aa649c7..677bc3c1 100644 --- a/README.md +++ b/README.md @@ -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
diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 4c682b3e..01936c72 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -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 continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry (a failed settings read only for a session that could nudge: its own switch not off, the exporter `injected`, a warm report; a settings.json that stays broken is logged once, not once a minute per session, until a read succeeds), and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires, and neither is recorded in the run summary (`recordHookEvent()` files a `stop` as a warning, and the Away digest lists warnings under Needs Attention). ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings save that changes the default or the cap (`rescheduleAll()`, compared on the merged settings since the browser sends both keys on every save, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. diff --git a/src/session-auto-ops.ts b/src/session-auto-ops.ts index d2454c65..78dc5219 100644 --- a/src/session-auto-ops.ts +++ b/src/session-auto-ops.ts @@ -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 }); }; diff --git a/src/session.ts b/src/session.ts index 7a29f62d..25a272b6 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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(); } diff --git a/src/types/session.ts b/src/types/session.ts index 87812edb..1c040372 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -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. */ diff --git a/src/usage-telemetry.ts b/src/usage-telemetry.ts index cdd16cfe..86810d9b 100644 --- a/src/usage-telemetry.ts +++ b/src/usage-telemetry.ts @@ -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; diff --git a/src/utils/token-validation.ts b/src/utils/token-validation.ts index 339a419a..b3d059c5 100644 --- a/src/utils/token-validation.ts +++ b/src/utils/token-validation.ts @@ -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; diff --git a/src/web/public/index.html b/src/web/public/index.html index c55ad232..5e44ee00 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1627,7 +1627,7 @@ Summarizes the context at the threshold while preserving key information.