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

This commit is contained in:
Codeman maintainer committed 2026-10-13 18:07:15 +02:00
commit 62227145dc
24 files changed
+931 -30

No files matched your search

+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,