Context cache silently freezes when used_percentage is 0 but current_usage is real — statusline and cache diverge (follow-up to #430)

Author: yotamleoCreated Sep 1, 2026Updated Sep 1, 2026

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_usage and 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:

  1. src/stdin.ts:165-171getNativePercent() treats used_percentage <= 0 as "not populated" and returns null:

    typescript
    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;
    }
  2. src/stdin.ts:180-192 (inside getContextPercent, and the same pattern in getBufferedPercent) — on null, it recomputes and displays a live percentage from current_usage:

    typescript
    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));
  3. src/context-cache.ts:249-255hasGoodContext(), which gates the cache write, checks the same raw used_percentage and never sees the fallback value computed above:

    typescript
    function hasGoodContext(contextWindow: ContextWindow): boolean {
      return (
        (contextWindow.context_window_size ?? 0) > 0 &&
        typeof contextWindow.used_percentage === "number" &&
        contextWindow.used_percentage > 0
      );
    }
  4. src/context-cache.ts:338-339 — so the write is skipped on exactly the frames where the renderer computed something worth caching:

    typescript
    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() returns false (it requires current_usage to 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 returns false (raw used_percentage is 0), so the cache-refresh branch never fires either — incorrectly, since current_usage clearly 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:

bash
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%):

bash
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>.json snapshot silently diverge on any tick where native used_percentage reads 0 alongside a non-empty current_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 past v0.8.0).
  • src/context-cache.ts and src/stdin.ts are unchanged back through v0.6.0 for 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.