From 551a7f27926a4d058286c243282ea77a5706e19f Mon Sep 17 00:00:00 2001 From: JD Date: Sat, 10 Oct 2026 12:22:00 -0400 Subject: [PATCH 1/2] feat(telemetry): read out Claude Code's prompt cache state per session Claude Code's statusline has carried a prompt_cache object since v2.1.251, and Codeman's statusline exporter already received it, but StatusTelemetrySchema stripped the whole object, so Codeman never knew whether a session's conversation was still cached. Keep it and show it. StatusTelemetrySchema now lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer), while parsePromptCache normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. Session.promptCache rides toState with a change-only session:promptCache event, and the route broadcasts the stored object. The Session Options (Respawn tab) readout shows warm-until or cold with the re-read stake, the cache hit rate, the last miss's causes, and, for Claude Code's own idle compaction (recache_tokens_if_cold null while expected_rebuilds rose), a "compacted at HH:MM" note that is never raised as an alert. setPromptCache stamps that on a new object rather than mutating the parsed report, so the stamp reaches the broadcast. Sessions without a statusline of their own also get a cache:until HH:MM / cache:cold group in the terminal footer. The last report is restored on a Codeman restart (like displayModel) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose expiry has passed reads as cold, not stale-warm. A launch without the exporter records why (collection off, workspace statusline, remote) so the row says the cause instead of "not reported yet" forever. This is the sensor a later opt-in keep-warm would read, and the TTL it reports is the source of truth rather than the plan tier. --- CLAUDE.md | 2 +- docs/api-reference.md | 29 ++++ docs/architecture-invariants.md | 2 +- docs/usage-limits-display-plan.md | 2 + docs/wiki/Keeping-Agents-Running.md | 8 + src/mux-interface.ts | 3 + src/session.ts | 64 ++++++++ src/tmux-manager.ts | 50 +++++-- src/types/session.ts | 53 +++++++ src/usage-telemetry.ts | 98 +++++++++++- src/web/public/app.js | 1 + src/web/public/constants.js | 43 ++++++ src/web/public/index.html | 16 ++ src/web/public/session-ui.js | 22 +++ src/web/routes/status-telemetry-routes.ts | 16 +- src/web/schemas.ts | 28 ++++ src/web/server.ts | 5 + src/web/sse-events.ts | 7 +- test/mocks/mock-session.ts | 9 +- test/prompt-cache-status.test.ts | 91 ++++++++++++ test/routes/status-telemetry-routes.test.ts | 91 +++++++++++- test/session-prompt-cache.test.ts | 156 ++++++++++++++++++++ test/statusline-cli-flag.test.ts | 57 ++++++- test/usage-telemetry.test.ts | 121 +++++++++++++++ 24 files changed, 945 insertions(+), 29 deletions(-) create mode 100644 test/prompt-cache-status.test.ts create mode 100644 test/session-prompt-cache.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 15d32bfc2..aa6aa9011 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` +**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-read stake, the hit rate, the last miss's causes, and a `compacted at HH:MM` note for Claude Code's own idle compaction (`recache_tokens_if_cold` null while `expected_rebuilds` rose — NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 9a53134e5..770ac9174 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -625,6 +625,35 @@ most 64 characters, but treat it as untrusted text. A `statusline` or `screen` v persisted and restored after a server restart until the next report replaces it; a `config` value is read again at every pane start, attach and relaunch instead. +## Prompt cache (`promptCache`) + +Claude-mode session state (`GET /api/v1/sessions`) carries the main conversation's +prompt-cache state as Claude Code last reported it on the statusline (v2.1.251+), with a +change-only `session:promptCache` event (`{ sessionId, ...promptCache }`): + +```json +"promptCache": { "warm": true, "ttl": "1h", "expiresAt": 1791653039000, "recacheTokensIfCold": 45000, "misses": 2, "expectedRebuilds": 1, "hitRatio": 0.91, "lastMissCauses": ["ttl_expired_1h"], "compactedAt": 1791649439872 } +``` + +| Field | Meaning | +| --------------------- | --------------------------------------------------------------------------------------------------------- | +| `warm` | Whether the cached prefix is still within its TTL. The only field always present. | +| `ttl` | `5m` or `1h`; absent when Claude reported neither. | +| `expiresAt` | Epoch MILLISECONDS when the prefix goes cold (the statusline reports seconds). Absent once cold. | +| `recacheTokensIfCold` | Tokens the next request re-writes if the cache has gone cold. Absent right after a compaction. | +| `misses` | Requests that re-processed content the cache already held. | +| `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. | +| `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. | +| `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. | +| `compactedAt` | Epoch MILLISECONDS when Codeman first saw Claude Code's own idle compaction of the current prefix (derived, not reported). | + +The whole object is absent until the first report, and restored after a server restart +(so the readout is not blank until the pane's next render) rather than recomputed; a value +whose `expiresAt` has passed reads as cold. The statusline re-runs when a warm cache hits +`expiresAt`, so the cold flip arrives on its own. A post-`/clear` conversation switch drops +it. Shown in Session Options → Respawn and, for sessions without a statusline of their own, +as a `cache:until HH:MM` / `cache:cold` group in the terminal footer. + ## Approvals Inbox Cross-session queue of prompts waiting on a human (permission dialogs, diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ce51923b8..6a77a9350 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -157,7 +157,7 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 omitted -> 4`), so both counters must be present. + if ( + inPostCompactWindow && + prev?.expectedRebuilds != null && + status.expectedRebuilds != null && + status.expectedRebuilds > prev.expectedRebuilds + ) { + compactedAt = Date.now(); + } else if (prev?.compactedAt != null && inPostCompactWindow && status.expectedRebuilds === prev.expectedRebuilds) { + compactedAt = prev.compactedAt; + } + // A NEW object when a stamp applies, never a mutation of the caller's parsed report: the + // route broadcasts the STORED object, so the stamp lives here and never leaks back into the + // parser's output (which would make the route's "broadcast the stored object" choice vacuous). + const next = compactedAt != null ? { ...status, compactedAt } : status; + const changed = JSON.stringify(next) !== JSON.stringify(prev); + this._promptCache = next; + return changed; + } + /** * Check if the session's process tree has active child processes beyond Claude itself. * Detects running bash tools, test suites, builds, servers, etc. that Claude spawned. @@ -1893,6 +1955,8 @@ export class Session extends EventEmitter { autoCompactPrompt: this._autoOps.autoCompactPrompt, autoResumeEnabled: this._autoOps.autoResumeEnabled, autoResumeAt: this._autoOps.autoResumeAt ?? undefined, + promptCache: this._promptCache ?? undefined, + statusLineTelemetry: this._muxSession?.statusLine, imageWatcherEnabled: this._imageWatcherEnabled, pinned: this._pinned || undefined, pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d7974a757..2adad4848 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -59,6 +59,7 @@ import { type SessionDocker, type DockerCommandMode, type PaneExit, + type StatusLineTelemetry, } from './types.js'; import { getCli } from './config/cli-registry/registry.js'; import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js'; @@ -2039,6 +2040,27 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** + * The statusLine exporter for one claude launch, and why it is missing when it is. + * Registry-gated (capabilities.statusLineTelemetry, claude only today), local spawns + * only (remote/docker have their own separate command builders). Resolving still runs + * with collection off, because it also self-heals: it strips any legacy disk-written + * exporter from an older Codeman build the first time a session starts in that + * workspace again. `state` lands on the mux record so the Status row and keep-warm can + * say why no cache report will come, instead of "not reported yet" forever. + */ + private async _resolveStatusLine( + mode: SessionMode, + workingDir: string, + remoteOrDocker: boolean + ): Promise<{ command?: string; state?: StatusLineTelemetry }> { + if (!getCli(mode)?.capabilities.statusLineTelemetry) return {}; + if (remoteOrDocker) return { state: 'remote' }; + const enabled = await readPlanUsageTelemetryEnabled(); + const command = await resolveStatusLineCliCommand(workingDir, enabled); + return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' }; + } + /** * Export the user's own REAL statusLine command (found by * findEffectiveUserStatusLineCommand) via tmux setenv, so the shared @@ -2156,14 +2178,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); - // Registry-gated (capabilities.statusLineTelemetry — claude only today), local - // spawns only (remote/docker have their own separate command builders — out of - // scope here). Also self-heals: strips any legacy disk-written exporter from an - // older Codeman build the first time a session starts in that workspace again. - const statusLineCommand = - getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker - ? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled()) - : undefined; + const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine( + mode, + workingDir, + !!(remote || docker) + ); // 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. @@ -2327,6 +2346,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode, attached: false, name, + statusLine, }; this.sessions.set(sessionId, session); @@ -2451,11 +2471,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); - // See createSession()'s identical resolution for rationale. - const statusLineCommand = - getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker - ? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled()) - : undefined; + const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine( + mode, + workingDir, + !!(remote || docker) + ); const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined; const baseCmd = buildSpawnCommand({ @@ -2518,6 +2538,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this.clearPaneExit(muxName); const pid = this.getPanePid(muxName); if (pid) session.pid = pid; + if (session.statusLine !== statusLine) { + session.statusLine = statusLine; + this.saveSessions(); + } return pid; } catch (err) { console.error('[TmuxManager] Failed to respawn pane:', err); diff --git a/src/types/session.ts b/src/types/session.ts index ae62638d6..5da97a48c 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -724,6 +724,49 @@ export interface PaneExit { at: number; } +/** + * Normalized prompt-cache state of a session's main conversation, from the statusline's + * `prompt_cache` object (`parsePromptCache` in usage-telemetry.ts). `warm` is the only + * field Claude always reports: once the prefix has gone cold it sends `warm:false` with a + * null `expires_at`. + */ +export interface PromptCacheStatus { + warm: boolean; + /** Cache lifetime of the current prefix; Claude Code only ever reports these two. */ + ttl?: '5m' | '1h'; + /** Epoch MILLISECONDS when the cached prefix goes cold (the statusline reports seconds). */ + expiresAt?: number; + /** Tokens the next request re-writes if the cache has gone cold by then. */ + recacheTokensIfCold?: number; + /** Requests this session that re-processed content the cache already held. */ + misses?: number; + /** Cache rebuilds that followed a compaction or a clearing of old tool results. */ + expectedRebuilds?: number; + /** Cache read tokens as a fraction of all input tokens this session, 0-1. */ + hitRatio?: number; + /** Claude Code's diagnosis of the last miss, e.g. `ttl_expired_1h` or `tools_changed`. */ + lastMissCauses?: string[]; + /** + * Epoch MS when Codeman first saw Claude Code's own idle compaction for the current prefix + * (`recacheTokensIfCold` null while `expectedRebuilds` rose). Derived in `Session.setPromptCache`, + * not reported by the statusline, so it is never set by `parsePromptCache`. Lets the readout say + * "compacted at HH:MM"; it is never an alert (CC's idle compaction is benign). + */ + compactedAt?: number; +} + +/** + * Whether this launch carries Codeman's statusLine exporter, the only source of + * `promptCache`, and why not when it does not. Decided once per claude spawn + * (`TmuxManager`), so a setting flipped later does not change it until the next launch. + * - `injected`: the exporter is on the launch. + * - `collection-off`: plan-usage collection (`showPlanUsageLimits`) was off at launch. + * - `workspace-statusline`: the workspace's own `.claude/settings.local.json` sets a + * statusLine (or could not be read), and Codeman never replaces one there. + * - `remote`: remote and Docker launches never carry the exporter. + */ +export type StatusLineTelemetry = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote'; + export interface SessionState { /** Unique session identifier */ id: string; @@ -775,6 +818,16 @@ export interface SessionState { autoResumeEnabled?: boolean; /** Pending usage-limit auto-resume fire time (epoch ms), if armed */ autoResumeAt?: number; + /** + * Prompt-cache state of the main conversation as Claude Code last reported it on the + * statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Absent until the first + * report. Restored on a restart (the Session constructor's `promptCache`) 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. + */ + promptCache?: PromptCacheStatus; + /** Why a cache report will or will not arrive (see `StatusLineTelemetry`); absent when unknown. */ + statusLineTelemetry?: StatusLineTelemetry; /** Pinned to the top of the session manager list (COD-139) */ pinned?: boolean; /** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */ diff --git a/src/usage-telemetry.ts b/src/usage-telemetry.ts index f8a823d33..abf4c7a61 100644 --- a/src/usage-telemetry.ts +++ b/src/usage-telemetry.ts @@ -15,6 +15,13 @@ * Only those two windows exist (no Opus-weekly field). `rate_limits` is absent * before the first API response and for non-subscriber auth — both yield null. * + * Since Claude Code v2.1.251 the same blob carries a `prompt_cache` object for the + * main conversation (`warm`, `ttl`, `expires_at`, `recache_tokens_if_cold`, + * `misses`, `last_miss_cause`, ...). `parsePromptCache` normalizes the subset + * Codeman reads, and the footer shows it as `cache:until HH:MM` while warm and + * `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, + * so the footer flips to cold on its own. + * * The Codex parser consumes the read-only `account/rateLimits/read` app-server * response and selects only the main `codex` bucket, excluding model-specific * buckets. All functions are pure for testability. See @@ -23,6 +30,10 @@ * @module usage-telemetry */ +import type { PromptCacheStatus } from './types/index.js'; + +export type { PromptCacheStatus }; + /** A single normalized plan-usage window. */ export interface UsageWindow { /** Percent of the window consumed, 0–100. */ @@ -52,6 +63,24 @@ export interface RawStatuslinePayload { context_window?: { used_percentage?: number; total_input_tokens?: number; total_output_tokens?: 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. */ + prompt_cache?: RawPromptCache | null; +} + +/** Raw `prompt_cache` object as Claude emits it (the subset Codeman reads; every field null-tolerant). */ +export interface RawPromptCache { + warm?: boolean | null; + ttl?: string | null; + /** Epoch SECONDS when the cached prefix goes cold; null once the last response reported no cache tokens. */ + expires_at?: number | null; + /** Null right after a compaction or tool-result clearing, until the next request records the rewritten size. */ + recache_tokens_if_cold?: number | null; + misses?: number | null; + /** Cache rebuilds that followed a compaction or a clearing of old tool results (the compaction signal). */ + expected_rebuilds?: number | null; + /** Cache read tokens as a fraction of all input tokens this session, 0-1; null while those counts are all zero. */ + hit_ratio?: number | null; + last_miss_cause?: { causes?: unknown } | null; } interface RawCodexRateLimitWindow { @@ -107,6 +136,43 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St return t; } +/** + * Normalize the statusline's `prompt_cache` object. Null when the payload has none + * (pre-first-response, or a Claude Code older than v2.1.251) or when `warm` is not + * a boolean, so the caller can treat the cache state as unknown rather than cold. + */ +export function parsePromptCache(data: RawStatuslinePayload | undefined): PromptCacheStatus | null { + const pc = data?.prompt_cache; + if (!pc || typeof pc.warm !== 'boolean') return null; + const s: PromptCacheStatus = { warm: pc.warm }; + if (pc.ttl === '5m' || pc.ttl === '1h') s.ttl = pc.ttl; + if (typeof pc.expires_at === 'number' && Number.isFinite(pc.expires_at) && pc.expires_at > 0) { + // Validate the CONVERTED ms, not just the seconds: a huge seconds value overflows to + // Infinity or lands outside the Date range after *1000, which renders "cache:until + // NaN:NaN". 8.64e15 is the max absolute epoch ms a Date can represent. + const ms = Math.round(pc.expires_at * 1000); + if (Number.isFinite(ms) && Math.abs(ms) <= 8.64e15) s.expiresAt = ms; + } + if (typeof pc.recache_tokens_if_cold === 'number' && Number.isFinite(pc.recache_tokens_if_cold)) { + s.recacheTokensIfCold = Math.max(0, Math.round(pc.recache_tokens_if_cold)); + } + if (typeof pc.misses === 'number' && Number.isFinite(pc.misses)) { + s.misses = Math.max(0, Math.round(pc.misses)); + } + if (typeof pc.expected_rebuilds === 'number' && Number.isFinite(pc.expected_rebuilds)) { + s.expectedRebuilds = Math.max(0, Math.round(pc.expected_rebuilds)); + } + if (typeof pc.hit_ratio === 'number' && Number.isFinite(pc.hit_ratio)) { + s.hitRatio = Math.min(1, Math.max(0, pc.hit_ratio)); + } + const causes = pc.last_miss_cause?.causes; + if (Array.isArray(causes)) { + const names = causes.filter((c): c is string => typeof c === 'string' && c.length > 0).map((c) => c.slice(0, 60)); + if (names.length) s.lastMissCauses = names; + } + return s; +} + /** Normalize the main Codex app-server bucket into the chip's two known windows. */ export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null { if (!value || typeof value !== 'object') return null; @@ -140,6 +206,8 @@ export interface SessionStatus { inputTokens?: number; outputTokens?: number; contextUsedPercentage?: number; + /** Prompt-cache state of the main conversation, when the statusline reports it. */ + cache?: PromptCacheStatus; } /** Group a non-negative integer with thousands separators: 562411 → "562,411". */ @@ -166,19 +234,34 @@ export function parseSessionStatus(data: RawStatuslinePayload | undefined): Sess if (typeof cw?.used_percentage === 'number') { s.contextUsedPercentage = clampPct(cw.used_percentage); } + const cache = parsePromptCache(data); + if (cache) s.cache = cache; return Object.keys(s).length ? s : null; } +/** + * `cache:until 19:00` (server-local clock) while the prefix is warm, `cache:cold` + * once Claude reports it cold or the render happened past `expires_at`. A clock + * time rather than a countdown because the footer only re-renders on statusline + * triggers, so a countdown would sit stale on screen between turns. + */ +function formatCacheGroup(c: PromptCacheStatus, now: number): string { + if (!c.warm || c.expiresAt == null || c.expiresAt <= now) return 'cache:cold'; + const d = new Date(c.expiresAt); + return `cache:until ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + /** * Format the in-terminal statusline footer: the CURRENT SESSION's status — - * `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%` — NOT the plan limits, - * which live in the Codeman header chip. Claude requires a statusLine command to - * emit the rate_limits JSON at all, so this is what that command prints back - * when it has no statusline of the user's own to wrap. With nothing to show it - * returns '' rather than a brand word: a bare `codeman` on the statusline is - * the symptom discussion #405 opened with. + * `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56% cache:until 19:00` — NOT + * the plan limits, which live in the Codeman header chip. Claude requires a + * statusLine command to emit the rate_limits JSON at all, so this is what that + * command prints back when it has no statusline of the user's own to wrap. With + * nothing to show it returns '' rather than a brand word: a bare `codeman` on the + * statusline is the symptom discussion #405 opened with. `now` is injectable so + * the cache group is testable. */ -export function formatSessionStatusText(s: SessionStatus | null): string { +export function formatSessionStatusText(s: SessionStatus | null, now: number = Date.now()): string { if (!s) return ''; const groups: string[] = []; if (s.modelDisplayName) groups.push(s.modelDisplayName); @@ -187,6 +270,7 @@ export function formatSessionStatusText(s: SessionStatus | null): string { if (s.outputTokens != null) tok.push(`out:${withCommas(s.outputTokens)}`); if (tok.length) groups.push(tok.join(' ')); if (s.contextUsedPercentage != null) groups.push(`ctx:${Math.round(clampPct(s.contextUsedPercentage))}%`); + if (s.cache) groups.push(formatCacheGroup(s.cache, now)); return groups.length ? groups.join(' ') : ''; } diff --git a/src/web/public/app.js b/src/web/public/app.js index dda356446..10d919b18 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -193,6 +193,7 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'], [SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'], [SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'], + [SSE_EVENTS.SESSION_PROMPT_CACHE, '_onSessionPromptCache'], // Scheduled runs [SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'], diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 8a1d500fc..a612fdbfc 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -711,6 +711,45 @@ function computeConnectionLossUi(input) { // SSE staleness policy: is this stream a zombie? // +// Session Options → Respawn → "Prompt cache": one sentence of the main +// conversation's cache state as Claude Code reports it on the statusline +// (`session.promptCache`, v2.1.251+). A clock time rather than a countdown +// because the row refreshes on SSE, not on a timer, and a countdown would sit +// stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the +// session's `statusLineTelemetry`: a launch without the exporter never reports, so +// it says why instead of "not reported yet" forever. +const PROMPT_CACHE_NOT_COLLECTED = { + 'collection-off': + 'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.', + 'workspace-statusline': + "Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.", + remote: 'Not collected for remote and Docker sessions.', +}; +function formatPromptCacheStatus(cache, now, statusLine) { + if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return PROMPT_CACHE_NOT_COLLECTED[statusLine]; + if (!cache || typeof cache.warm !== 'boolean') { + return 'Not reported yet. Claude Code 2.1.251 or newer reports it after the first response.'; + } + const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const kTokens = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`); + const stake = cache.recacheTokensIfCold; + const tokens = Number.isFinite(stake) && stake > 0 ? kTokens(stake) : null; + const parts = []; + if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) { + const ttl = cache.ttl ? ` (${cache.ttl} cache)` : ''; + parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`); + if (tokens) parts.push(`About ${tokens} tokens would be re-read if it goes cold.`); + } else { + parts.push(tokens ? `Cold. The next turn re-reads about ${tokens} tokens at the cache-write rate.` : 'Cold.'); + } + // Claude Code's own idle compaction kept the prefix warm but smaller, so there is no + // re-read stake to show; naming when it happened explains the shrink. Never an alert. + if (Number.isFinite(cache.compactedAt)) parts.push(`Compacted at ${clock(cache.compactedAt)}.`); + if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`); + if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`); + return parts.join(' '); +} + // An EventSource that stops delivering does not always error. A proxy that // idle-closed the connection, a laptop resumed from sleep, a tailnet // reconnect: `onerror` never fires, the header dot stays green, and every @@ -1447,6 +1486,9 @@ if (typeof window !== 'undefined') { compute: computeSseStale, TIMEOUT_MS: SSE_STALE_TIMEOUT_MS, }; + window.CodemanPromptCache = { + format: formatPromptCacheStatus, + }; window.CodemanSessionOrder = { RANK: SESSION_ACTIVITY_RANK, anchor: sessionActivityAnchor, @@ -1632,6 +1674,7 @@ const SSE_EVENTS = { SESSION_INTERACTIVE: 'session:interactive', SESSION_RUNNING: 'session:running', SESSION_STATUS_TELEMETRY: 'session:statusTelemetry', + SESSION_PROMPT_CACHE: 'session:promptCache', // Scheduled runs SCHEDULED_CREATED: 'scheduled:created', diff --git a/src/web/public/index.html b/src/web/public/index.html index cb6d38d02..6d58dcb30 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1286,6 +1286,22 @@

Respawn

+ +
+

Prompt cache

+

While the cached conversation is warm, a turn re-reads it at the cheap cached rate. Once it sits idle past the cache lifetime, the next turn re-processes the whole context at the cache-write rate.

+
+
+
+ Status + Not reported yet. +
+
+
+
+

Loop control

diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 3ec17fe06..39b186788 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2534,6 +2534,7 @@ Object.assign(CodemanApp.prototype, { // Populate auto-resume on usage limit (token pause control) document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false; this.updateAutoResumeStatus(sessionId); + this.updatePromptCacheStatus(sessionId); document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true; document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false; @@ -2784,6 +2785,27 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Session Options → Respawn → "Prompt cache": the main conversation's cache state as + * Claude Code reports it on the statusline (`session.promptCache`, v2.1.251+), worded by + * the pure `CodemanPromptCache.format` (constants.js). Refreshed on open and on the + * `session:promptCache` SSE event while the modal shows that session. + */ + updatePromptCacheStatus(sessionId) { + const el = document.getElementById('promptCacheStatus'); + if (!el || this.editingSessionId !== sessionId) return; + const session = this.sessions.get(sessionId); + el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineTelemetry); + }, + + _onSessionPromptCache(data) { + const { sessionId, ...promptCache } = data; + const session = this.sessions.get(sessionId); + if (!session) return; + session.promptCache = promptCache; + this.updatePromptCacheStatus(sessionId); + }, + async toggleSessionImageWatcher() { if (!this.editingSessionId) return; const enabled = document.getElementById('modalImageWatcherEnabled').checked; diff --git a/src/web/routes/status-telemetry-routes.ts b/src/web/routes/status-telemetry-routes.ts index b7074c6c2..56f333436 100644 --- a/src/web/routes/status-telemetry-routes.ts +++ b/src/web/routes/status-telemetry-routes.ts @@ -20,13 +20,14 @@ import { FastifyInstance } from 'fastify'; import { StatusTelemetrySchema } from '../schemas.js'; import { parseBody } from '../route-helpers.js'; import { + parsePromptCache, parseStatusTelemetry, parseSessionStatus, formatSessionStatusText, telemetrySignature, type RawStatuslinePayload, } from '../../usage-telemetry.js'; -import { SessionStatusTelemetry } from '../sse-events.js'; +import { SessionPromptCache, SessionStatusTelemetry } from '../sse-events.js'; import { setLatestPlanUsage } from '../plan-usage-latest.js'; import type { SessionPort, EventPort } from '../ports/index.js'; import { getCli } from '../../config/cli-registry/index.js'; @@ -59,6 +60,19 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session session.noteReportedModel('statusline', payload?.model?.display_name); } + // 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 + // carries the STORED object, so a compaction stamp set in setPromptCache rides along. + // Reports are applied in arrival order. When the exporter wraps a user's own statusline + // it backgrounds this POST (hooks-config.ts), so two sub-second loopback reports can + // arrive out of order; a stale one then briefly overwrites newer state until the next + // render corrects it. Accepted for a display-only readout rather than ordered here. + const promptCache = parsePromptCache(payload); + if (promptCache && session?.setPromptCache(promptCache)) { + ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache }); + } + // Plan-usage limits (account-wide) → broadcast to the header chip, when // present and changed (the statusline fires on every assistant message). const telemetry = parseStatusTelemetry(payload); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index d242a6254..23e0f650a 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -653,6 +653,34 @@ export const StatusTelemetrySchema = z.object({ .nullish(), cost: z.object({ total_cost_usd: z.number().nullish() }).nullish(), model: z.object({ display_name: z.string().max(100).nullish() }).nullish(), + // Main-conversation prompt-cache statistics (Claude Code v2.1.251+). Every documented + // scalar field is listed nullish: `expires_at`/`hit_ratio`/`last_miss_at`/ + // `recache_tokens_if_cold` are all documented as possibly null, and the object gains + // fields over CLI versions, so a stricter shape would 400 the whole post and blank the + // footer (the recurring .optional()/null trap). Causes stay `unknown` (the parser keeps + // the strings); `last_miss_cause`'s extra numeric keys and the `miss_causes` record are + // stripped by Zod unread, which keeps the stored object bounded — a hostile localhost + // payload cannot bloat state, since only the normalized subset below is ever stored. + prompt_cache: z + .object({ + warm: z.boolean().nullish(), + caching_observed: z.boolean().nullish(), + // No length cap: a future TTL longer than today's `5m`/`1h` must not 400 the whole + // post and blank the footer. parsePromptCache discards any TTL it does not know, so + // the stored value stays bounded to those two regardless. + ttl: z.string().nullish(), + expires_at: z.number().nullish(), + requests: z.number().nullish(), + misses: z.number().nullish(), + expected_rebuilds: z.number().nullish(), + hit_ratio: z.number().nullish(), + cache_write_tokens: z.number().nullish(), + miss_recache_tokens: z.number().nullish(), + last_miss_at: z.number().nullish(), + recache_tokens_if_cold: z.number().nullish(), + last_miss_cause: z.object({ causes: z.array(z.unknown()).max(20).nullish() }).nullish(), + }) + .nullish(), }) .nullish(), }); diff --git a/src/web/server.ts b/src/web/server.ts index bb159e16f..f51884ef4 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3569,6 +3569,11 @@ export class WebServer extends EventEmitter { // tile header keeps naming it until the next report; an idle claude // session re-renders its statusline only on its next turn. displayModel: savedState?.displayModel, + // The last prompt-cache report, restored for the same reason: an idle claude + // session re-renders its statusline only on its next turn, so without this the + // Session Options readout would read "not reported yet" after every restart. The + // readout is time-aware, so a value past its expiry shows as cold, not stale-warm. + promptCache: savedState?.promptCache, // A record rebuilt from the socket has no provenance, so its // apparent locality is a guess (see `MuxSession.discovered`). discoveredMuxSession: muxSession.discovered, diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index f18fbf6d3..b7020b9d2 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -5,10 +5,10 @@ * and referenced by the frontend (`SSE_EVENTS` in `constants.js`). * Both files MUST be kept in sync. * - * 161 event constants organized by category: + * 162 event constants organized by category: * - **Core** (1): init * - **Transport** (1): sse:heartbeat - * - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ... + * - **Session lifecycle** (24): created, updated, deleted, terminal, idle, working, ... * - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ... * - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate * - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded @@ -119,6 +119,8 @@ export const SessionInteractive = 'session:interactive' as const; export const SessionRunning = 'session:running' as const; /** Combined Claude and main Codex plan-usage telemetry for the shared header chip. */ export const SessionStatusTelemetry = 'session:statusTelemetry' as const; +/** Prompt-cache state of one session's main conversation changed (`{ sessionId, ...PromptCacheStatus }`). */ +export const SessionPromptCache = 'session:promptCache' as const; // ─── Session: Ralph ────────────────────────────────────────────────────────── @@ -526,6 +528,7 @@ export const SseEvent = { SessionInteractive, SessionRunning, SessionStatusTelemetry, + SessionPromptCache, // Session: Ralph SessionRalphLoopUpdate, diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 17c838ae2..9b5d01fe6 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -4,7 +4,7 @@ */ import { EventEmitter } from 'node:events'; import { vi } from 'vitest'; -import type { SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js'; +import type { PromptCacheStatus, SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js'; /** * Enhanced mock session for testing RespawnController. @@ -41,6 +41,13 @@ export class MockSession extends EventEmitter { * need to be able to set it. */ watching: string | null = null; + /** Mirrors Session.promptCache / setPromptCache (fed by POST /api/status-telemetry). */ + promptCache: PromptCacheStatus | null = null; + setPromptCache(status: PromptCacheStatus): boolean { + const changed = JSON.stringify(status) !== JSON.stringify(this.promptCache); + this.promptCache = status; + return changed; + } private _activeChildProcesses: { pid: number; command: string }[] = []; ralphTracker: null = null; writeBuffer: string[] = []; diff --git a/test/prompt-cache-status.test.ts b/test/prompt-cache-status.test.ts new file mode 100644 index 000000000..ec0e67bf7 --- /dev/null +++ b/test/prompt-cache-status.test.ts @@ -0,0 +1,91 @@ +/** + * The Session Options "Prompt cache" readout (`CodemanPromptCache.format`, constants.js): + * the one sentence that tells the user whether the next turn re-reads the conversation + * from cache or re-processes it cold. Loaded the way the other constants.js policies are. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; + +type Cache = { + warm: boolean; + ttl?: string; + expiresAt?: number; + recacheTokensIfCold?: number; + hitRatio?: number; + lastMissCauses?: string[]; + compactedAt?: number; +}; + +function loadFormat() { + const context = vm.createContext({ window: {}, globalThis: {} }); + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + vm.runInContext(source, context, { filename: 'constants.js' }); + return (context.window as { CodemanPromptCache: { format: (c: Cache | null | undefined, now: number) => string } }) + .CodemanPromptCache.format as (c: Cache | null | undefined, now: number, statusLine?: string) => string; +} + +const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +const NOW = Date.UTC(2026, 9, 6, 22, 18, 0); + +describe('Prompt cache readout', () => { + it('says so when Claude has not reported the cache yet (older CLI or no response yet)', () => { + const format = loadFormat(); + expect(format(null, NOW)).toMatch(/^Not reported yet/); + expect(format(undefined, NOW)).toMatch(/^Not reported yet/); + }); + + it('says why when the launch carries no statusLine exporter, whatever report is stored', () => { + const format = loadFormat(); + expect(format(null, NOW, 'collection-off')).toMatch(/^Not collected: Plan Usage was off when this session started/); + expect(format({ warm: true, expiresAt: NOW + 60_000 }, NOW, 'collection-off')).toMatch(/^Not collected/); + expect(format(null, NOW, 'workspace-statusline')).toMatch(/settings\.local\.json sets its own statusLine/); + expect(format(null, NOW, 'remote')).toBe('Not collected for remote and Docker sessions.'); + expect(format(null, NOW, 'injected')).toMatch(/^Not reported yet/); + }); + + it('warm: a clock time, the lifetime and the tokens at stake', () => { + const format = loadFormat(); + const expiresAt = NOW + 42 * 60_000; + expect(format({ warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 109833 }, NOW)).toBe( + `Warm until ${clock(expiresAt)} (1h cache). About 110k tokens would be re-read if it goes cold.` + ); + expect(format({ warm: true, expiresAt }, NOW)).toBe(`Warm until ${clock(expiresAt)}.`); + }); + + it('cold once reported cold, past the expiry, or warm with no expiry to promise', () => { + const format = loadFormat(); + expect(format({ warm: false, recacheTokensIfCold: 89882 }, NOW)).toBe( + 'Cold. The next turn re-reads about 90k tokens at the cache-write rate.' + ); + expect(format({ warm: true, expiresAt: NOW - 1, recacheTokensIfCold: 500 }, NOW)).toBe( + 'Cold. The next turn re-reads about 500 tokens at the cache-write rate.' + ); + expect(format({ warm: true }, NOW)).toBe('Cold.'); + expect(format({ warm: false }, NOW)).toBe('Cold.'); + }); + + it('appends the hit rate and the last miss cause when Claude reports them', () => { + const format = loadFormat(); + const expiresAt = NOW + 42 * 60_000; + expect(format({ warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 50000, hitRatio: 0.91 }, NOW)).toBe( + `Warm until ${clock(expiresAt)} (1h cache). About 50k tokens would be re-read if it goes cold. 91% cache hit rate.` + ); + expect( + format({ warm: false, recacheTokensIfCold: 90000, hitRatio: 0.6, lastMissCauses: ['ttl_expired_1h'] }, NOW) + ).toBe( + 'Cold. The next turn re-reads about 90k tokens at the cache-write rate. 60% cache hit rate. Last miss: ttl_expired_1h.' + ); + }); + + it("names Claude Code's own idle compaction, with no re-read stake while the prefix is still warm", () => { + const format = loadFormat(); + const expiresAt = NOW + 42 * 60_000; + const compactedAt = NOW - 6 * 60_000; + // recacheTokensIfCold is null right after a compaction, so no "would be re-read" clause. + expect(format({ warm: true, ttl: '1h', expiresAt, compactedAt, hitRatio: 0.88 }, NOW)).toBe( + `Warm until ${clock(expiresAt)} (1h cache). Compacted at ${clock(compactedAt)}. 88% cache hit rate.` + ); + }); +}); diff --git a/test/routes/status-telemetry-routes.test.ts b/test/routes/status-telemetry-routes.test.ts index f85b8c693..55199ec2f 100644 --- a/test/routes/status-telemetry-routes.test.ts +++ b/test/routes/status-telemetry-routes.test.ts @@ -6,13 +6,14 @@ * skip, per-session change-detection (dedup), rebroadcast on a displayed change, * NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented * fields (the .nullish() schema — the project's recurring .optional()/null trap), + * the prompt-cache group of the footer (warm and cold shapes of `prompt_cache`), * and 400 on a malformed body. Also the session's model (`displayModel`) the route * records off the same payload. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js'; -import { SessionStatusTelemetry } from '../../src/web/sse-events.js'; +import { SessionPromptCache, SessionStatusTelemetry } from '../../src/web/sse-events.js'; import { Session } from '../../src/session.js'; const SID = 'test-session-1'; // default id created by createMockRouteContext @@ -97,6 +98,62 @@ describe('POST /api/status-telemetry', () => { expect(payload.sevenDay).toBeUndefined(); }); + it('prints the prompt-cache state in the footer: a clock time while warm, cold once Claude says so', async () => { + const expiresAtS = Math.floor(Date.now() / 1000) + 3600; + const warm = await post({ + sessionId: SID, + data: { + ...REAL, + prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, recache_tokens_if_cold: 109833 }, + }, + }); + expect(warm.statusCode).toBe(200); + expect(warm.body).toMatch(/^Opus 4\.8 \(1M context\) in:562,411 out:1,188 ctx:56% cache:until \d\d:\d\d$/); + + // The cold flip Claude sends when expires_at passes: null expiry, and a last_miss_cause + // that carries numeric detail keys beside `causes` (must not 400, which would blank the footer). + const cold = await post({ + sessionId: SID, + data: { + ...REAL, + prompt_cache: { + warm: false, + ttl: '1h', + expires_at: null, + misses: 1, + last_miss_cause: { causes: ['ttl_expired_1h'], system_char_delta: 0 }, + }, + }, + }); + expect(cold.statusCode).toBe(200); + expect(cold.body.endsWith(' cache:cold')).toBe(true); + + // The report is kept on the session (toState / Session Options readout) and each CHANGE + // is broadcast on its own event; the plan chip did not change, so it broadcast once. + const session = h.ctx.sessions.get(SID) as unknown as { promptCache: { warm: boolean } | null }; + expect(session.promptCache).toMatchObject({ + warm: false, + ttl: '1h', + misses: 1, + lastMissCauses: ['ttl_expired_1h'], + }); + const events = h.ctx.broadcast.mock.calls.map(([name]) => name); + expect(events.filter((e) => e === SessionStatusTelemetry)).toHaveLength(1); + expect(events.filter((e) => e === SessionPromptCache)).toHaveLength(2); + expect(h.ctx.broadcast).toHaveBeenCalledWith( + SessionPromptCache, + expect.objectContaining({ sessionId: SID, warm: true, ttl: '1h', recacheTokensIfCold: 109833 }) + ); + }); + + it('does not rebroadcast an unchanged prompt-cache report', async () => { + const pc = { warm: true, ttl: '1h', expires_at: Math.floor(Date.now() / 1000) + 3600 }; + await post({ sessionId: SID, data: { ...REAL, prompt_cache: pc } }); + await post({ sessionId: SID, data: { ...REAL, prompt_cache: pc } }); + const events = h.ctx.broadcast.mock.calls.map(([name]) => name); + expect(events.filter((e) => e === SessionPromptCache)).toHaveLength(1); + }); + it('rejects a malformed body (missing sessionId) with 400', async () => { const res = await post({ data: REAL }); expect(res.statusCode).toBe(400); @@ -154,4 +211,36 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => { await post({ sessionId: 'codex-1', data: { model: { display_name: 'Opus 4.8' } } }); expect(session.toState().displayModel).toBeUndefined(); }); + + it('broadcasts the STORED prompt-cache object, so a compaction stamp reaches the live readout', async () => { + const session = realSession('claude', 'claude-compact'); + const expiresAtS = Math.floor(Date.now() / 1000) + 3600; + // A normal warm report (rebuild baseline 0), then Claude Code's idle compaction: recache + // goes null (absent), expected_rebuilds rises above the known baseline, still warm. The + // session stamps compactedAt in setPromptCache. + await post({ + sessionId: 'claude-compact', + data: { + prompt_cache: { + warm: true, + ttl: '1h', + expires_at: expiresAtS, + expected_rebuilds: 0, + recache_tokens_if_cold: 500000, + }, + }, + }); + await post({ + sessionId: 'claude-compact', + data: { prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, expected_rebuilds: 1 } }, + }); + expect(typeof session.toState().promptCache?.compactedAt).toBe('number'); + // The broadcast must carry the stored object, not just the freshly parsed one, or the + // frontend's live update would never see the stamp (only a modal reopen would). + const last = (h.ctx.broadcast.mock.calls as [string, Record][]) + .filter(([name]) => name === SessionPromptCache) + .at(-1)?.[1]; + expect(last).toMatchObject({ sessionId: 'claude-compact', warm: true }); + expect(typeof last?.compactedAt).toBe('number'); + }); }); diff --git a/test/session-prompt-cache.test.ts b/test/session-prompt-cache.test.ts new file mode 100644 index 000000000..d2463743a --- /dev/null +++ b/test/session-prompt-cache.test.ts @@ -0,0 +1,156 @@ +/** + * Session.setPromptCache's compaction detection and the restore of the last report. + * + * The statusline never reports WHEN Claude Code's own idle compaction happened, so the + * session stamps it: `recacheTokensIfCold` null (the transient post-compaction window the + * CC statusline docs describe) while `expectedRebuilds` rose above the last report. The + * stamp drives the "compacted at HH:MM" readout; it is never an alert. A rise is only a rise + * against a KNOWN prior counter, so a first high report never invents one. The last report is + * restored through the constructor (keeping an old compaction stamp, never re-stamping at + * restart time), and a conversation switch drops it. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Session } from '../src/session.js'; +import type { PromptCacheStatus } from '../src/types.js'; + +function claudeSession(extra: Record = {}): Session { + return new Session({ workingDir: '/tmp', mode: 'claude', ...extra } as ConstructorParameters[0]); +} + +describe('Session.setPromptCache compaction detection', () => { + afterEach(() => vi.useRealTimers()); + + it('stamps compactedAt only when recache went null and expected_rebuilds rose', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-06T22:05:00Z')); + const s = claudeSession(); + + // A normal warm report establishes the rebuild baseline (known 0): recache known, no + // compaction yet. Claude reports expected_rebuilds from the first response, so a real + // baseline always carries it. + s.setPromptCache({ + warm: true, + ttl: '1h', + expiresAt: Date.now() + 3600_000, + expectedRebuilds: 0, + recacheTokensIfCold: 50000, + }); + expect(s.promptCache?.compactedAt).toBeUndefined(); + + // Claude Code compacts: recache goes null (absent after parsing), expected_rebuilds rises, + // the prefix stays warm. This is the one transition that stamps. + vi.setSystemTime(new Date('2026-10-06T22:31:00Z')); + s.setPromptCache({ warm: true, ttl: '1h', expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 }); + expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z')); + }); + + it('carries the stamp forward through the transient window, drops it once a real request re-caches', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-06T22:30:00Z')); + const s = claudeSession(); + // Baseline (known rebuild count) then the compaction, so the rise is against a known prior. + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 0, recacheTokensIfCold: 70000 }); + vi.setSystemTime(new Date('2026-10-06T22:31:00Z')); + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 }); + const stampedAt = s.promptCache?.compactedAt; + expect(stampedAt).toBe(Date.parse('2026-10-06T22:31:00Z')); + + // A later idle report, still no request since the compaction: same rebuild count, recache + // still null. The stamp is kept (not re-stamped), so the clock in the readout does not move. + vi.setSystemTime(new Date('2026-10-06T22:45:00Z')); + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 }); + expect(s.promptCache?.compactedAt).toBe(stampedAt); + + // The next real request records the rewritten size (recache non-null): the window is over. + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1, recacheTokensIfCold: 42000 }); + expect(s.promptCache?.compactedAt).toBeUndefined(); + }); + + it('never stamps a plain miss (recache present) or an unchanged rebuild count', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-06T22:31:00Z')); + const s = claudeSession(); + // expected_rebuilds rose but recache is present: the transient window was already missed. + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 2, recacheTokensIfCold: 60000 }); + expect(s.promptCache?.compactedAt).toBeUndefined(); + // recache null but the rebuild count did not rise: not a fresh compaction. + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 2 }); + expect(s.promptCache?.compactedAt).toBeUndefined(); + }); + + it('never invents a stamp without a KNOWN prior rebuild count (a rise needs two known reports)', () => { + // The very first report Codeman sees already carries a high count with recache null (a + // restart mid-session). Treating an absent prior as 0 would fabricate "compacted now". + const s1 = claudeSession(); + s1.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 }); + expect(s1.promptCache?.compactedAt).toBeUndefined(); + + // `4 -> omitted -> 4`: a report that drops the counter clears the known prior, so the next + // report carrying 4 again is not a provable rise. + const s2 = claudeSession(); + s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4, recacheTokensIfCold: 10 }); + s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000 }); // counter omitted + s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 }); + expect(s2.promptCache?.compactedAt).toBeUndefined(); + }); + + it('reports changed only when the stored object differs', () => { + const s = claudeSession(); + const pc: PromptCacheStatus = { warm: true, ttl: '1h', expiresAt: 1, recacheTokensIfCold: 10 }; + expect(s.setPromptCache({ ...pc })).toBe(true); + expect(s.setPromptCache({ ...pc })).toBe(false); + expect(s.setPromptCache({ ...pc, warm: false })).toBe(true); + }); +}); + +describe('prompt-cache report survives a restart', () => { + it('restores the constructor value onto the session and toState, and a new report replaces it', () => { + const restored: PromptCacheStatus = { warm: true, ttl: '1h', expiresAt: 1, recacheTokensIfCold: 25000, misses: 1 }; + const s = claudeSession({ promptCache: restored }); + expect(s.promptCache).toEqual(restored); + expect(s.toState().promptCache).toEqual(restored); + s.setPromptCache({ warm: false }); + expect(s.toState().promptCache).toEqual({ warm: false }); + }); + + it('is absent, not null, when nothing was restored', () => { + const s = claudeSession(); + expect(s.promptCache).toBeNull(); + expect(s.toState().promptCache).toBeUndefined(); + }); + + it('restores a post-compaction value WITHOUT re-stamping it at restart time', () => { + // A direct restore, not a setPromptCache call: an already-compacted report (recache absent, + // expectedRebuilds > 0, an OLD compactedAt) must keep its original timestamp through + // construction, never be re-stamped with "now" as a fresh compaction. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-06T23:59:00Z')); + const oldStamp = Date.parse('2026-10-06T22:31:00Z'); + const s = claudeSession({ + promptCache: { + warm: true, + ttl: '1h', + expiresAt: Date.now() + 600_000, + expectedRebuilds: 3, + compactedAt: oldStamp, + }, + }); + expect(s.promptCache?.compactedAt).toBe(oldStamp); + vi.useRealTimers(); + }); +}); + +describe('a conversation switch drops the prompt-cache report', () => { + it('clears it when the Claude conversation id changes, keeps it on a same-id reattach', () => { + const s = claudeSession(); + s.adoptClaudeSessionId('conv-a', { firstHand: true }); // establish the current conversation + s.setPromptCache({ warm: true, ttl: '1h', expiresAt: Date.now() + 600_000, expectedRebuilds: 0 }); + // A hook reports the SAME conversation (a reattach): the report must survive. + s.adoptClaudeSessionId('conv-a', { firstHand: true }); + expect(s.promptCache).not.toBeNull(); + // A post-/clear switch to a NEW conversation: the old prefix's state is gone. + s.adoptClaudeSessionId('conv-b', { firstHand: true }); + expect(s.promptCache).toBeNull(); + expect(s.toState().promptCache).toBeUndefined(); + }); +}); diff --git a/test/statusline-cli-flag.test.ts b/test/statusline-cli-flag.test.ts index dd85368a2..ce4927a08 100644 --- a/test/statusline-cli-flag.test.ts +++ b/test/statusline-cli-flag.test.ts @@ -15,9 +15,13 @@ * sees. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { buildSpawnCommand } from '../src/tmux-manager.js'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { buildSpawnCommand, TmuxManager } from '../src/tmux-manager.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; const EXPORTER_CMD = 'curl -sfk -X POST "$CODEMAN_API_URL/api/status-telemetry" --data @- 2>/dev/null || true'; @@ -94,3 +98,52 @@ describe('buildSpawnCommand statusLineCommand (claude mode)', () => { expect(cmd).not.toContain('--settings'); }); }); + +// Why a launch does or does not carry the exporter. Recorded on the mux record and +// published as `statusLineTelemetry`, so the Status row and keep-warm can say why no +// cache report will come instead of "not reported yet" forever. +describe('TmuxManager statusLine classification', () => { + type Resolve = ( + mode: string, + workingDir: string, + remoteOrDocker: boolean + ) => Promise<{ command?: string; state?: string }>; + let workingDir: string; + let resolve: Resolve; + + beforeEach(() => { + workingDir = mkdtempSync(join(tmpdir(), 'statusline-state-')); + const mux = new TmuxManager() as unknown as { _resolveStatusLine: Resolve }; + resolve = mux._resolveStatusLine.bind(mux); + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + }); + afterEach(() => { + rmSync(workingDir, { recursive: true, force: true }); + rmSync(SETTINGS_PATH, { force: true }); + }); + + it('injected when plan-usage collection is on (an absent key reads as on)', async () => { + const r = await resolve('claude', workingDir, false); + expect(r.state).toBe('injected'); + expect(r.command).toBeTruthy(); + }); + + it('collection-off when the setting is explicitly false', async () => { + writeFileSync(SETTINGS_PATH, JSON.stringify({ showPlanUsageLimits: false })); + expect(await resolve('claude', workingDir, false)).toEqual({ command: undefined, state: 'collection-off' }); + }); + + it("workspace-statusline when the workspace's settings.local.json sets its own", async () => { + mkdirSync(join(workingDir, '.claude')); + writeFileSync( + join(workingDir, '.claude', 'settings.local.json'), + JSON.stringify({ statusLine: { type: 'command', command: 'echo mine' } }) + ); + expect(await resolve('claude', workingDir, false)).toEqual({ command: undefined, state: 'workspace-statusline' }); + }); + + it('remote for remote and docker launches, nothing at all for a CLI without the capability', async () => { + expect(await resolve('claude', workingDir, true)).toEqual({ state: 'remote' }); + expect(await resolve('codex', workingDir, false)).toEqual({}); + }); +}); diff --git a/test/usage-telemetry.test.ts b/test/usage-telemetry.test.ts index f5797dcb5..3d1ee7b87 100644 --- a/test/usage-telemetry.test.ts +++ b/test/usage-telemetry.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { parseStatusTelemetry, parseSessionStatus, + parsePromptCache, formatSessionStatusText, telemetrySignature, type RawStatuslinePayload, @@ -88,7 +89,111 @@ describe('parseStatusTelemetry', () => { }); }); +// Mirrors a captured `prompt_cache` object (CC 2.1.280, Claude Max, 1h TTL) from a +// session whose first turn finished at 23:00:06Z; expires_at is epoch SECONDS. +const EXPIRES_AT_S = Date.UTC(2026, 9, 6, 23, 0, 6) / 1000; +const WARM_CACHE: RawStatuslinePayload = { + prompt_cache: { + warm: true, + ttl: '1h', + expires_at: EXPIRES_AT_S, + recache_tokens_if_cold: 109833, + misses: 0, + last_miss_cause: null, + }, +}; + +/** The footer prints the server-local clock, so derive the expected text the same way. */ +function localHHMM(ms: number): string { + const d = new Date(ms); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +describe('parsePromptCache', () => { + it('normalizes the captured object, converting expires_at seconds to ms', () => { + expect(parsePromptCache(WARM_CACHE)).toEqual({ + warm: true, + ttl: '1h', + expiresAt: EXPIRES_AT_S * 1000, + recacheTokensIfCold: 109833, + misses: 0, + }); + }); + + it('keeps a cold report (warm:false, null expires_at) and the diagnosed miss causes', () => { + expect( + parsePromptCache({ + prompt_cache: { + warm: false, + ttl: '1h', + expires_at: null, + misses: 1, + // Real shape: numeric detail keys ride next to `causes` + last_miss_cause: { causes: ['ttl_expired_1h', 7, ''] } as never, + }, + }) + ).toEqual({ warm: false, ttl: '1h', misses: 1, lastMissCauses: ['ttl_expired_1h'] }); + }); + + it('returns null when the payload has no prompt_cache or warm is not a boolean (state unknown, not cold)', () => { + expect(parsePromptCache(undefined)).toBeNull(); + expect(parsePromptCache({})).toBeNull(); + expect(parsePromptCache({ prompt_cache: null })).toBeNull(); + expect(parsePromptCache({ prompt_cache: { warm: null, ttl: '1h' } })).toBeNull(); + }); + + it('drops a TTL it does not know and a non-positive or non-finite expiry', () => { + expect(parsePromptCache({ prompt_cache: { warm: true, ttl: '2h', expires_at: 0 } })).toEqual({ warm: true }); + expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: Number.NaN } })).toEqual({ warm: true }); + expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: -5 } })).toEqual({ warm: true }); + }); + + it('drops an expiry that overflows or leaves the Date range after the seconds->ms multiply', () => { + // These pass `expires_at > 0 && isFinite` on the SECONDS but render "cache:until NaN:NaN" + // once converted, so the converted ms must be validated too (finite, within Date range). + expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: Number.POSITIVE_INFINITY } })).toEqual({ + warm: true, + }); + expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: 1e308 } })).toEqual({ warm: true }); // *1000 = Infinity + expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: 1e14 } })).toEqual({ warm: true }); // 1e17 ms, out of range + // The documented boundary still parses (8.64e15 ms = 8.64e12 s). + expect(parsePromptCache({ prompt_cache: { warm: true, expires_at: 8.64e12 } })).toEqual({ + warm: true, + expiresAt: 8.64e15, + }); + }); + + it('keeps hit_ratio (clamped 0-1) and expected_rebuilds (rounded, non-negative)', () => { + expect(parsePromptCache({ prompt_cache: { warm: true, hit_ratio: 0.91, expected_rebuilds: 2 } })).toEqual({ + warm: true, + hitRatio: 0.91, + expectedRebuilds: 2, + }); + // Out-of-range or non-finite values are clamped or dropped, never stored as-is. + expect(parsePromptCache({ prompt_cache: { warm: true, hit_ratio: 1.5 } })).toEqual({ warm: true, hitRatio: 1 }); + expect(parsePromptCache({ prompt_cache: { warm: true, hit_ratio: -0.2 } })).toEqual({ warm: true, hitRatio: 0 }); + // expected_rebuilds is rounded and floored at 0 (it indexes the compaction rise). + expect(parsePromptCache({ prompt_cache: { warm: true, expected_rebuilds: 2.9 } })).toEqual({ + warm: true, + expectedRebuilds: 3, + }); + expect(parsePromptCache({ prompt_cache: { warm: true, expected_rebuilds: -4 } })).toEqual({ + warm: true, + expectedRebuilds: 0, + }); + expect(parsePromptCache({ prompt_cache: { warm: true, hit_ratio: null, expected_rebuilds: null } })).toEqual({ + warm: true, + }); + }); +}); + describe('parseSessionStatus', () => { + 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) + expect(parseSessionStatus(WARM_CACHE)).toEqual({ cache: parsePromptCache(WARM_CACHE) }); + }); + it('extracts model, token totals, and context % for the footer', () => { const s = parseSessionStatus(REAL); expect(s).toEqual({ @@ -123,6 +228,22 @@ describe('formatSessionStatusText', () => { expect(formatSessionStatusText(null)).toBe(''); expect(formatSessionStatusText({} as never)).toBe(''); }); + + it('shows the warm cache as a server-local clock time, not a countdown that would sit stale', () => { + const expiresAt = EXPIRES_AT_S * 1000; + const now = expiresAt - 42 * 60_000; + expect(formatSessionStatusText(parseSessionStatus({ ...REAL, ...WARM_CACHE }), now)).toBe( + `Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56% cache:until ${localHHMM(expiresAt)}` + ); + }); + + it('shows cache:cold once Claude reports it cold or the render happened past expires_at', () => { + const expiresAt = EXPIRES_AT_S * 1000; + expect(formatSessionStatusText({ cache: { warm: false } })).toBe('cache:cold'); + expect(formatSessionStatusText({ cache: { warm: true, expiresAt } }, expiresAt + 1)).toBe('cache:cold'); + // warm without an expiry is unknowable, so it is not promised as warm + expect(formatSessionStatusText({ cache: { warm: true } })).toBe('cache:cold'); + }); }); describe('telemetrySignature', () => { From ecb2e3d8f32bb10f2dfde13afe2a70a265b8662c Mon Sep 17 00:00:00 2001 From: JD Date: Sun, 11 Oct 2026 12:36:59 -0400 Subject: [PATCH 2/2] fix(telemetry): stamp a compaction on entering the null window Claude Code's tracker nulls recache_tokens_if_cold at a compaction or a tool-result clearing and only counts the expected rebuild at the next request, which also closes the window, so no report carried both and the old rule never fired. Stamp on the first warm null report after one that carried a value, carry it through idle re-reports and drop it at the next request; the readout now says "Context compacted at HH:MM" since a clearing opens the same window. Also persist a changed report so a crash restores the latest one, rename the session field to statusLineExporter (statusLineTelemetry is the registry capability) and document its values, word the stake as re-cached, move the readout block above the SSE staleness heading, and drop keep-warm mentions until it lands. --- CLAUDE.md | 2 +- docs/api-reference.md | 8 +- docs/architecture-invariants.md | 2 +- docs/usage-limits-display-plan.md | 2 +- docs/wiki/Keeping-Agents-Running.md | 2 +- src/mux-interface.ts | 4 +- src/session.ts | 29 +++--- src/tmux-manager.ts | 6 +- src/types/session.ts | 16 ++-- src/web/public/constants.js | 16 ++-- src/web/public/session-ui.js | 2 +- src/web/routes/status-telemetry-routes.ts | 2 + test/prompt-cache-status.test.ts | 20 ++-- test/routes/status-telemetry-routes.test.ts | 18 +++- test/session-prompt-cache.test.ts | 100 ++++++++------------ test/statusline-cli-flag.test.ts | 2 +- 16 files changed, 108 insertions(+), 123 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa6aa9011..9308f6108 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-read stake, the hit rate, the last miss's causes, and a `compacted at HH:MM` note for Claude Code's own idle compaction (`recache_tokens_if_cold` null while `expected_rebuilds` rose — NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` +**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 770ac9174..ce307e823 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -645,7 +645,7 @@ change-only `session:promptCache` event (`{ sessionId, ...promptCache }`): | `expectedRebuilds` | Cache rebuilds that followed a compaction or a clearing of old tool results. | | `hitRatio` | Cache read tokens as a fraction of all input tokens this session, 0 to 1. | | `lastMissCauses` | Claude Code's diagnosis of the last miss (e.g. `ttl_expired_1h`, `tools_changed`); untrusted text. | -| `compactedAt` | Epoch MILLISECONDS when Codeman first saw Claude Code's own idle compaction of the current prefix (derived, not reported). | +| `compactedAt` | Epoch MILLISECONDS when the context was compacted or its old tool results cleared: the first warm report without `recacheTokensIfCold` after one that had it. Derived, not reported; dropped at the next request. | The whole object is absent until the first report, and restored after a server restart (so the readout is not blank until the pane's next render) rather than recomputed; a value @@ -654,6 +654,12 @@ whose `expiresAt` has passed reads as cold. The statusline re-runs when a warm c it. Shown in Session Options → Respawn and, for sessions without a statusline of their own, as a `cache:until HH:MM` / `cache:cold` group in the terminal footer. +Beside it, `statusLineExporter` says whether this launch carries the statusline exporter +that sends the report, decided when the pane starts: `injected` (it does), `collection-off` +(Plan Usage was off at launch), `workspace-statusline` (the workspace's +`.claude/settings.local.json` sets its own `statusLine`, which Codeman never replaces) or +`remote` (remote and Docker sessions). Absent when unknown. + ## Approvals Inbox Cross-session queue of prompts waiting on a human (permission dialogs, diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6a77a9350..5a19a5703 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -157,7 +157,7 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 omitted -> 4`), so both counters must be present. - if ( - inPostCompactWindow && - prev?.expectedRebuilds != null && - status.expectedRebuilds != null && - status.expectedRebuilds > prev.expectedRebuilds - ) { + if (inWindow && prev?.recacheTokensIfCold != null) { compactedAt = Date.now(); - } else if (prev?.compactedAt != null && inPostCompactWindow && status.expectedRebuilds === prev.expectedRebuilds) { + } else if (inWindow && prev?.compactedAt != null) { compactedAt = prev.compactedAt; } // A NEW object when a stamp applies, never a mutation of the caller's parsed report: the @@ -1956,7 +1949,7 @@ export class Session extends EventEmitter { autoResumeEnabled: this._autoOps.autoResumeEnabled, autoResumeAt: this._autoOps.autoResumeAt ?? undefined, promptCache: this._promptCache ?? undefined, - statusLineTelemetry: this._muxSession?.statusLine, + statusLineExporter: this._muxSession?.statusLine, imageWatcherEnabled: this._imageWatcherEnabled, pinned: this._pinned || undefined, pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined, diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 2adad4848..2f149cce9 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -59,7 +59,7 @@ import { type SessionDocker, type DockerCommandMode, type PaneExit, - type StatusLineTelemetry, + type StatusLineExporter, } from './types.js'; import { getCli } from './config/cli-registry/registry.js'; import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js'; @@ -2046,14 +2046,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * only (remote/docker have their own separate command builders). Resolving still runs * with collection off, because it also self-heals: it strips any legacy disk-written * exporter from an older Codeman build the first time a session starts in that - * workspace again. `state` lands on the mux record so the Status row and keep-warm can + * workspace again. `state` lands on the mux record so the Status row can * say why no cache report will come, instead of "not reported yet" forever. */ private async _resolveStatusLine( mode: SessionMode, workingDir: string, remoteOrDocker: boolean - ): Promise<{ command?: string; state?: StatusLineTelemetry }> { + ): Promise<{ command?: string; state?: StatusLineExporter }> { if (!getCli(mode)?.capabilities.statusLineTelemetry) return {}; if (remoteOrDocker) return { state: 'remote' }; const enabled = await readPlanUsageTelemetryEnabled(); diff --git a/src/types/session.ts b/src/types/session.ts index 5da97a48c..61b3d450b 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -747,10 +747,12 @@ export interface PromptCacheStatus { /** Claude Code's diagnosis of the last miss, e.g. `ttl_expired_1h` or `tools_changed`. */ lastMissCauses?: string[]; /** - * Epoch MS when Codeman first saw Claude Code's own idle compaction for the current prefix - * (`recacheTokensIfCold` null while `expectedRebuilds` rose). Derived in `Session.setPromptCache`, - * not reported by the statusline, so it is never set by `parsePromptCache`. Lets the readout say - * "compacted at HH:MM"; it is never an alert (CC's idle compaction is benign). + * Epoch MS when Codeman saw the context compacted (`/compact`, Claude Code's own idle + * compaction, or a clearing of old tool results): the first warm report whose + * `recacheTokensIfCold` is null after one that carried it. Carried while the null window holds, + * dropped at the next request. Derived in `Session.setPromptCache`, not reported by the + * statusline, so it is never set by `parsePromptCache`. Lets the readout say "Context compacted + * at HH:MM"; it is never an alert. */ compactedAt?: number; } @@ -765,7 +767,7 @@ export interface PromptCacheStatus { * statusLine (or could not be read), and Codeman never replaces one there. * - `remote`: remote and Docker launches never carry the exporter. */ -export type StatusLineTelemetry = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote'; +export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote'; export interface SessionState { /** Unique session identifier */ @@ -826,8 +828,8 @@ export interface SessionState { * `expiresAt` has passed reads as cold rather than as a stale warm. */ promptCache?: PromptCacheStatus; - /** Why a cache report will or will not arrive (see `StatusLineTelemetry`); absent when unknown. */ - statusLineTelemetry?: StatusLineTelemetry; + /** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */ + statusLineExporter?: StatusLineExporter; /** Pinned to the top of the session manager list (COD-139) */ pinned?: boolean; /** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */ diff --git a/src/web/public/constants.js b/src/web/public/constants.js index a612fdbfc..15c4c7cec 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -709,14 +709,12 @@ function computeConnectionLossUi(input) { }; } -// SSE staleness policy: is this stream a zombie? -// // Session Options → Respawn → "Prompt cache": one sentence of the main // conversation's cache state as Claude Code reports it on the statusline // (`session.promptCache`, v2.1.251+). A clock time rather than a countdown // because the row refreshes on SSE, not on a timer, and a countdown would sit // stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the -// session's `statusLineTelemetry`: a launch without the exporter never reports, so +// session's `statusLineExporter`: a launch without the exporter never reports, so // it says why instead of "not reported yet" forever. const PROMPT_CACHE_NOT_COLLECTED = { 'collection-off': @@ -738,18 +736,20 @@ function formatPromptCacheStatus(cache, now, statusLine) { if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) { const ttl = cache.ttl ? ` (${cache.ttl} cache)` : ''; parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`); - if (tokens) parts.push(`About ${tokens} tokens would be re-read if it goes cold.`); + if (tokens) parts.push(`About ${tokens} tokens would be re-cached if it goes cold.`); } else { - parts.push(tokens ? `Cold. The next turn re-reads about ${tokens} tokens at the cache-write rate.` : 'Cold.'); + parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.'); } - // Claude Code's own idle compaction kept the prefix warm but smaller, so there is no - // re-read stake to show; naming when it happened explains the shrink. Never an alert. - if (Number.isFinite(cache.compactedAt)) parts.push(`Compacted at ${clock(cache.compactedAt)}.`); + // A compaction or a tool-result clearing leaves no re-cache stake to show until the next + // request; naming when it happened explains the gap. Never an alert. + if (Number.isFinite(cache.compactedAt)) parts.push(`Context compacted at ${clock(cache.compactedAt)}.`); if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`); if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`); return parts.join(' '); } +// SSE staleness policy: is this stream a zombie? +// // An EventSource that stops delivering does not always error. A proxy that // idle-closed the connection, a laptop resumed from sleep, a tailnet // reconnect: `onerror` never fires, the header dot stays green, and every diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 39b186788..82e001cfb 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2795,7 +2795,7 @@ Object.assign(CodemanApp.prototype, { const el = document.getElementById('promptCacheStatus'); if (!el || this.editingSessionId !== sessionId) return; const session = this.sessions.get(sessionId); - el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineTelemetry); + el.textContent = window.CodemanPromptCache.format(session?.promptCache, Date.now(), session?.statusLineExporter); }, _onSessionPromptCache(data) { diff --git a/src/web/routes/status-telemetry-routes.ts b/src/web/routes/status-telemetry-routes.ts index 56f333436..a568ac74e 100644 --- a/src/web/routes/status-telemetry-routes.ts +++ b/src/web/routes/status-telemetry-routes.ts @@ -71,6 +71,8 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session const promptCache = parsePromptCache(payload); if (promptCache && session?.setPromptCache(promptCache)) { ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache }); + // Persisted on change, like a model change, so a crash restores the latest report. + ctx.persistSessionState(session); } // Plan-usage limits (account-wide) → broadcast to the header chip, when diff --git a/test/prompt-cache-status.test.ts b/test/prompt-cache-status.test.ts index ec0e67bf7..6f3e90263 100644 --- a/test/prompt-cache-status.test.ts +++ b/test/prompt-cache-status.test.ts @@ -1,6 +1,6 @@ /** * The Session Options "Prompt cache" readout (`CodemanPromptCache.format`, constants.js): - * the one sentence that tells the user whether the next turn re-reads the conversation + * the one sentence that tells the user whether the next turn reads the conversation * from cache or re-processes it cold. Loaded the way the other constants.js policies are. */ import { describe, it, expect } from 'vitest'; @@ -49,7 +49,7 @@ describe('Prompt cache readout', () => { const format = loadFormat(); const expiresAt = NOW + 42 * 60_000; expect(format({ warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 109833 }, NOW)).toBe( - `Warm until ${clock(expiresAt)} (1h cache). About 110k tokens would be re-read if it goes cold.` + `Warm until ${clock(expiresAt)} (1h cache). About 110k tokens would be re-cached if it goes cold.` ); expect(format({ warm: true, expiresAt }, NOW)).toBe(`Warm until ${clock(expiresAt)}.`); }); @@ -57,10 +57,10 @@ describe('Prompt cache readout', () => { it('cold once reported cold, past the expiry, or warm with no expiry to promise', () => { const format = loadFormat(); expect(format({ warm: false, recacheTokensIfCold: 89882 }, NOW)).toBe( - 'Cold. The next turn re-reads about 90k tokens at the cache-write rate.' + 'Cold. The next turn re-caches about 90k tokens.' ); expect(format({ warm: true, expiresAt: NOW - 1, recacheTokensIfCold: 500 }, NOW)).toBe( - 'Cold. The next turn re-reads about 500 tokens at the cache-write rate.' + 'Cold. The next turn re-caches about 500 tokens.' ); expect(format({ warm: true }, NOW)).toBe('Cold.'); expect(format({ warm: false }, NOW)).toBe('Cold.'); @@ -70,22 +70,20 @@ describe('Prompt cache readout', () => { const format = loadFormat(); const expiresAt = NOW + 42 * 60_000; expect(format({ warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 50000, hitRatio: 0.91 }, NOW)).toBe( - `Warm until ${clock(expiresAt)} (1h cache). About 50k tokens would be re-read if it goes cold. 91% cache hit rate.` + `Warm until ${clock(expiresAt)} (1h cache). About 50k tokens would be re-cached if it goes cold. 91% cache hit rate.` ); expect( format({ warm: false, recacheTokensIfCold: 90000, hitRatio: 0.6, lastMissCauses: ['ttl_expired_1h'] }, NOW) - ).toBe( - 'Cold. The next turn re-reads about 90k tokens at the cache-write rate. 60% cache hit rate. Last miss: ttl_expired_1h.' - ); + ).toBe('Cold. The next turn re-caches about 90k tokens. 60% cache hit rate. Last miss: ttl_expired_1h.'); }); - it("names Claude Code's own idle compaction, with no re-read stake while the prefix is still warm", () => { + it('names a compaction, with no re-cache stake while the prefix is still warm', () => { const format = loadFormat(); const expiresAt = NOW + 42 * 60_000; const compactedAt = NOW - 6 * 60_000; - // recacheTokensIfCold is null right after a compaction, so no "would be re-read" clause. + // recacheTokensIfCold is null right after a compaction, so no "would be re-cached" clause. expect(format({ warm: true, ttl: '1h', expiresAt, compactedAt, hitRatio: 0.88 }, NOW)).toBe( - `Warm until ${clock(expiresAt)} (1h cache). Compacted at ${clock(compactedAt)}. 88% cache hit rate.` + `Warm until ${clock(expiresAt)} (1h cache). Context compacted at ${clock(compactedAt)}. 88% cache hit rate.` ); }); }); diff --git a/test/routes/status-telemetry-routes.test.ts b/test/routes/status-telemetry-routes.test.ts index 55199ec2f..0741b17f1 100644 --- a/test/routes/status-telemetry-routes.test.ts +++ b/test/routes/status-telemetry-routes.test.ts @@ -215,9 +215,9 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => { it('broadcasts the STORED prompt-cache object, so a compaction stamp reaches the live readout', async () => { const session = realSession('claude', 'claude-compact'); const expiresAtS = Math.floor(Date.now() / 1000) + 3600; - // A normal warm report (rebuild baseline 0), then Claude Code's idle compaction: recache - // goes null (absent), expected_rebuilds rises above the known baseline, still warm. The - // session stamps compactedAt in setPromptCache. + // The order Claude Code really reports in: a warm report with 500k at stake, then + // `/compact` finishes (recache null, expected_rebuilds unchanged), and the session stamps + // compactedAt in setPromptCache on entering that window. await post({ sessionId: 'claude-compact', data: { @@ -232,7 +232,7 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => { }); await post({ sessionId: 'claude-compact', - data: { prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, expected_rebuilds: 1 } }, + data: { prompt_cache: { warm: true, ttl: '1h', expires_at: expiresAtS, expected_rebuilds: 0 } }, }); expect(typeof session.toState().promptCache?.compactedAt).toBe('number'); // The broadcast must carry the stored object, not just the freshly parsed one, or the @@ -243,4 +243,14 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => { expect(last).toMatchObject({ sessionId: 'claude-compact', warm: true }); expect(typeof last?.compactedAt).toBe('number'); }); + + it('persists a changed prompt-cache report, and only a changed one', async () => { + const session = realSession('claude', 'claude-persist'); + const data = { prompt_cache: { warm: true, ttl: '1h', expires_at: Math.floor(Date.now() / 1000) + 3600 } }; + await post({ sessionId: 'claude-persist', data }); + expect(h.ctx.persistSessionState).toHaveBeenCalledWith(session); + h.ctx.persistSessionState.mockClear(); + await post({ sessionId: 'claude-persist', data }); + expect(h.ctx.persistSessionState).not.toHaveBeenCalled(); + }); }); diff --git a/test/session-prompt-cache.test.ts b/test/session-prompt-cache.test.ts index d2463743a..9f9fa9425 100644 --- a/test/session-prompt-cache.test.ts +++ b/test/session-prompt-cache.test.ts @@ -1,13 +1,15 @@ /** * Session.setPromptCache's compaction detection and the restore of the last report. * - * The statusline never reports WHEN Claude Code's own idle compaction happened, so the - * session stamps it: `recacheTokensIfCold` null (the transient post-compaction window the - * CC statusline docs describe) while `expectedRebuilds` rose above the last report. The - * stamp drives the "compacted at HH:MM" readout; it is never an alert. A rise is only a rise - * against a KNOWN prior counter, so a first high report never invents one. The last report is - * restored through the constructor (keeping an old compaction stamp, never re-stamping at - * restart time), and a conversation switch drops it. + * The statusline never reports WHEN the context was compacted, so the session stamps it. + * Claude Code's tracker (2.1.294-2.1.296) opens a window at a compaction or a tool-result + * clearing in which `recacheTokensIfCold` reads null, and closes it on the next request, + * which is also the one that counts as an expected rebuild. So `expectedRebuilds` never rises + * inside the window, and the stamp fires on ENTERING it: a warm null report after one that + * carried a value. It is carried through idle re-reports, dropped at the next request, and a + * first report already inside the window never invents one. The fixtures follow that real + * order. The last report is restored through the constructor (keeping an old stamp, never + * re-stamping at restart time), and a conversation switch drops it. */ import { afterEach, describe, expect, it, vi } from 'vitest'; import { Session } from '../src/session.js'; @@ -20,78 +22,50 @@ function claudeSession(extra: Record = {}): Session { describe('Session.setPromptCache compaction detection', () => { afterEach(() => vi.useRealTimers()); - it('stamps compactedAt only when recache went null and expected_rebuilds rose', () => { + it('stamps when /compact finishes, keeps the stamp through idle re-reports, drops it at the next request', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-06T22:05:00Z')); const s = claudeSession(); + const expiresAt = Date.now() + 3600_000; - // A normal warm report establishes the rebuild baseline (known 0): recache known, no - // compaction yet. Claude reports expected_rebuilds from the first response, so a real - // baseline always carries it. - s.setPromptCache({ - warm: true, - ttl: '1h', - expiresAt: Date.now() + 3600_000, - expectedRebuilds: 0, - recacheTokensIfCold: 50000, - }); + // 1. Warm, 500k at stake. + s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0, recacheTokensIfCold: 500000 }); expect(s.promptCache?.compactedAt).toBeUndefined(); - // Claude Code compacts: recache goes null (absent after parsing), expected_rebuilds rises, - // the prefix stays warm. This is the one transition that stamps. + // 2. `/compact` finishes: recache null (absent after parsing), expected_rebuilds unchanged. vi.setSystemTime(new Date('2026-10-06T22:31:00Z')); - s.setPromptCache({ warm: true, ttl: '1h', expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 }); + s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0 }); expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z')); - }); - - it('carries the stamp forward through the transient window, drops it once a real request re-caches', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-10-06T22:30:00Z')); - const s = claudeSession(); - // Baseline (known rebuild count) then the compaction, so the rise is against a known prior. - s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 0, recacheTokensIfCold: 70000 }); - vi.setSystemTime(new Date('2026-10-06T22:31:00Z')); - s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 }); - const stampedAt = s.promptCache?.compactedAt; - expect(stampedAt).toBe(Date.parse('2026-10-06T22:31:00Z')); - // A later idle report, still no request since the compaction: same rebuild count, recache - // still null. The stamp is kept (not re-stamped), so the clock in the readout does not move. + // An idle re-report inside the same window: the stamp is carried, not re-stamped. vi.setSystemTime(new Date('2026-10-06T22:45:00Z')); - s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1 }); - expect(s.promptCache?.compactedAt).toBe(stampedAt); + s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0 }); + expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z')); - // The next real request records the rewritten size (recache non-null): the window is over. - s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 1, recacheTokensIfCold: 42000 }); + // 3. The next request closes the window: expected_rebuilds 1, recache 40k. + s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 1, recacheTokensIfCold: 40000 }); expect(s.promptCache?.compactedAt).toBeUndefined(); }); - it('never stamps a plain miss (recache present) or an unchanged rebuild count', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-10-06T22:31:00Z')); + it('never stamps a first report that is already inside the window', () => { + // The first report Codeman sees (a restart mid-window) has no prior value to leave. const s = claudeSession(); - // expected_rebuilds rose but recache is present: the transient window was already missed. - s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 2, recacheTokensIfCold: 60000 }); + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 }); expect(s.promptCache?.compactedAt).toBeUndefined(); - // recache null but the rebuild count did not rise: not a fresh compaction. - s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 2 }); + // Nor does a later report in the same window: the stamp needs a known value before it. + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 }); expect(s.promptCache?.compactedAt).toBeUndefined(); }); - it('never invents a stamp without a KNOWN prior rebuild count (a rise needs two known reports)', () => { - // The very first report Codeman sees already carries a high count with recache null (a - // restart mid-session). Treating an absent prior as 0 would fabricate "compacted now". - const s1 = claudeSession(); - s1.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 }); - expect(s1.promptCache?.compactedAt).toBeUndefined(); - - // `4 -> omitted -> 4`: a report that drops the counter clears the known prior, so the next - // report carrying 4 again is not a provable rise. - const s2 = claudeSession(); - s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4, recacheTokensIfCold: 10 }); - s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000 }); // counter omitted - s2.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 }); - expect(s2.promptCache?.compactedAt).toBeUndefined(); + it('never stamps a cold report or a report that still carries the stake', () => { + const s = claudeSession(); + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, recacheTokensIfCold: 60000 }); + // Cold with no stake: an expired cache, not a compaction. + s.setPromptCache({ warm: false, expectedRebuilds: 0 }); + expect(s.promptCache?.compactedAt).toBeUndefined(); + // A rebuild count that rose with the stake present is the request after a window, not one. + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 2, recacheTokensIfCold: 60000 }); + expect(s.promptCache?.compactedAt).toBeUndefined(); }); it('reports changed only when the stored object differs', () => { @@ -120,9 +94,9 @@ describe('prompt-cache report survives a restart', () => { }); it('restores a post-compaction value WITHOUT re-stamping it at restart time', () => { - // A direct restore, not a setPromptCache call: an already-compacted report (recache absent, - // expectedRebuilds > 0, an OLD compactedAt) must keep its original timestamp through - // construction, never be re-stamped with "now" as a fresh compaction. + // A direct restore, not a setPromptCache call: a report from inside the window (recache + // absent, an OLD compactedAt) must keep its original timestamp through construction, + // never be re-stamped with "now" as a fresh compaction. vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-06T23:59:00Z')); const oldStamp = Date.parse('2026-10-06T22:31:00Z'); diff --git a/test/statusline-cli-flag.test.ts b/test/statusline-cli-flag.test.ts index ce4927a08..929fa0f52 100644 --- a/test/statusline-cli-flag.test.ts +++ b/test/statusline-cli-flag.test.ts @@ -100,7 +100,7 @@ describe('buildSpawnCommand statusLineCommand (claude mode)', () => { }); // Why a launch does or does not carry the exporter. Recorded on the mux record and -// published as `statusLineTelemetry`, so the Status row and keep-warm can say why no +// published as `statusLineExporter`, so the Status row can say why no // cache report will come instead of "not reported yet" forever. describe('TmuxManager statusLine classification', () => { type Resolve = (