Skip to content

fix(cache-timer): use Claude Code's reported cache expiry when present - #576

Merged
sirmalloc merged 4 commits into
sirmalloc:mainfrom
durandom:cache-timer-real-expiry
Oct 29, 2026
Merged

sirmalloc merged 4 commits into
sirmalloc:mainfrom
durandom:cache-timer-real-expiry

Conversation

@durandom

@durandom durandom commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Problem

CacheTimer infers its countdown from the newest assistant row's timestamp plus a configured TTL. Its own comment explains why:

// The expiry itself is never exposed (the transcript only records token counts),
// so this is a best-effort countdown from the last turn

That is no longer true. Claude Code now includes a prompt_cache object in the status JSON:

"prompt_cache": {
  "warm": true,
  "caching_observed": true,
  "ttl": "1h",
  "expires_at": 1788615572,
  "hit_ratio": 0.9721430554245729,
  "recache_tokens_if_cold": 217318
}

Why the inference is wrong in the case that matters most

The widget exists to tell you the cache went cold. The inference is least reliable in exactly that situation:

  • A resumed session reports HOT. getTranscriptState returns { isWorking: true } whenever a user-role row sits at the tail, and a user row is what a resumed session starts with. So you come back to a session that has been idle for three hours and the widget says 🔥 HOT — the opposite of the truth, at the one moment you would act on it.
  • A turn that read an entry written much earlier restarts the countdown from the wrong anchor, since the anchor is the turn's timestamp rather than the entry's.

Change

getReportedRemainingSeconds() reads prompt_cache.expires_at and returns null when the field is absent, so older Claude Code versions fall through to the existing transcript logic untouched. caching_observed === false is treated as "no signal", not as cold. Everything downstream — the glyph slots, the raw/labeled formatting, the TTL keybind — is unchanged.

Verified against real payloads, same settings, --config held constant:

payload before after
warm, expires in ~35 min Cache: 🔥 HOT Cache: 🟢 34:46
warm: false Cache: 🔥 HOT Cache: ❄️ COLD
no prompt_cache (older Claude Code) Cache: 🔥 HOT Cache: 🔥 HOT

The middle row is the bug; the third row is the compatibility check.

Notes

  • StatusJSON gains a permissive prompt_cache schema — every field optional and nullable, coerced numbers like the neighbouring rate_limits.
  • bun run lint (tsc + eslint) clean.
  • The object carries two more fields worth a follow-up: hit_ratio, and recache_tokens_if_cold, which is how many tokens the next turn must reprocess once the entry lapses. That second number is the actionable one — a lapsed 1-hour entry on a 200k context is a rebuild billed at 2× the input rate. Happy to add a widget for it separately if you'd want one.

https://claude.ai/code/session_01Jy7HKv8er6ZiodbjFeped2

The widget inferred the countdown from the newest assistant row's timestamp
plus a configured TTL, because - as its comment said - the expiry was never
exposed. Claude Code now ships a prompt_cache object in status JSON carrying
warm, caching_observed, ttl and the real expires_at, so the guess is no longer
necessary.

Two things the inference gets wrong, both fixed by reading the reported value:

  - A resumed session shows HOT while a user row sits at the tail, even when
    the cache lapsed hours ago. That is exactly backwards from what the widget
    is for: the moment you most need to know the cache is cold is when you come
    back to an idle session.
  - A turn that read an entry written much earlier restarts the countdown from
    the wrong anchor.

Verified against real payloads with the same settings:

  warm    before: Cache: HOT     after: Cache: 34:46
  cold    before: Cache: HOT     after: Cache: COLD
  legacy  before: Cache: HOT     after: Cache: HOT

The third case has no prompt_cache field and falls through to the existing
transcript logic unchanged, so older Claude Code versions keep working.

StatusJSON gains a permissive prompt_cache schema; every field is optional and
nullable, and caching_observed === false is treated as no signal rather than as
cold.

Assisted-by: claude:claude-opus-5
@eric-engberg

Copy link
Copy Markdown
Contributor

Thanks for this, reading prompt_cache is a real improvement. A few things I noticed:

  1. The glyph thresholds still use the configured TTL. getStateSymbol(item, reportedRemaining, getTtlSeconds(item)) divides the reported time left by the widget's TTL setting. With the default 5m and a 1-hour cache (every cache write in my sessions is 1h), 🟡 only appears with 2½ minutes left instead of 30. With the setting on 1h and a 5-minute cache, it's 🔴 right after each response. Claude Code reports ttl as "5m" or "1h", so using that when it's present (300 or 3600 seconds), and the setting otherwise, would fix both.
  2. Tests: CacheTimer.test.ts doesn't cover the new path yet. Cases that seem worth pinning: a warm cache with expires_at, warm: false, caching_observed: false and no prompt_cache at all (both falling back to the transcript), expires_at: null, and the glyph at a 1h ttl.
  3. The Working state: once prompt_cache is present, the reported branch returns before the transcript check, so 🔥 never shows on current Claude Code. That seems reasonable, since the cache really is warm mid-turn. If it's intended, it might be worth saying in docs/USAGE.md that the Working glyph and the t TTL setting only apply when Claude Code doesn't report the cache state.

Happy to help with any of these if useful.

@eric-engberg

Copy link
Copy Markdown
Contributor

One more note: #587 includes this PR's prompt_cache and CacheTimer changes as-is, alongside its new cost widgets, and its description credits this PR. So if anything changes here, like the glyph thresholds or new tests, a quick note on #587 might help keep the two in step.

@sirmalloc
sirmalloc merged commit 57d67bd into sirmalloc:main Oct 29, 2026
3 checks passed
pcvelz added a commit to pcvelz/ccstatusline-usage that referenced this pull request Nov 2, 2026
Upstream: weekly limit timers (sirmalloc#768), prompt cache miss widgets (sirmalloc#770), context window size env override (sirmalloc#352, sirmalloc#407), windowed speed fixes (sirmalloc#769, sirmalloc#771), separator/renderer fixes (sirmalloc#773), custom-command styling (sirmalloc#775), clickable cwd links (sirmalloc#744), account email from live credentials (sirmalloc#724), cache timer reported expiry + short toggle (sirmalloc#576, sirmalloc#592), claude-status lock (sirmalloc#635, sirmalloc#772), dedup token metrics (sirmalloc#622, sirmalloc#725, sirmalloc#735).
Not adopted: sirmalloc#765 terminal width from COLUMNS with fixed reserve - the fork keeps flexMode/compactThreshold and tmux pane priority.

Hand edits outside conflicts:
- README.md: kept fork Configure flex separator behavior bullet and older-updates details wrapper; dropped upstream Related Projects additions (claude-eta)
- AGENTS.md: kept fork terminal width wording (sirmalloc#765 not adopted)
- src/types/FlexMode.ts, src/tui/components/TerminalWidthMenu.tsx, src/tui/components/__tests__/TerminalWidthMenu.test.ts, src/tui/components/TerminalOptionsMenu.tsx, src/tui/components/__tests__/TerminalOptionsMenu.test.ts: restored, fork keeps terminal width settings
- src/tui/App.tsx, src/tui/components/index.ts, src/types/index.ts: re-wired terminal width menu and FlexMode export
- src/types/StatusJSON.ts: dropped fork duplicate prompt_cache key, upstream schema is a superset
- src/utils/terminal.ts: COLUMNS source gated on non-tmux so tmux pane width keeps priority
- src/widgets/WeeklyLimitTimer.ts, src/utils/usage-prefetch.ts, src/widgets/__tests__/WeeklyLimitTimer.test.ts: compat fix - fableUsage/fableResetAt -> weeklyFableUsage/weeklyFableResetAt
- src/widgets/ApiUsage.tsx: compat fix - Context Bar honors effective context window (override, clamp, 1.0M format); Weekly renders a valid value despite a session error
- src/widgets/__tests__/ApiUsage.localModelFallback.test.ts: output tokens no longer counted in context (upstream sirmalloc#730)
- src/utils/__tests__/renderer-flex-width.test.ts, src/utils/__tests__/renderer-ansi.test.ts, src/utils/__tests__/gradient.test.ts, src/utils/__tests__/renderer-separator-collapse.test.ts, src/utils/__tests__/config.test.ts, src/tui/__tests__/App.test.ts: fork deviation - fork width model (flexMode, 6-column full reserve, kept settings, Terminal Width menu entry)
- src/utils/__tests__/default-colors.test.ts: fork deviation - accept ansi256:/hex: defaults
- src/utils/__tests__/usage-prefetch.test.ts: fork deviation - weekly usage comes from the API, fork widget imports from ApiUsage
- src/widgets/__tests__/CurrentWorkingDir.test.ts, src/widgets/__tests__/CustomCommand.test.ts: Settings literal gains flexMode/compactThreshold
- src/utils/__tests__/context-window.test.ts, src/utils/__tests__/jsonl-metrics.test.ts: restored fork tests upstream dropped
- docs/test-retirements.md: ledger for the dev-reload snapshot-path and RemoteControlStatus glyph (sirmalloc#605) tests upstream replaced
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.

3 participants