diff --git a/CLAUDE.md b/CLAUDE.md index 15d32bfc2..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). → [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 9a53134e5..ce307e823 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -625,6 +625,41 @@ 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 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 +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. + +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 ce51923b8..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 { + 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..61b3d450b 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -724,6 +724,51 @@ 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 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; +} + +/** + * 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 StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote'; + export interface SessionState { /** Unique session identifier */ id: string; @@ -775,6 +820,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 `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/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..15c4c7cec 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -709,6 +709,45 @@ function computeConnectionLossUi(input) { }; } +// 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 `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': + '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-cached if it goes cold.`); + } else { + parts.push(tokens ? `Cold. The next turn re-caches about ${tokens} tokens.` : 'Cold.'); + } + // 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 @@ -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..82e001cfb 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?.statusLineExporter); + }, + + _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..a568ac74e 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,21 @@ 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 }); + // 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 // 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..6f3e90263 --- /dev/null +++ b/test/prompt-cache-status.test.ts @@ -0,0 +1,89 @@ +/** + * The Session Options "Prompt cache" readout (`CodemanPromptCache.format`, constants.js): + * 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'; +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-cached 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-caches about 90k tokens.' + ); + expect(format({ warm: true, expiresAt: NOW - 1, recacheTokensIfCold: 500 }, NOW)).toBe( + 'Cold. The next turn re-caches about 500 tokens.' + ); + 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-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-caches about 90k tokens. 60% cache hit rate. Last miss: ttl_expired_1h.'); + }); + + 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-cached" clause. + expect(format({ warm: true, ttl: '1h', expiresAt, compactedAt, hitRatio: 0.88 }, NOW)).toBe( + `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 f85b8c693..0741b17f1 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,46 @@ 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; + // 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: { + 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: 0 } }, + }); + 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'); + }); + + 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 new file mode 100644 index 000000000..9f9fa9425 --- /dev/null +++ b/test/session-prompt-cache.test.ts @@ -0,0 +1,130 @@ +/** + * Session.setPromptCache's compaction detection and the restore of the last report. + * + * 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'; +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 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; + + // 1. Warm, 500k at stake. + s.setPromptCache({ warm: true, ttl: '1h', expiresAt, expectedRebuilds: 0, recacheTokensIfCold: 500000 }); + expect(s.promptCache?.compactedAt).toBeUndefined(); + + // 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, expectedRebuilds: 0 }); + expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z')); + + // 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, ttl: '1h', expiresAt, expectedRebuilds: 0 }); + expect(s.promptCache?.compactedAt).toBe(Date.parse('2026-10-06T22:31:00Z')); + + // 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 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(); + s.setPromptCache({ warm: true, expiresAt: Date.now() + 3600_000, expectedRebuilds: 4 }); + expect(s.promptCache?.compactedAt).toBeUndefined(); + // 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 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', () => { + 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: 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'); + 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..929fa0f52 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 `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 = ( + 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', () => {