Skip to content

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

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
JDProfresh:feat/prompt-cache-readout
Oct 13, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
JDProfresh:feat/prompt-cache-readout

Conversation

@JDProfresh

Copy link
Copy Markdown

This is step 1 of #554: keep the prompt_cache object Claude Code puts on the statusline and show each session's cache state. It is display-only, no behaviour change, and it stands on its own. The opt-in keep-warm from the discussion is a separate later PR.

What it does

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. Now it keeps it and shows it:

  • Session Options, on the Respawn tab, gets a Prompt cache row: "Warm until 01:23 PM (1h cache). About 45k tokens would be re-read if it goes cold. 91% cache hit rate." or "Cold. The next turn re-reads about 90k tokens at the cache-write rate." with the last miss's cause next to it.
  • Sessions without a statusline of their own also get a short cache:until HH:MM or cache:cold group in the terminal footer (a user's own statusline wins the footer, which is why the row exists).
  • If Claude Code compacts the conversation on its own while it sits idle, the row adds "Compacted at HH:MM": the cache stays warm but holds a smaller prefix, so there is nothing to re-read. It never raises an alert.

Design, against your asks in #554

  • Keep the whole object, nullish. StatusTelemetrySchema now lists every documented scalar field (warm, caching_observed, ttl, expires_at, requests, misses, expected_rebuilds, hit_ratio, cache_write_tokens, miss_recache_tokens, last_miss_at, recache_tokens_if_cold, last_miss_cause) as .nullish(). Listing every documented field is how "keep the whole object" stays bounded: it survives validation without a passthrough that would let an arbitrary payload grow the stored object. expires_at, hit_ratio, last_miss_at and recache_tokens_if_cold are all documented as possibly null, and the object gains fields across CLI versions, so a strict shape would 400 the whole post and blank the footer. ttl carries no length cap for the same reason: a future TTL longer than 5m/1h must not reject the post. parsePromptCache then normalizes only the subset the UI needs, so a hostile localhost payload cannot bloat state; miss_causes (an open-keyed record) and last_miss_cause's extra numeric keys are tolerated but not stored.
  • Show hit_ratio and the miss causes. Both are in the readout: the hit rate as a percentage, and last_miss_cause.causes verbatim ("ttl_expired_1h", "tools_changed").
  • Detect idle compaction, no alert. recache_tokens_if_cold goes null right after a compaction while expected_rebuilds rises (both documented); Session.setPromptCache stamps compactedAt when it sees that transition, carries it through the transient window, and drops it once the next request re-caches. A rise counts only against a known prior expected_rebuilds, so the first report of a mid-session restart (which can already carry a high count) does not invent a compaction. The statusline never reports when the compaction happened, so the stamp is the time Codeman first saw it. It only feeds the readout line; nothing alerts. A post-/clear conversation switch drops the whole report, so a fresh conversation never shows the old prefix's warm-until.
  • Persist per session. promptCache rides toState and is restored through the Session constructor on a Codeman restart, the same way displayModel is (set directly, so a restored compaction is not re-stamped with the restart time), so the row is not blank until the pane's next render. The readout is time-aware, so a restored value whose expires_at has passed reads as cold rather than as a stale warm; parsePromptCache also validates the converted-to-ms expiry against the Date range so a huge expires_at cannot render NaN:NaN. The statusline re-runs when a warm cache hits expires_at, so the cold flip arrives without any timer on our side.

I checked the field names and null-ability against the Claude Code statusline docs (code.claude.com/docs/en/statusline) rather than the summary in the thread. One note: the docs give ttl_expired_5m as the example cause name; the parser keeps whatever strings arrive, so ttl_expired_1h works the same. Everything else matched.

One known limitation, stated rather than engineered around: the route applies reports in arrival order, and when the exporter wraps a user's own statusline it backgrounds the POST, so two sub-second loopback reports can land out of order and a stale one briefly overwrites newer state until the next render corrects it. For a display-only readout that is acceptable.

A spawn-time detail from the same change: a launch without the exporter (plan-usage collection off, a workspace that sets its own statusLine, or a remote/Docker session) never reports, so the row says the cause instead of "not reported yet" forever.

Tests

  • test/session-prompt-cache.test.ts: the compaction stamp (set, carried forward, dropped, never on a plain miss, and never invented without a known prior count), the restart restore (including that an already-compacted report keeps its old stamp rather than being re-stamped), and the conversation-switch clear.
  • test/usage-telemetry.test.ts: parsePromptCache keeps hit_ratio (clamped 0-1) and expected_rebuilds (rounded, floored at 0), drops an expiry that overflows or leaves the Date range after the seconds-to-ms multiply, and the footer group.
  • test/prompt-cache-status.test.ts: the readout wording, including the hit rate, the last miss cause, and the compacted-at note.
  • test/routes/status-telemetry-routes.test.ts: the route stores and broadcasts the stored object (so the compaction stamp reaches the live readout), and does not 400 on the full object with an unknown extra field.
  • I removed each new guard and confirmed the matching test went red before putting it back.

The full suite is green (545 files, 10695 tests; the browser, mobile, and perf suites are the usual environment-gated exclusions).

Docs

api-reference.md gets a Prompt cache (promptCache) section next to the existing Session model one, documenting the field and the session:promptCache event. The feature wiki (Keeping-Agents-Running), the design note (usage-limits-display-plan), CLAUDE.md and architecture-invariants are updated in the same change.

Verified

Observed, on a throwaway instance running Claude Code 2.1.294:

  • Posted a realistic warm statusline payload (the full prompt_cache object plus an unknown extra field) to /api/status-telemetry: 200, the footer printed "... cache:until 13:23", and the session stored exactly the normalized subset (the unknown field stripped).
  • Posted the compaction transition (recache_tokens_if_cold null, expected_rebuilds risen, still warm): the session stamped compactedAt and dropped the re-read stake.
  • Opened Session Options and read the live row: "Warm until 01:23 PM (1h cache). Compacted at 12:23 PM. 93% cache hit rate."
  • Restarted Codeman: the session recovered with its prompt_cache intact, compactedAt included.

Also observed, on my dev install running this together with the other open PRs of this batch on 1.41.0 (aa3730aa), from a real statusline rather than posted payloads: after one turn of a Claude Code 2.1.294 session the session reported warm: true, ttl: 1h, about 35k tokens at stake and a 100% hit ratio, and Session Options read "Warm until 05:05 PM (1h cache). About 35k tokens would be re-read if it goes cold. 100% cache hit rate." With plan-usage collection switched off, the session instead reports statusLineTelemetry: collection-off, so the row says why it is empty.

Inferred, not waited out live: the warm-to-cold flip at expires_at (covered by the unit tests, which render cold once now passes expires_at; and the statusline re-runs on its own at expiry per the docs). An earlier cut of this (the readout and the footer, without hit_ratio, the compaction note and the restart restore) has been running on my own install since 2026-10-07.

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.
@Ark0N

Ark0N commented Oct 11, 2026

Copy link
Copy Markdown
Owner

Thanks @JDProfresh, this is a careful first step for #554. It keeps Claude Code's prompt_cache object from the statusline, stores it per session, and shows it in Session Options and in the terminal footer. It also restores it after a restart and says why when a launch never reports. The schema and parser bounds, the tests and the docs are all in great shape, and the full suite is green here too.

One thing needs to change before merge, and it's on me, not you: the compaction signal I asked for in #554 doesn't exist in that shape.

1. The "Compacted at" stamp never fires against a real Claude Code (src/session.ts:1453)

In #554 I wrote that recache_tokens_if_cold goes null right after a compaction "and expected_rebuilds goes up", and you built exactly that. But the prompt-cache tracker in the shipped 2.1.294 to 2.1.296 binaries does these two things at different times:

  • At a compaction (or a tool-result clearing), Claude Code calls expectDrop(), which sets dropExpectedAt. While that is set, recache_tokens_if_cold reads null, and expected_rebuilds does not change.
  • On the next request, record() clears dropExpectedAt. In that same call it classifies the request as "expected" and increments expectedRebuilds. From then on recache_tokens_if_cold is non-null again.

So no single report carries both "null" and "rose", and setPromptCache never stamps for /compact or for Claude Code's idle compaction. I replayed the real order through your Session:

  1. Warm with 500k at stake.
  2. /compact finishes: recache null, rebuilds 0.
  3. The next request: rebuilds 1, recache 40k.

compactedAt stayed unset at every step. The tests in test/session-prompt-cache.test.ts and test/routes/status-telemetry-routes.test.ts:213 pass because their fixtures follow the order from my ask.

The signal that does hold is entering the null window: a warm report with recacheTokensIfCold null, where the previous report had a known non-null value.

const inWindow = status.warm && status.recacheTokensIfCold == null;
if (inWindow && prev?.recacheTokensIfCold != null) compactedAt = Date.now();
else if (inWindow && prev?.compactedAt != null) compactedAt = prev.compactedAt;

That stamps when /compact finishes, carries the stamp through idle re-runs, drops it at the next request, and still never invents a stamp from a first report that is already inside the window. It also fires for a tool-result clearing (the docs give both causes for the null), so please word the readout to cover both, for example "Context compacted at HH:MM".

Then please:

  • update the fixtures to the real order;
  • update the five places that document the rise rule: CLAUDE.md:227, docs/architecture-invariants.md:160, docs/api-reference.md:648, docs/wiki/Keeping-Agents-Running.md:108 and docs/usage-limits-display-plan.md:49.

If you'd rather keep this PR small, dropping compactedAt here and bringing it back with step 2 works for me too.

Smaller things, all quick:

  1. src/web/routes/status-telemetry-routes.ts:72: call ctx.persistSessionState(session) when setPromptCache reports a change, the same way a model change persists. Today state.json only picks up the report on an unrelated persist or a graceful shutdown, so after a crash the restore brings back an older report.
  2. src/web/public/constants.js:712: the new block landed between the "SSE staleness policy: is this stream a zombie?" heading and the rest of that comment. Please move it above that line.
  3. src/web/public/constants.js:741 and :743: a cold turn re-writes the prefix into the cache rather than re-reading it ("re-read" is the cheap warm path). Claude Code's own /usage line says "re-caches", so "would be re-cached if it goes cold" and "The next turn re-caches about 90k tokens" read clearer.
  4. src/types/session.ts:830: statusLineTelemetry is also the name of the boolean registry capability capabilities.statusLineTelemetry, and docs/usage-limits-display-plan.md:122 says no such session field exists anymore. Please give it a distinct name such as statusLineExporter. It's now part of GET /api/v1/sessions, so please also add a line with its four values to the Prompt cache section of docs/api-reference.md.
  5. src/tmux-manager.ts:2049 and test/statusline-cli-flag.test.ts:103 mention "keep-warm", which is step 2 and not in the codebase yet. Please leave that out until it lands.

Once the compaction signal is fixed (or removed), I'll merge. Everything else here is ready.

Claude Code's tracker nulls recache_tokens_if_cold at a compaction or a tool-result clearing and only counts the expected rebuild at the next request, which also closes the window, so no report carried both and the old rule never fired. Stamp on the first warm null report after one that carried a value, carry it through idle re-reports and drop it at the next request; the readout now says "Context compacted at HH:MM" since a clearing opens the same window.

Also persist a changed report so a crash restores the latest one, rename the session field to statusLineExporter (statusLineTelemetry is the registry capability) and document its values, word the stake as re-cached, move the readout block above the SSE staleness heading, and drop keep-warm mentions until it lands.
@JDProfresh

Copy link
Copy Markdown
Author

Thanks for replaying it against the real order. I checked the 2.1.294 binary here and see the same thing: record() clears dropExpectedAt before it classifies, so the request that counts as "expected" is also the one that closes the null window, and estimateRecacheTokens returns null only while dropExpectedAt is set. All six points are in ecb2e3d:

  1. Compaction stamp. setPromptCache now uses your rule: it stamps on the first warm report whose recacheTokensIfCold is null after one that carried a value, carries the stamp through idle re-reports inside the window, and drops it at the next request. A first report already inside the window gets none. expectedRebuilds is no longer read for it. The readout says "Context compacted at HH:MM". The fixtures in test/session-prompt-cache.test.ts and the route test follow the real order (your three steps are the first test, plus an idle re-report in the window), and the five docs places are updated (CLAUDE.md, architecture-invariants.md, api-reference.md, Keeping-Agents-Running.md, usage-limits-display-plan.md). Removing each condition (the known prior, the carry, warm) turns its test red, and the old rise rule fails the replay test and the route test.
  2. Persist. The route calls ctx.persistSessionState(session) when setPromptCache reports a change, with a route test that it persists a changed report and not an unchanged one.
  3. Comment placement. The readout block now sits above "SSE staleness policy: is this stream a zombie?", and that heading is back on its own comment.
  4. Wording. "would be re-cached if it goes cold" and "The next turn re-caches about 90k tokens", in the readout, its tests and the wiki page.
  5. Field name. The session field and its type are now statusLineExporter / StatusLineExporter, so statusLineTelemetry is only the registry capability again. The Prompt cache section of api-reference.md lists its four values.
  6. keep-warm. Both mentions are gone (tmux-manager.ts and test/statusline-cli-flag.test.ts).

Full suite green locally (545 files, 10,695 tests).

@Ark0N
Ark0N merged commit 6222714 into Ark0N:master Oct 13, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 13, 2026
…#607 merge-time fix)

Two comments still described the abandoned expected_rebuilds rise rule;
setPromptCache no longer reads that field for the compaction stamp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 13, 2026
…to groups

Conflict resolved in docs/api-reference.md: #607's prompt cache section and
#528's tabGroupId section are both kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 13, 2026

Copy link
Copy Markdown
Owner

Thanks @JDProfresh, shipped in 1.41.1. All six points from the review were in. At merge I only fixed two comments that still described the old expected_rebuilds rule. Two small things for a later PR if you like: promptCache is stored for any session mode while the model report from the same payload is capability-gated, and more than 20 miss causes 400s the whole statusline post instead of truncating the list.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants