Context cache silently freezes when used_percentage is 0 but current_usage is real — statusline and cache diverge (follow-up to #430)
Summary
When Claude Code emits a frame where context_window.used_percentage is 0 but context_window.current_usage already has real, non-zero token counts, the statusline and the session-scoped context cache diverge and stay diverged.
This zero-with-real-usage frame is already known to the codebase: the comment on getNativePercent (src/stdin.ts:159-163) describes it for the fresh-session case, and PR #430 ("show initial context tokens when used_percentage is 0", fixing #417) added the render-side fallback precisely for it. This report is the other half of that same fix. #430 corrected what the statusline displays; the cache-write gate was not updated in the same pass and still tests the raw used_percentage, so it skips the write on exactly the frames #430 taught the renderer to handle. Our field observation also suggests the frame is not limited to session start — we saw the divergence with substantial usage already accumulated, well into a session (details below).
The two halves:
- The rendered statusline correctly falls back to a live percentage computed from
current_usageand displays it. - The context-cache write is skipped entirely for that frame, because the write gate checks the raw (untouched)
used_percentage, not the live fallback value.
If native used_percentage keeps reading 0 for a while, the cache stays frozen at whatever it last held, while the rendered number keeps moving. Any other consumer that reads the cache file (e.g. a script gating context-fill behavior on it) then sees a stale, materially lower number that still looks fresh going by saved_at.
Observed in the field: statusline showing 61%, cache file showing 38%, same session, same moment — a 23-point gap that a "checked the cache 30 seconds ago" consumer would never suspect.
Found on commit 939eb66 (current main, 2 commits past the v0.8.0 tag). Reproduces on every version back through at least v0.6.0 — the relevant functions (getNativePercent, hasGoodContext, writeCache) are unchanged across that range.
Root cause
Two files, four functions, one untouched raw value:
src/stdin.ts:165-171—getNativePercent()treatsused_percentage <= 0as "not populated" and returnsnull:function getNativePercent(stdin: StdinData): number | null { const nativePercent = stdin.context_window?.used_percentage; if (typeof nativePercent === 'number' && !Number.isNaN(nativePercent) && nativePercent > 0) { return Math.min(100, Math.max(0, Math.round(nativePercent))); } return null; }src/stdin.ts:180-192(insidegetContextPercent, and the same pattern ingetBufferedPercent) — onnull, it recomputes and displays a live percentage fromcurrent_usage:const native = getNativePercent(stdin); if (native !== null) { return native; } // Fallback: manual calculation without buffer const size = stdin.context_window?.context_window_size; if (!size || size <= 0) { return 0; } const totalTokens = getTotalTokens(stdin); return Math.min(100, Math.round((totalTokens / size) * 100));src/context-cache.ts:249-255—hasGoodContext(), which gates the cache write, checks the same rawused_percentageand never sees the fallback value computed above:function hasGoodContext(contextWindow: ContextWindow): boolean { return ( (contextWindow.context_window_size ?? 0) > 0 && typeof contextWindow.used_percentage === "number" && contextWindow.used_percentage > 0 ); }src/context-cache.ts:338-339— so the write is skipped on exactly the frames where the renderer computed something worth caching:if (hasGoodContext(contextWindow)) { writeCache(homeDir, transcriptPath, contextWindow, now, sessionName); ... }
Note this is not the same bug isSuspiciousZero() / #508 / #576 cover. Those handle used_percentage: 0 combined with an empty current_usage (restoring from cache on a genuine glitch or fresh-session frame). This report is the opposite combination: used_percentage: 0 with a non-empty, real current_usage. In that case:
isSuspiciousZero()returnsfalse(it requirescurrent_usageto be all-zero), so the cache-restore branch never fires — correctly, since there's nothing to restore, the live number is genuinely more current than the cache.hasGoodContext()also returnsfalse(rawused_percentageis0), so the cache-refresh branch never fires either — incorrectly, sincecurrent_usageclearly has a usable snapshot in it.
Nothing in applyContextWindowFallback (src/context-cache.ts:293+) reconciles the two: it only touches used_percentage inside the isSuspiciousZero branch (for the post-/compact transition, via compactHint.lastCompactPostTokens) and inside the cache-restore path. This particular combination — zero native percent, real usage — falls through both untouched.
Minimal reproduction
Point CLAUDE_CONFIG_DIR at a scratch directory so this doesn't touch a real config, then feed the HUD two frames for the same transcript_path.
Positive control — confirm the write path works at all. A frame with a normal non-zero used_percentage writes the cache as expected:
export CLAUDE_CONFIG_DIR=/tmp/hud-repro
rm -rf "$CLAUDE_CONFIG_DIR"
echo '{
"transcript_path": "/tmp/session-repro.jsonl",
"model": {"display_name": "Opus"},
"context_window": {
"used_percentage": 38,
"remaining_percentage": 62,
"context_window_size": 200000,
"current_usage": {
"input_tokens": 74000,
"output_tokens": 1200,
"cache_creation_input_tokens": 1000,
"cache_read_input_tokens": 1000
}
}
}' | node dist/index.js
HASH=$(node -e "console.log(require('crypto').createHash('sha256').update(require('path').resolve('/tmp/session-repro.jsonl')).digest('hex'))")
cat "$CLAUDE_CONFIG_DIR/plugins/claude-hud/context-cache/$HASH.json"
# → {"used_percentage":38, ...} -- cache written, as expected.The bug. Now send a second frame for the same transcript_path, simulating the in-flight-request gap: used_percentage: 0, but current_usage already shows real, higher token counts (statusline should read ~61%):
echo '{
"transcript_path": "/tmp/session-repro.jsonl",
"model": {"display_name": "Opus"},
"context_window": {
"used_percentage": 0,
"remaining_percentage": 100,
"context_window_size": 200000,
"current_usage": {
"input_tokens": 120000,
"output_tokens": 4000,
"cache_creation_input_tokens": 1500,
"cache_read_input_tokens": 500
}
}
}' | node dist/index.js
# stdout: the context bar shows ~61% -- correct, computed live from current_usage.
cat "$CLAUDE_CONFIG_DIR/plugins/claude-hud/context-cache/$HASH.json"
# → still {"used_percentage":38, ...} -- UNCHANGED. The renderer showed 61%,
# the cache still says 38%, and it will keep saying 38% for as long as
# native used_percentage keeps reading 0, no matter how much real usage
# current_usage accumulates in the meantime.Observed vs expected
- Observed: the rendered line and the persisted
context-cache/<sha>.jsonsnapshot silently diverge on any tick where nativeused_percentagereads0alongside a non-emptycurrent_usage, and the divergence only grows from there. - Expected: the cache should hold the same value the renderer is showing at that moment — either write the fallback-derived percentage, or don't compute one at all, but the two should never quietly disagree.
Version / commit
- Vendored/observed at commit
939eb66485832dead1b0a28a954f76f7aa2bdb06(main, 2 commits pastv0.8.0). src/context-cache.tsandsrc/stdin.tsare unchanged back throughv0.6.0for the functions involved (getNativePercent,isSuspiciousZero,hasGoodContext,writeCache,applyContextWindowFallback), so this reproduces on any version in that range too.
Suggested fixes
Option A (recommended) — synthesize used_percentage before the write gate runs, in place. In applyContextWindowFallback, add a branch alongside the existing isSuspiciousZero handling: when used_percentage is 0/missing but current_usage is not all-zero, recompute used_percentage (and remaining_percentage) from current_usage right there — the same way getContextPercent's fallback and the existing post-compact postTokens synthesis (src/context-cache.ts:326-332) already do — before hasGoodContext() is evaluated. This makes the mutated frame the single source of truth: hasGoodContext() now sees a real percentage and writes it, and since this branch runs before render() consumes the same stdin object, getNativePercent() picks up the synced value directly too. One code path, no possibility of the two disagreeing again. Smallest diff; reuses a pattern already in the file; ideally reuses getTotalTokens() from stdin.ts (input + cache-creation + cache-read tokens, matching the render fallback's exact formula) rather than re-deriving the sum.
Option B (alternative) — decouple the write gate from raw used_percentage. Change hasGoodContext() to accept "usage is present" (!isAllUsageZero(contextWindow.current_usage)) as sufficient, independent of the raw used_percentage value, and have writeCache() store the same fallback-derived percentage the renderer computes. This avoids mutating the frame that gets passed on to rendering, at the cost of a slightly larger diff (the synthesis logic has to live in the write path instead of a single shared mutation point).
Either way, one existing test currently encodes the buggy behavior as expected and needs its assertions updated: applyContextWindowFallback keeps live usage when zero-percent frame already has current_usage data (tests/context-cache.test.js) asserts used_percentage stays 0 in exactly the scenario this issue describes — that assertion is checking that the frame isn't touched, not that the cache stays correct, and it's the reason this combination fell through unnoticed.
I'm attaching a PR with option A implemented, including updated/added tests.
Source: jarrodwatts/claude-hud