SLOPSHOPPER

cache-buster

Band above the prompt showing prompt-cache age, hit rate, and a Compact button before the cache expires.

newbandtoasttimer
v0.2.0no licenseupdated 2026-10-06dblanken-yale/cache-buster
A shopper browsing a rack in a slop shop
README

cache-buster

A Claude Code mod that shows how much time is left on the prompt cache, so you can keep a session going (or compact it) before the cache expires.

Desktop app:

cache-buster above the prompt in Claude Code Desktop

Terminal:

cache-buster above the prompt in the Claude Code terminal

Why

Claude Code caches your conversation between requests, for an hour or for five minutes depending on your account (see Which cache length you have). While the cache is warm, each new message reads the conversation back from the cache at a tenth of the normal input price or less, so it is at least 90% cheaper (95% on Opus 5.5).

If you go longer than that without sending anything, the cache expires. Your next message has to write the whole conversation back into the cache, which costs twice the normal input price on the 1-hour cache. That one message costs about 20 times what it would have with a warm cache (40 times on Opus 5.5), and the more context you have built up, the bigger that bill.

This mod shows how long you have before that happens. If you have a long session going and know you'll be away past the hour, press Compact first. The conversation shrinks to a short summary, so the message that starts it up again is cheap.

Prices are from the Claude pricing page, checked 2026-10-02.

What it shows

The goal is two things: keep the cache from expiring, and keep the hit rate high on each request. Each piece of the row helps with one of them.

Dot

How close the cache is to expiring.

ColorMeansWhat to do
GreenPlenty of timeNothing
YellowRunning low (15 minutes left on the 1-hour cache, 2 minutes on the 5-minute cache)If you're about to step away, decide now whether to compact
RedAbout to expire (5 minutes left, or 1 minute on the 5-minute cache)Send a message to keep the cache, or press Compact

Time left and bar

How long until the cache expires, counted from the last request Claude Code sent for this conversation. Every request refreshes the cache, so the bar refills to full each time Claude replies. It drains one block every 3 minutes on the 1-hour cache. Remaining blocks take the dot's color, and used blocks turn gray. At zero the label reads expired, and your next message pays the full price described in Why.

Requests made by subagents don't refresh it, since they don't use the main conversation's cache.

Hit rate

The share of the last request's input that was read from the cache instead of processed fresh. Higher is better: cached input costs a tenth of the normal price or less, while fresh input costs full price, or twice that when it's written into the 1-hour cache.

Hit rateUsually means
90% to 100%Normal. Almost the whole conversation came from the cache.
Low, right after a new session, /clear, or a compactExpected. Nothing was cached yet, and the next request should be back up near 100%.
Near 0% in the middle of a sessionThe cache was lost. Either it expired, or something invalidated it: switching models, connecting or removing an MCP server, or a Claude Code upgrade. See Actions that invalidate the cache.

It only describes the last request. It says nothing about time left; the countdown covers that.

Context size

How many tokens the last request sent, which is the size of the conversation so far (for example 142k ctx). It turns yellow past 200k. That is a rule of thumb, not a hard limit: answers tend to get less focused as context grows, so past this point it is worth compacting or starting fresh. It turns red at 80% of the model's context window, near where Claude Code auto-compacts.

Compact button

Runs the same thing as /compact. It replaces the conversation with a short summary, so the next request only has to cache that summary instead of the whole history. Use it when the dot is yellow or red and you know you'll be away past the cache length. Compacting while the cache is still warm is cheap, because the summarizing request reads the conversation from the cache. Compacting after it expires costs a full uncached read of the history.

The row hides after any compact (the button, /compact, or an automatic one) and comes back with the next reply. If Claude is mid-reply, the button waits and compacts as soon as the reply finishes.

Warning popup

Appears once when the dot turns red: "Cache expires in ~5m. Compact or send something." (~1m on the 5-minute cache). It shows once per idle stretch and resets with the next request.

Which cache length you have

From How Claude Code uses prompt caching, checked 2026-10-02:

How you use Claude CodeMain conversation cache
Claude subscription, within your plan's included usage1 hour
Claude subscription after you go over your plan's limit and draw on usage credits5 minutes
API key5 minutes
Amazon Bedrock, Google Cloud, or Microsoft Foundry5 minutes

You can override the default with the promptCacheTtl setting ("5m" or "1h") in ~/.claude/settings.json, or the CLAUDE_CODE_PROMPT_CACHE_TTL environment variable. Both need Claude Code v2.1.242 or later. FORCE_PROMPT_CACHING_5M=1 forces five minutes, and ENABLE_PROMPT_CACHING_1H=1 asks for an hour.

If you're on the 5-minute cache, set cache-buster's cache length to 5 (see Notes). The mod can't detect which one you have, so it won't notice if a subscription switches to 5 minutes partway through a session because you went over your plan's limit.

To check which one your sessions use, run this and look at usage.cache_creation. Tokens under ephemeral_1h_input_tokens mean the 1-hour cache, and tokens under ephemeral_5m_input_tokens mean the 5-minute cache.

claude -p "hello" --output-format json

Install

Run these in Claude Code:

/plugin marketplace add dblanken-yale/cache-buster
/plugin install cache-buster@cache-buster

Or from a terminal:

claude plugin marketplace add dblanken-yale/cache-buster
claude plugin install cache-buster@cache-buster

Start a new Claude Code session, in the terminal or the desktop app. The row appears above the prompt after the first reply.

Update

Turn on auto-update for the cache-buster marketplace in /plugin (Marketplaces tab), and new versions install when Claude Code starts. To update by hand:

claude plugin marketplace update cache-buster

Desktop app sessions pick up the new version when you start a new one.

Develop

Clone the repo and load it from the folder, so edits reload as you save:

git clone git@github.com:dblanken-yale/cache-buster.git ~/code/cache-buster
claude --plugin-dir ~/code/cache-buster

To load it in every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (folders separated by :). Don't also install it from the marketplace, or it loads twice.

Bump version in .claude-plugin/plugin.json with every release. Installed copies only update when the version changes.

Notes

  • The mod assumes a 1-hour cache by default. If you're on the 5-minute cache (see Which cache length you have), set the cache length to 5 in the plugin's row in the config menu, or in ~/.claude/settings.json:
  { "pluginConfigs": { "cache-buster": { "options": { "cacheMinutes": "5" } } } }

The display updates once a minute, so the 5-minute cache is coarse.

  • It shares the row above the prompt with other mods. Claude Code shows one tree there, built by a chain of hooks: the top mod's hook draws first, and the mods beneath only draw if it calls next(e). cache-buster calls next(e), gets back whatever the mods beneath drew, and stacks it under its own bar in a column (hooks/register.tsx, the AbovePrompt hook). Before this, it returned only its own bar, which hid any other mod's row once the first reply came in. A mod that draws in this row should do the same:
  const below = await next(e)

  return (
    <Box flexDirection="column">
      <Box>{/* this mod's row */}</Box>
      {below}
    </Box>
  )

When there's nothing to show, call return next(e) instead, as the hook already does before the first reply.

  • To check it after editing: claude plugin validate ~/code/cache-buster and claude plugin test ~/code/cache-buster.
  • tsconfig.json points at .claude-plugin/types/, which Claude Code generates and git ignores, so type-checking a fresh clone needs those files regenerated first.
Source 2 files
hooks/register.tsx 151 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const CELLS = 20
5// Past this many tokens of context, models tend to lose focus.
6const CONTEXT_WARN = 200_000
7// Red at this fraction of the model's context window: close to auto-compact.
8// ponytail: 80% of the window approximates the auto-compact point; the exact threshold is only
9// in $.session.usage({ breakdown: 'summary' }) (rawMaxTokens), if precision ever matters.
10const CONTEXT_ALARM = 0.8
11
12const lastAt = atom({ plugin: 'cache-buster', key: 'lastAt' } as const, null)
13const hitRate = atom({ plugin: 'cache-buster', key: 'hitRate' } as const, 0)
14const context = atom({ plugin: 'cache-buster', key: 'context' } as const, 0)
15const window = atom({ plugin: 'cache-buster', key: 'window' } as const, 0)
16const now = atom({ plugin: 'cache-buster', key: 'now' } as const, 0)
17
18let warned = false
19
20// Set when Compact is pressed mid-turn; the compact runs at the next turn.complete.
21let pending = false
22
23async function reset($: EngineInterface) {
24  warned = false
25  pending = false
26  await update($, lastAt, () => null)
27}
28
29// The engine refuses to compact while a turn runs, so hold it for the end of the turn.
30function queue($: EngineInterface) {
31  if (!pending) $.ui.toast('Will compact when this reply finishes.')
32  pending = true
33}
34
35async function compact($: EngineInterface) {
36  try {
37    const r = await $.session.compact()
38    pending = false
39    if (r.skip) $.ui.toast(`Compact skipped: ${r.skip}`)
40  } catch (err) {
41    const msg = err instanceof Error ? err.message : String(err)
42    if (msg.includes('a turn is running')) return queue($)
43    pending = false
44    $.ui.toast(msg)
45  }
46}
47
48export const register: Register = (on, options) => {
49  const ttl = Number(options.cacheMinutes) || 60
50  const warn = Math.max(1, Math.round(ttl / 12))
51  const caution = Math.max(warn + 1, ttl / 4)
52
53  on('session.start', async ($, e, next) => {
54    const started = await next(e)
55    const t0 = await $.clock.now()
56    await update($, now, () => t0)
57    $.clock.every(60_000, async () => {
58      const t = await $.clock.now()
59      await update($, now, () => t)
60      const at = await read($, lastAt)
61      if (at !== null && !warned && ttl - (t - at) / 60_000 <= warn) {
62        warned = true
63        $.ui.toast(`Cache expires in ~${warn}m. Compact or send something.`)
64      }
65    })
66    return started
67  })
68
69  on('session.end', async ($, e, next) => {
70    if (e.reason === 'clear') await reset($)
71    return next(e)
72  })
73
74  on('session.compact', async ($, e, next) => {
75    const r = await next(e)
76    if (!e.agentId && e.trigger !== 'precompute' && !r.skip) await reset($)
77    return r
78  })
79
80  on('turn.complete', async ($, e, next) => {
81    const r = await next(e)
82    if (pending && !e.agentId) void compact($)
83    return r
84  })
85
86  on('turn.step', async function* ($, e, next) {
87    const r = yield* next(e)
88    if (e.agentId || !r.usage) return r
89
90    const u = r.usage
91    const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
92    const t = await $.clock.now()
93    warned = false
94    await update($, lastAt, () => t)
95    await update($, now, () => t)
96    await update($, hitRate, () => (total ? u.cache_read_input_tokens / total : 0))
97    await update($, context, () => total)
98    const { context: c } = await $.session.usage()
99    await update($, window, () => c.window)
100    return r
101  })
102
103  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104    const at = await read($, lastAt)
105    if (e.props.hasSurvey || at === null) return next(e)
106
107    const [t, hit, ctx, win] = await Promise.all([
108      read($, now),
109      read($, hitRate),
110      read($, context),
111      read($, window),
112    ])
113    const ageMin = Math.max(0, Math.floor((t - at) / 60_000))
114    const leftMin = Math.max(0, ttl - ageMin)
115    const filled = Math.round((leftMin / ttl) * CELLS)
116    const color = leftMin <= warn ? 'red' : leftMin <= caution ? 'yellow' : 'green'
117    const ctxColor = win && ctx >= win * CONTEXT_ALARM ? 'red' : ctx > CONTEXT_WARN ? 'yellow' : undefined
118    const rate = Math.round(hit * 100)
119    const { Box, Button, Text } = $.ui.resolve(e)
120    // Stack whatever the bands beneath draw (other mods), instead of hiding it.
121    const below = await next(e)
122
123    return (
124      <Box flexDirection="column">
125        <Box>
126          <Text color={color}>● </Text>
127          <Text dimColor>cache </Text>
128          <Text color={color}>{leftMin === 0 ? 'expired' : `${leftMin}m left`} </Text>
129          <Box gap={1}>
130            {Array.from({ length: CELLS }, (_, i) => (
131              <Text key={`cell${i}`} backgroundColor={i < filled ? color : 'gray'}>
132                {' '}
133              </Text>
134            ))}
135          </Box>
136          <Text dimColor> {rate}% hit </Text>
137          <Text color={ctxColor} dimColor={!ctxColor}>
138            {Math.round(ctx / 1000)}k ctx{' '}
139          </Text>
140          <Button
141            key="compact"
142            label="Compact"
143            onPress={() => (e.props.isWorking ? queue($) : compact($))}
144          />
145        </Box>
146        {below}
147      </Box>
148    )
149  })
150}
151
types/index.d.ts 17 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'cache-buster': {
4      /** Epoch ms of the last main-thread request; null before the first. */
5      lastAt: number | null
6      /** Cache hit rate of that request, 0 to 1. */
7      hitRate: number
8      /** Input tokens of that request: the conversation's context size. */
9      context: number
10      /** The model's context window in tokens, read after each request; 0 before the first. */
11      window: number
12      /** Epoch ms, refreshed every minute so the band redraws. */
13      now: number
14    }
15  }
16}
17