feat(telemetry): read out Claude Code's prompt cache state per session

Claude Code's statusline has carried a prompt_cache object since v2.1.251, and Codeman's statusline exporter already received it, but StatusTelemetrySchema stripped the whole object, so Codeman never knew whether a session's conversation was still cached. Keep it and show it.

StatusTelemetrySchema now lists every documented scalar field null-tolerant (several are documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the post and blank the footer), while parsePromptCache normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. Session.promptCache rides toState with a change-only session:promptCache event, and the route broadcasts the stored object.

The Session Options (Respawn tab) readout shows warm-until or cold with the re-read stake, the cache hit rate, the last miss's causes, and, for Claude Code's own idle compaction (recache_tokens_if_cold null while expected_rebuilds rose), a "compacted at HH:MM" note that is never raised as an alert. setPromptCache stamps that on a new object rather than mutating the parsed report, so the stamp reaches the broadcast. Sessions without a statusline of their own also get a cache:until HH:MM / cache:cold group in the terminal footer. The last report is restored on a Codeman restart (like displayModel) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose expiry has passed reads as cold, not stale-warm. A launch without the exporter records why (collection off, workspace statusline, remote) so the row says the cause instead of "not reported yet" forever.

This is the sensor a later opt-in keep-warm would read, and the TTL it reports is the source of truth rather than the plan tier.
This commit is contained in:
JD committed 2026-10-10 13:09:32 -04:00
1 parent aa3730aace
commit 551a7f2792
24 files changed
+946 -30

No files matched your search

+3
View File
@@ -25,6 +25,7 @@ import type {
SessionRemote,
SessionDocker,
PaneExit,
StatusLineTelemetry,
} 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?: StatusLineTelemetry;
/**
* 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
+64
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,53 @@ 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 shows Claude Code's own idle compaction of the current
* prefix: `recacheTokensIfCold` is null (the transient post-compaction window the docs
* describe) while `expectedRebuilds` rose above the last report. The stamp is carried
* forward while that window holds, so the readout can say "compacted at HH:MM"; it is
* never an alert. A tool-result clearing bumps `expectedRebuilds` the same way, which the
* readout wording tolerates.
*/
setPromptCache(status: PromptCacheStatus): boolean {
const prev = this._promptCache;
const inPostCompactWindow = status.recacheTokensIfCold == null && status.warm;
let compactedAt: number | undefined;
// A rise is only a rise against a KNOWN prior counter. Treating an absent prior as 0
// would invent a compaction out of the first report that already carries a high count
// (a restart mid-session, or `4 -> omitted -> 4`), so both counters must be present.
if (
inPostCompactWindow &&
prev?.expectedRebuilds != null &&
status.expectedRebuilds != null &&
status.expectedRebuilds > prev.expectedRebuilds
) {
compactedAt = Date.now();
} else if (prev?.compactedAt != null && inPostCompactWindow && status.expectedRebuilds === prev.expectedRebuilds) {
compactedAt = prev.compactedAt;
}
// A NEW object when a stamp applies, never a mutation of the caller's parsed report: the
// route broadcasts the STORED object, so the stamp lives here and never leaks back into the
// parser's output (which would make the route's "broadcast the stored object" choice vacuous).
const next = compactedAt != null ? { ...status, compactedAt } : status;
const changed = JSON.stringify(next) !== JSON.stringify(prev);
this._promptCache = next;
return changed;
}
/**
* Check if the session's process tree has active child processes beyond Claude itself.
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
@@ -1893,6 +1955,8 @@ export class Session extends EventEmitter {
autoCompactPrompt: this._autoOps.autoCompactPrompt,
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
promptCache: this._promptCache ?? undefined,
statusLineTelemetry: this._muxSession?.statusLine,
imageWatcherEnabled: this._imageWatcherEnabled,
pinned: this._pinned || undefined,
pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined,
+37 -13
View File
@@ -59,6 +59,7 @@ import {
type SessionDocker,
type DockerCommandMode,
type PaneExit,
type StatusLineTelemetry,
} from './types.js';
import { getCli } from './config/cli-registry/registry.js';
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
@@ -2039,6 +2040,27 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* The statusLine exporter for one claude launch, and why it is missing when it is.
* Registry-gated (capabilities.statusLineTelemetry, claude only today), local spawns
* only (remote/docker have their own separate command builders). Resolving still runs
* with collection off, because it also self-heals: it strips any legacy disk-written
* exporter from an older Codeman build the first time a session starts in that
* workspace again. `state` lands on the mux record so the Status row and keep-warm can
* say why no cache report will come, instead of "not reported yet" forever.
*/
private async _resolveStatusLine(
mode: SessionMode,
workingDir: string,
remoteOrDocker: boolean
): Promise<{ command?: string; state?: StatusLineTelemetry }> {
if (!getCli(mode)?.capabilities.statusLineTelemetry) return {};
if (remoteOrDocker) return { state: 'remote' };
const enabled = await readPlanUsageTelemetryEnabled();
const command = await resolveStatusLineCliCommand(workingDir, enabled);
return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' };
}
/**
* Export the user's own REAL statusLine command (found by
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
@@ -2156,14 +2178,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
// Registry-gated (capabilities.statusLineTelemetry — claude only today), local
// spawns only (remote/docker have their own separate command builders — out of
// scope here). Also self-heals: strips any legacy disk-written exporter from an
// older Codeman build the first time a session starts in that workspace again.
const statusLineCommand =
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
: undefined;
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
mode,
workingDir,
!!(remote || docker)
);
// The user's own REAL statusLine, if any (walked via Claude Code's own
// settings precedence) — exported below so the shared exporter script
// can wrap it. Only worth discovering when we're actually injecting.
@@ -2327,6 +2346,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode,
attached: false,
name,
statusLine,
};
this.sessions.set(sessionId, session);
@@ -2451,11 +2471,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
// See createSession()'s identical resolution for rationale.
const statusLineCommand =
getCli(mode)?.capabilities.statusLineTelemetry && !remote && !docker
? await resolveStatusLineCliCommand(workingDir, await readPlanUsageTelemetryEnabled())
: undefined;
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
mode,
workingDir,
!!(remote || docker)
);
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
const baseCmd = buildSpawnCommand({
@@ -2518,6 +2538,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.clearPaneExit(muxName);
const pid = this.getPanePid(muxName);
if (pid) session.pid = pid;
if (session.statusLine !== statusLine) {
session.statusLine = statusLine;
this.saveSessions();
}
return pid;
} catch (err) {
console.error('[TmuxManager] Failed to respawn pane:', err);
+53
View File
@@ -724,6 +724,49 @@ export interface PaneExit {
at: number;
}
/**
* Normalized prompt-cache state of a session's main conversation, from the statusline's
* `prompt_cache` object (`parsePromptCache` in usage-telemetry.ts). `warm` is the only
* field Claude always reports: once the prefix has gone cold it sends `warm:false` with a
* null `expires_at`.
*/
export interface PromptCacheStatus {
warm: boolean;
/** Cache lifetime of the current prefix; Claude Code only ever reports these two. */
ttl?: '5m' | '1h';
/** Epoch MILLISECONDS when the cached prefix goes cold (the statusline reports seconds). */
expiresAt?: number;
/** Tokens the next request re-writes if the cache has gone cold by then. */
recacheTokensIfCold?: number;
/** Requests this session that re-processed content the cache already held. */
misses?: number;
/** Cache rebuilds that followed a compaction or a clearing of old tool results. */
expectedRebuilds?: number;
/** Cache read tokens as a fraction of all input tokens this session, 0-1. */
hitRatio?: number;
/** Claude Code's diagnosis of the last miss, e.g. `ttl_expired_1h` or `tools_changed`. */
lastMissCauses?: string[];
/**
* Epoch MS when Codeman first saw Claude Code's own idle compaction for the current prefix
* (`recacheTokensIfCold` null while `expectedRebuilds` rose). Derived in `Session.setPromptCache`,
* not reported by the statusline, so it is never set by `parsePromptCache`. Lets the readout say
* "compacted at HH:MM"; it is never an alert (CC's idle compaction is benign).
*/
compactedAt?: number;
}
/**
* Whether this launch carries Codeman's statusLine exporter, the only source of
* `promptCache`, and why not when it does not. Decided once per claude spawn
* (`TmuxManager`), so a setting flipped later does not change it until the next launch.
* - `injected`: the exporter is on the launch.
* - `collection-off`: plan-usage collection (`showPlanUsageLimits`) was off at launch.
* - `workspace-statusline`: the workspace's own `.claude/settings.local.json` sets a
* statusLine (or could not be read), and Codeman never replaces one there.
* - `remote`: remote and Docker launches never carry the exporter.
*/
export type StatusLineTelemetry = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote';
export interface SessionState {
/** Unique session identifier */
id: string;
@@ -775,6 +818,16 @@ export interface SessionState {
autoResumeEnabled?: boolean;
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
autoResumeAt?: number;
/**
* Prompt-cache state of the main conversation as Claude Code last reported it on the
* statusline (v2.1.251+), fed by `POST /api/status-telemetry`. Absent until the first
* report. Restored on a restart (the Session constructor's `promptCache`) so the readout
* is not blank until the pane's next render; the readout is time-aware, so a value whose
* `expiresAt` has passed reads as cold rather than as a stale warm.
*/
promptCache?: PromptCacheStatus;
/** Why a cache report will or will not arrive (see `StatusLineTelemetry`); absent when unknown. */
statusLineTelemetry?: StatusLineTelemetry;
/** Pinned to the top of the session manager list (COD-139) */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */
+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
@@ -711,6 +711,45 @@ function computeConnectionLossUi(input) {
// SSE staleness policy: is this stream a zombie?
//
// Session Options → Respawn → "Prompt cache": one sentence of the main
// conversation's cache state as Claude Code reports it on the statusline
// (`session.promptCache`, v2.1.251+). A clock time rather than a countdown
// because the row refreshes on SSE, not on a timer, and a countdown would sit
// stale between turns. Pure: no DOM, `now` is passed in. `statusLine` is the
// session's `statusLineTelemetry`: a launch without the exporter never reports, so
// it says why instead of "not reported yet" forever.
const PROMPT_CACHE_NOT_COLLECTED = {
'collection-off':
'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.',
'workspace-statusline':
"Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.",
remote: 'Not collected for remote and Docker sessions.',
};
function formatPromptCacheStatus(cache, now, statusLine) {
if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return PROMPT_CACHE_NOT_COLLECTED[statusLine];
if (!cache || typeof cache.warm !== 'boolean') {
return 'Not reported yet. Claude Code 2.1.251 or newer reports it after the first response.';
}
const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const kTokens = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`);
const stake = cache.recacheTokensIfCold;
const tokens = Number.isFinite(stake) && stake > 0 ? kTokens(stake) : null;
const parts = [];
if (cache.warm && Number.isFinite(cache.expiresAt) && cache.expiresAt > now) {
const ttl = cache.ttl ? ` (${cache.ttl} cache)` : '';
parts.push(`Warm until ${clock(cache.expiresAt)}${ttl}.`);
if (tokens) parts.push(`About ${tokens} tokens would be re-read if it goes cold.`);
} else {
parts.push(tokens ? `Cold. The next turn re-reads about ${tokens} tokens at the cache-write rate.` : 'Cold.');
}
// Claude Code's own idle compaction kept the prefix warm but smaller, so there is no
// re-read stake to show; naming when it happened explains the shrink. Never an alert.
if (Number.isFinite(cache.compactedAt)) parts.push(`Compacted at ${clock(cache.compactedAt)}.`);
if (Number.isFinite(cache.hitRatio)) parts.push(`${Math.round(cache.hitRatio * 100)}% cache hit rate.`);
if (cache.lastMissCauses && cache.lastMissCauses.length) parts.push(`Last miss: ${cache.lastMissCauses.join(', ')}.`);
return parts.join(' ');
}
// An EventSource that stops delivering does not always error. A proxy that
// idle-closed the connection, a laptop resumed from sleep, a tailnet
// reconnect: `onerror` never fires, the header dot stays green, and every
@@ -1447,6 +1486,9 @@ if (typeof window !== 'undefined') {
compute: computeSseStale,
TIMEOUT_MS: SSE_STALE_TIMEOUT_MS,
};
window.CodemanPromptCache = {
format: formatPromptCacheStatus,
};
window.CodemanSessionOrder = {
RANK: SESSION_ACTIVITY_RANK,
anchor: sessionActivityAnchor,
@@ -1632,6 +1674,7 @@ const SSE_EVENTS = {
SESSION_INTERACTIVE: 'session:interactive',
SESSION_RUNNING: 'session:running',
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
SESSION_PROMPT_CACHE: 'session:promptCache',
// Scheduled runs
SCHEDULED_CREATED: 'scheduled:created',
+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?.statusLineTelemetry);
},
_onSessionPromptCache(data) {
const { sessionId, ...promptCache } = data;
const session = this.sessions.get(sessionId);
if (!session) return;
session.promptCache = promptCache;
this.updatePromptCacheStatus(sessionId);
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
+15 -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,19 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
session.noteReportedModel('statusline', payload?.model?.display_name);
}
// Prompt-cache state of the main conversation (Claude Code v2.1.251+): kept on the
// session for the Session Options readout and `toState()`, broadcast only when the
// report changes (the statusline fires on every assistant message). The broadcast
// carries the STORED object, so a compaction stamp set in setPromptCache rides along.
// Reports are applied in arrival order. When the exporter wraps a user's own statusline
// it backgrounds this POST (hooks-config.ts), so two sub-second loopback reports can
// arrive out of order; a stale one then briefly overwrites newer state until the next
// render corrects it. Accepted for a display-only readout rather than ordered here.
const promptCache = parsePromptCache(payload);
if (promptCache && session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
}
// Plan-usage limits (account-wide) → broadcast to the header chip, when
// present and changed (the statusline fires on every assistant message).
const telemetry = parseStatusTelemetry(payload);
+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
@@ -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,
+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,