mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
1 parent
aa3730aace
commit
551a7f2792
24 files changed
+946
-30
No files matched your search
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in new issue
Block a user