Merge pull request #607: feat(telemetry): read out Claude Code's prompt cache state per session

This commit is contained in:
Codeman maintainer
2026-10-13 18:07:15 +02:00
24 changed files with 931 additions and 30 deletions
+1 -1
View File
@@ -226,7 +226,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`.
+35
View File
@@ -636,6 +636,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,
+1 -1
View File
@@ -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 </dev/null &`, closing stdin too — only in the wrap branch, where the user's own command owns the footer; `curl --max-time 5` bounds a hung, not just refused, Codeman). Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox and chip visibility (DISPLAY only) — the SAME `showPlanUsageLimits` setting also doubles as the server-side telemetry COLLECTION switch, read FRESH from `settings.json` by `readPlanUsageTelemetryEnabled()` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), never cached, with no per-session field and no per-request wire field — applies uniformly across every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) and survives a Codeman restart by construction (nothing per-session to lose). ⚠️ An ABSENT key reads as ON, the same way an absent `workspaceHooksEnabled` does: the desktop chip already shows as on for an install that never touched the setting, and the exporter posts only to this Codeman over loopback. Resolving the default in the reader is what keeps `GET /api/settings` a plain read. It briefly reconciled the key on first read (persisting `true` when absent), but `readJsonConfig()` answers `{}` for ANY read failure, not only ENOENT, and every page load hits that route, so one unlucky read replaced the whole settings file with a one-key file; pinned by `test/routes/system-routes-settings-get-plan-usage-default.test.ts`. ⚠️ The client sends `showPlanUsageLimits` in a settings save ONLY when that save FLIPS the chip relative to what the device had (`planUsageCollectionFlip()` in settings-ui.js): the chip defaults OFF on handhelds, so sending it on every save let a phone saving its font size persist `false` and switch collection off for every desktop, whose chip then went stale with no error anywhere. An explicit toggle on any device still writes the switch. Injection covers LOCAL tmux-spawned claude sessions only: the non-tmux direct-PTY fallback (`Session.startInteractive` when tmux is unavailable) and the remote/docker pane builders do not carry the flag. Registry-gated on `getCli(mode)?.capabilities.statusLineTelemetry` rather than a hardcoded mode string. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`, `test/hooks-config.test.ts` (statusline exporter script + `readPlanUsageTelemetryEnabled`), `test/statusline-cli-flag.test.ts`.
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`.
Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits.
### Cron jobs
+2
View File
@@ -46,6 +46,8 @@ Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a con
The same stdin object also carries `model.display_name`, `context_window.{used_percentage, total_input_tokens, total_output_tokens, …}`, `cost.total_cost_usd`, `effort.level`, etc. The shipped feature uses **model + token totals + context %** to build the in-terminal footer (so the statusline stays useful even though we own it). The endpoint also broadcasts `contextUsedPercentage`/`costUsd`/`modelDisplayName` alongside the limits for future chip tooltips.
Since Claude Code v2.1.251 the payload also carries a `prompt_cache` object for the main conversation: `warm`, `ttl` (`5m` or `1h`), `expires_at` (epoch seconds), `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at` and `last_miss_cause`. `StatusTelemetrySchema` 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); `parsePromptCache()` normalizes only the subset Codeman stores. The footer appends `cache:until HH:MM` while warm or `cache:cold` otherwise, and Claude re-runs the statusline when `expires_at` passes, so the cold flip arrives without a turn. The Session Options readout adds the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (the first warm report whose `recache_tokens_if_cold` is null after one that carried it; `expected_rebuilds` rises only at the next request, so it is not read; never raised as an alert). The last report is restored on a Codeman restart and the readout is time-aware, so a value whose expiry has passed shows as cold rather than stale-warm.
### Alternatives considered & rejected
| Source | Why not |
+8
View File
@@ -99,6 +99,14 @@ limit-paused. Without that, the next cycle would fire `/clear` and wipe the conv
are waiting to resume. This is the single most useful setting for overnight runs on a
subscription plan.
## The prompt cache readout
**Claude only.** On the Respawn tab, above the loop controls.
Claude Code bills a turn against the cached conversation while that cache is warm, and lets the cache expire after a fixed idle time: one hour on a subscription within plan usage, five minutes on an API key, a cloud provider or extra-usage credits. The first turn after a longer break re-processes the whole context at the cache-write rate, which is why coming back to a big session after lunch costs more than the turns around it.
Claude Code 2.1.251 and newer reports the cache state on the statusline after every response, and Codeman's statusline exporter keeps it per session. The row shows **Warm until HH:MM** with the lifetime and the tokens that would be re-cached if it went cold, or **Cold** with the size of the re-cache the next turn pays. When Claude reports them it also shows the session's cache hit rate and the cause of the last cache miss (for example `ttl_expired_1h` or `tools_changed`), so a surprise miss has a reason next to it. If the conversation is compacted while it stays warm (Claude Code now does this on its own on large idle sessions, a manual `/compact` does it too, and so does a clearing of old tool results), the row notes **Context compacted at HH:MM** until the next turn: Claude Code reports no re-cache size until then. None of this raises an alert. The row refreshes live and flips to cold on its own when the lifetime passes, and the last state is kept across a Codeman restart so it is not blank until the session's next turn. Sessions without a statusline of their own also get a short `cache:until HH:MM` or `cache:cold` group in the terminal footer. The readout rides the plan usage chip's telemetry collection, which is on by default on desktop.
## The plan usage chip
**Claude only.** A header chip showing live subscription usage, on by default on desktop and
+3
View File
@@ -25,6 +25,7 @@ import type {
SessionRemote,
SessionDocker,
PaneExit,
StatusLineExporter,
} from './types.js';
/**
@@ -57,6 +58,8 @@ export interface MuxSession {
respawnConfig?: PersistedRespawnConfig;
/** Whether Ralph / Todo tracking is enabled */
ralphEnabled?: boolean;
/** Whether the current launch carries the statusLine exporter; set on every create and respawn. */
statusLine?: StatusLineExporter;
/**
* This record was rebuilt from the tmux socket rather than from Codeman's own
* bookkeeping, so everything on it but the name and the pid is a guess. Its
+57
View File
@@ -63,6 +63,7 @@ import {
type SessionNameSource,
type SessionWriteOptions,
type PaneExit,
type PromptCacheStatus,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
@@ -585,6 +586,8 @@ export class Session extends EventEmitter {
* running. A session that comes back with no label has no chip on its screen.
*/
private _watching: string | null = null;
/** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */
private _promptCache: PromptCacheStatus | null = null;
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
private _workingLineRe: RegExp | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
@@ -873,6 +876,8 @@ export class Session extends EventEmitter {
paneExit?: PaneExit;
/** The previous run's `displayModel`; a CLI-reported one is restored (see `displayModel`). */
displayModel?: DisplayModel;
/** The previous run's last prompt-cache report, restored so the readout is not blank (see `promptCache`). */
promptCache?: PromptCacheStatus;
/** This session was rebuilt from the tmux socket, so its metadata is a guess. */
discoveredMuxSession?: boolean;
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
@@ -1041,6 +1046,11 @@ export class Session extends EventEmitter {
// browser panel arms and disarms — see `startPaneExitWatcher`.
this.setPaneExit(config.paneExit);
this._reportedModel = restoredReportedModel(config.displayModel) ?? null;
// Last prompt-cache report, restored so the Session Options readout is not blank until
// the pane's next statusline render. The readout is time-aware, so a value whose expiry
// has passed renders as cold rather than stale-warm. Set directly, not via
// setPromptCache, so recovery does not re-stamp a compaction it did not just observe.
this._promptCache = config.promptCache ?? null;
// Never self-parent: a session pointing at itself would draw a zero-length
// lineage arc under its own tab. Only reachable via the recovery path, where
// both the id and the saved parent come from disk.
@@ -1244,6 +1254,11 @@ export class Session extends EventEmitter {
}
if (newId === this._claudeSessionId) return;
this._claudeSessionId = newId;
// The conversation itself changed (a post-/clear switch), so the old prefix's cache
// report and its compaction baseline belong to a conversation that is gone. Drop them
// rather than show the previous conversation's "warm until" against the new one; the
// next statusline render repopulates. A same-id reattach returned above, untouched.
this._promptCache = null;
}
/**
@@ -1414,6 +1429,46 @@ export class Session extends EventEmitter {
return this._watching;
}
/**
* 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`. Null until the first
* report. It rides `toState()` for the Session Options readout and is restored on a
* Codeman restart (constructor `promptCache`) so the row is not blank until the pane's
* next render; the readout is time-aware, so a value whose `expiresAt` has passed renders
* as cold rather than as a stale warm.
*/
get promptCache(): PromptCacheStatus | null {
return this._promptCache;
}
/**
* Replace the prompt-cache report; true when it differs from the previous one. Stamps
* `compactedAt` when this report ENTERS the window in which Claude Code reads
* `recacheTokensIfCold` as null: a compaction (`/compact` or its own idle compaction) or
* a clearing of old tool results opened it, and the previous report still had a known
* value. Claude Code closes the window on the next request (that request is the one that
* counts as an expected rebuild), so `expectedRebuilds` never rises inside it and is not
* read here. The stamp is carried while the window holds and dropped once it closes; a
* first report already inside the window gets none. It is never an alert.
*/
setPromptCache(status: PromptCacheStatus): boolean {
const prev = this._promptCache;
const inWindow = status.warm && status.recacheTokensIfCold == null;
let compactedAt: number | undefined;
if (inWindow && prev?.recacheTokensIfCold != null) {
compactedAt = Date.now();
} 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
// 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 +1948,8 @@ export class Session extends EventEmitter {
autoCompactPrompt: this._autoOps.autoCompactPrompt,
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
promptCache: this._promptCache ?? undefined,
statusLineExporter: this._muxSession?.statusLine,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
+37 -13
View File
@@ -59,6 +59,7 @@ import {
type SessionDocker,
type DockerCommandMode,
type PaneExit,
type StatusLineExporter,
} from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
@@ -2164,6 +2165,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 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?: StatusLineExporter }> {
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
@@ -2281,14 +2303,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.
@@ -2452,6 +2471,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode,
attached: false,
name,
statusLine,
};
this.sessions.set(sessionId, session);
@@ -2576,11 +2596,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({
@@ -2643,6 +2663,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);
+55
View File
@@ -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 */
+92 -8
View File
@@ -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;
}
/**
* 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.
* `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.
*/
export function formatSessionStatusText(s: SessionStatus | null): string {
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% 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, 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(' ') : '';
}
+1
View File
@@ -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'],
+43
View File
@@ -724,6 +724,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
@@ -1462,6 +1501,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,
@@ -1647,6 +1689,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',
+16
View File
@@ -1286,6 +1286,22 @@
<!-- Hidden wholesale for non-claude sessions and for a session with
no live process (session-ui.js: openSessionOptions). -->
<div id="sessionRespawnSection">
<!-- Claude Code (v2.1.251+) reports the main conversation's prompt-cache
state on the statusline; the exporter keeps it per session and this
row shows it live (session-ui.js: updatePromptCacheStatus). -->
<div class="set-group">
<div class="set-group-head"><h4>Prompt cache</h4></div>
<p class="set-group-hint">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.</p>
<div class="set-group-body">
<div class="set-row">
<div class="set-row-text">
<span class="set-row-label">Status</span>
<span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span>
</div>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Loop control</h4></div>
<div class="set-group-body">
+22
View File
@@ -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;
+17 -1
View File
@@ -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);
+28
View File
@@ -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(),
});
+5
View File
@@ -3582,6 +3582,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,
+5 -2
View File
@@ -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,
+8 -1
View File
@@ -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[] = [];
+89
View File
@@ -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.`
);
});
});
+100 -1
View File
@@ -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<string, unknown>][])
.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();
});
});
+130
View File
@@ -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<string, unknown> = {}): Session {
return new Session({ workingDir: '/tmp', mode: 'claude', ...extra } as ConstructorParameters<typeof Session>[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();
});
});
+55 -2
View File
@@ -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({});
});
});
+121
View File
@@ -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', () => {