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

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:

Terminal:

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.
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.
How close the cache is to expiring.
| Color | Means | What to do |
|---|---|---|
| Green | Plenty of time | Nothing |
| Yellow | Running 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 |
| Red | About to expire (5 minutes left, or 1 minute on the 5-minute cache) | Send a message to keep the cache, or press Compact |
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.
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 rate | Usually means |
|---|---|
| 90% to 100% | Normal. Almost the whole conversation came from the cache. |
Low, right after a new session, /clear, or a compact | Expected. Nothing was cached yet, and the next request should be back up near 100%. |
| Near 0% in the middle of a session | The 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.
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.
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.
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.
From How Claude Code uses prompt caching, checked 2026-10-02:
| How you use Claude Code | Main conversation cache |
|---|---|
| Claude subscription, within your plan's included usage | 1 hour |
| Claude subscription after you go over your plan's limit and draw on usage credits | 5 minutes |
| API key | 5 minutes |
| Amazon Bedrock, Google Cloud, or Microsoft Foundry | 5 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
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.
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.
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.
~/.claude/settings.json: { "pluginConfigs": { "cache-buster": { "options": { "cacheMinutes": "5" } } } }
The display updates once a minute, so the 5-minute cache is coarse.
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.
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.hooks/register.tsx 151 lines1import { 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}
151types/index.d.ts 17 lines1declare 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