Shows a countdown until this chat's prompt cache goes cold, and can keep it warm with a small hidden ping. Inspired by Karan Binsal's "cache tax" mod.

A Claude Code mod that shows when this chat's prompt cache goes cold, and can keep it warm.
Returning to a chat after its cache has gone cold makes Claude reread and re-save the whole conversation at full price. On a long Opus chat that is the difference between roughly a dollar or more and a few cents. The timer is invisible, so this mod draws it above the prompt.
Inspired by Karan Binsal's "cache tax" mod. Credit and thanks to Karan Binsal for the idea.
cache_creation.ephemeral_<n>m|h_input_tokens), so it differs by itself between a Claude subscription (about 1 hour) and an API key (about 5 minutes). Nothing is hardcoded or configured. Until a reply shows a lifetime, no countdown is drawn.Keep warm button, or /keep warm, sends a small hidden message shortly before the cache runs out (the reply is not added to the chat). It reads the whole chat from cache, which refreshes the timer, and repeats each cycle. It stops when you send your next message, after 2 hours, or when you press Stop or type /keep off. It never pings while it is off, and never after the cache has already gone cold./keep warm switches keep warm on, /keep off switches it off./keep-warm toggles it./plugin install cache-clock --marketplace FCisco95/claude-code-mods
Or copy plugins/cache-clock to ~/.claude/skills/cache-clock (see the repo README).
hooks/register.tsx 359 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const GREEN = '#16a34a'
5const AMBER = '#ca8a04'
6const RED = '#dc2626'
7// Keep warm never runs longer than this.
8const MAX_KEEP_MS = 2 * 60 * 60 * 1000
9const TICK_MS = 1000
10const PING = 'Cache keep-alive. Reply with the single word: ok'
11
12const ttlMs = atom({ plugin: 'cache-clock', key: 'ttlMs' } as const, 0)
13const ttlLabel = atom({ plugin: 'cache-clock', key: 'ttlLabel' } as const, '')
14const lastRequestAt = atom({ plugin: 'cache-clock', key: 'lastRequestAt' } as const, 0)
15const isTurnRunning = atom({ plugin: 'cache-clock', key: 'isTurnRunning' } as const, false)
16const contextTokens = atom({ plugin: 'cache-clock', key: 'contextTokens' } as const, 0)
17const keepWarmSince = atom({ plugin: 'cache-clock', key: 'keepWarmSince' } as const, 0)
18const pingCount = atom({ plugin: 'cache-clock', key: 'pingCount' } as const, 0)
19const lastPing = atom({ plugin: 'cache-clock', key: 'lastPing' } as const, '')
20const isHidden = atom({ plugin: 'cache-clock', key: 'isHidden' } as const, false)
21
22const formatClock = (ms: number) => {
23 const total = Math.max(0, Math.floor(ms / 1000))
24 const h = Math.floor(total / 3600)
25 const m = Math.floor((total % 3600) / 60)
26 const s = String(total % 60).padStart(2, '0')
27
28 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
29}
30
31const formatTokens = (n: number) =>
32 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
33
34// The engine hands a mod only four token counts, so the lifetime is read where Anthropic's reply is kept
35// whole: the session transcript. Its usage carries `cache_creation.ephemeral_<n>m|h_input_tokens`; the
36// lifetime is read off that field's own name (the shortest if a reply holds several). This runs Node,
37// reads the transcript's last 2 MB and prints {ms, label}; exit 2 = no transcript, 3 = no cache data yet.
38const TTL_SCRIPT = [
39 "const fs=require('fs'),path=require('path'),os=require('os');",
40 "const id=process.argv[1];",
41 "const root=process.env.CLAUDE_CONFIG_DIR||path.join(os.homedir(),'.claude');",
42 "const proj=path.join(root,'projects');",
43 "let file;",
44 "for(const d of fs.readdirSync(proj)){const f=path.join(proj,d,id+'.jsonl');if(fs.existsSync(f)){file=f;break}}",
45 "if(!file)process.exit(2);",
46 "const size=fs.statSync(file).size,len=Math.min(size,1<<21),buf=Buffer.alloc(len),fd=fs.openSync(file,'r');",
47 "fs.readSync(fd,buf,0,len,size-len);fs.closeSync(fd);",
48 "for(const l of buf.toString('utf8').split('\\n').reverse()){",
49 "if(!l.includes('\"cache_creation\":{'))continue;",
50 "try{const j=JSON.parse(l);const c=j.message&&j.message.usage&&j.message.usage.cache_creation;if(!c)continue;",
51 "let best;for(const [k,v] of Object.entries(c)){const m=/^ephemeral_(\\d+)(m|h)_input_tokens$/.exec(k);if(!m||!(Number(v)>0))continue;",
52 "const ms=Number(m[1])*(m[2]==='h'?3600000:60000);if(!best||ms<best.ms)best={ms,label:m[1]+m[2]}}",
53 "if(best){console.log(JSON.stringify(best));process.exit(0)}}catch(e){}}",
54 "process.exit(3)",
55].join('')
56
57const readTtl = async ($: EngineInterface): Promise<{ ms: number; label: string } | undefined> => {
58 try {
59 const run = await $.process.run(['node', '-e', TTL_SCRIPT, await $.session.id()], { timeoutMs: 10_000 })
60
61 if (run.exitCode !== 0) {
62 return undefined
63 }
64
65 const parsed = JSON.parse(run.stdout.trim()) as { ms?: unknown; label?: unknown }
66
67 return typeof parsed.ms === 'number' && parsed.ms > 0 && typeof parsed.label === 'string'
68 ? { ms: parsed.ms, label: parsed.label }
69 : undefined
70 } catch {
71 return undefined
72 }
73}
74
75// Ping this long before the cache runs out: a fifth of the lifetime, at most a minute.
76const marginOf = (ttl: number) => Math.min(60_000, Math.floor(ttl * 0.2))
77
78let isPinging = false
79// When to next read the cache lifetime from the transcript (0 = not pending), and how often it came back empty.
80let detectAt = 0
81let detectTries = 0
82
83const stopKeepWarm = async ($: EngineInterface, why: string) => {
84 if ((await read($, keepWarmSince)) === 0) {
85 return
86 }
87
88 await update($, keepWarmSince, () => 0)
89 $.ui.toast(`Keep warm stopped: ${why}.`)
90}
91
92const startKeepWarm = async ($: EngineInterface): Promise<string> => {
93 const ttl = await read($, ttlMs)
94 const last = await read($, lastRequestAt)
95 const now = await $.clock.now()
96
97 if (ttl === 0 || last === 0) {
98 return 'Cache lifetime not known yet. Send a message first; the next reply shows it.'
99 }
100
101 if (now >= last + ttl) {
102 return 'The cache is already cold, so a ping would only pay for a cold restart. Send your next message instead.'
103 }
104
105 await update($, keepWarmSince, () => now)
106 await update($, pingCount, () => 0)
107 await update($, lastPing, () => '')
108
109 return `Keep warm on: a hidden ping goes out about ${formatClock(marginOf(ttl))} before the cache runs out. It stops when you send a message or after 2 hours.`
110}
111
112const ping = async ($: EngineInterface) => {
113 isPinging = true
114
115 try {
116 const startedAt = await $.clock.now()
117 const reply = await $.model.fork({ prompt: PING })
118
119 if (!reply.isAnswered) {
120 await stopKeepWarm($, reply.reason === 'nothing-to-fork' ? 'nothing to keep warm yet' : `the ping failed (${reply.reason})`)
121
122 return
123 }
124
125 const read_ = reply.usage.cache_read_input_tokens
126 const wrote = reply.usage.cache_creation_input_tokens
127 await update($, lastRequestAt, () => startedAt)
128 await update($, pingCount, n => n + 1)
129 await update($, lastPing, () => `read ${formatTokens(read_)}${wrote > 1000 ? `, re-wrote ${formatTokens(wrote)} (it was already cold)` : ''}`)
130
131 } catch (error) {
132 await stopKeepWarm($, `the ping failed (${String(error)})`)
133 } finally {
134 isPinging = false
135 }
136}
137
138export const register: Register = on => {
139 on('session.start', async ($, e, next) => {
140 await $.command.register({
141 name: 'keep',
142 description: 'keep warm: ping the chat just before its cache expires. keep off: stop',
143 })
144 await $.command.register({
145 name: 'keep-warm',
146 description: 'Toggle keep warm (hidden ping just before the prompt cache expires)',
147 })
148
149 // A session picked up mid-chat (a reload, a resume) has replies in its transcript already.
150 detectAt = (await $.clock.now()) + 1000
151
152 // One clock for the countdown redraw and for the keep-warm pings.
153 $.clock.every(TICK_MS, () => {
154 void (async () => {
155 const last = await read($, lastRequestAt)
156
157 if (last === 0) {
158 return
159 }
160
161 $.ui.invalidate('ui.render')
162
163 const at = await $.clock.now()
164
165 if (detectAt > 0 && at >= detectAt) {
166 detectAt = 0
167 const found = await readTtl($)
168
169 if (found) {
170 await update($, ttlMs, () => found.ms)
171 await update($, ttlLabel, () => found.label)
172 } else if ((await read($, ttlMs)) === 0 && detectTries < 10) {
173 // No cache data in the transcript yet: try again in a few seconds, then give up.
174 detectTries += 1
175 detectAt = at + 4000
176 }
177 }
178
179 const since = await read($, keepWarmSince)
180
181 if (since === 0 || isPinging || (await read($, isTurnRunning))) {
182 return
183 }
184
185 const now = await $.clock.now()
186
187 if (now - since >= MAX_KEEP_MS) {
188 await stopKeepWarm($, '2 hours passed')
189
190 return
191 }
192
193 const ttl = await read($, ttlMs)
194 const remaining = last + ttl - now
195
196 if (ttl > 0 && remaining > 0 && remaining <= marginOf(ttl)) {
197 await ping($)
198 } else if (ttl > 0 && remaining <= 0) {
199 await stopKeepWarm($, 'the cache went cold')
200 }
201 })()
202 })
203
204 return next(e)
205 })
206
207 on('command.run', { command: 'keep' }, async ($, e) => {
208 const arg = e.args.trim().toLowerCase()
209
210 if (arg === 'warm') {
211 return { text: await startKeepWarm($) }
212 }
213
214 if (arg === 'off' || arg === 'stop') {
215 await stopKeepWarm($, 'you switched it off')
216
217 return { text: 'Keep warm is off.' }
218 }
219
220 return { text: 'Use /keep warm to keep the cache warm, or /keep off to stop.' }
221 })
222
223 on('command.run', { command: 'keep-warm' }, async $ => {
224 if ((await read($, keepWarmSince)) > 0) {
225 await stopKeepWarm($, 'you switched it off')
226
227 return { text: 'Keep warm is off.' }
228 }
229
230 return { text: await startKeepWarm($) }
231 })
232
233 on('turn.start', async ($, e, next) => {
234 if (e.agentId === undefined) {
235 await update($, isTurnRunning, () => true)
236 }
237
238 return next(e)
239 })
240
241 on('turn.complete', async ($, e, next) => {
242 if (e.agentId === undefined) {
243 await update($, isTurnRunning, () => false)
244 // The transcript is written a moment after the reply: read the lifetime from it on a later tick.
245 detectAt = (await $.clock.now()) + 2000
246 detectTries = 0
247 }
248
249 return next(e)
250 })
251
252 // Your next message ends keep warm.
253 on('prompt.submit', async ($, e, next) => {
254 if (e.origin.kind === 'composer' && !e.isMeta) {
255 await stopKeepWarm($, 'you sent a message')
256 }
257
258 return next(e)
259 })
260
261 // Each main-thread request refreshes the cache: restart the clock from when it started.
262 on('turn.step', async function* ($, e, next) {
263 const startedAt = e.agentId === undefined ? await $.clock.now() : 0
264 const response = yield* next(e)
265
266 if (e.agentId === undefined) {
267 await update($, lastRequestAt, () => startedAt)
268
269 if (response.usage) {
270 const u = response.usage
271 await update($, contextTokens, () => u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens)
272 }
273 }
274
275 return response
276 })
277
278 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
279 const last = await read($, lastRequestAt)
280
281 if (last === 0 || e.props.hasSurvey || (await read($, isHidden))) {
282 return next(e)
283 }
284
285 const { Box, Button, Text } = $.ui.resolve(e)
286 const ttl = await read($, ttlMs)
287 const label = await read($, ttlLabel)
288 const since = await read($, keepWarmSince)
289 const running = await read($, isTurnRunning)
290 const now = await $.clock.now()
291 const remaining = last + ttl - now
292 const isWarm = ttl > 0 && remaining > 0
293 const tokens = await read($, contextTokens)
294 const toggle = (
295 <Button
296 key="keep"
297 label={since > 0 ? 'Stop' : 'Keep warm'}
298 variant={since > 0 ? 'secondary' : 'primary'}
299 onPress={async () => {
300 if (since > 0) {
301 await stopKeepWarm($, 'you switched it off')
302 } else {
303 $.ui.toast(await startKeepWarm($))
304 }
305 }}
306 />
307 )
308 const hide = <Button key="hide" label="×" onPress={() => update($, isHidden, () => true)} />
309
310 if (running) {
311 return (
312 <Box paddingX={1} gap={1}>
313 <Text dimColor>⏱ Cache in use while Claude works</Text>
314 {hide}
315 </Box>
316 )
317 }
318
319 if (ttl === 0) {
320 return (
321 <Box paddingX={1} gap={1}>
322 <Text dimColor>⏱ Cache timer: this reply showed no cache lifetime</Text>
323 {hide}
324 </Box>
325 )
326 }
327
328 if (!isWarm) {
329 return (
330 <Box paddingX={1} gap={1}>
331 <Text color={RED} weight="bold">⏱ Cache cold</Text>
332 <Text dimColor>{`your next message re-caches about ${formatTokens(tokens)} tokens at full price`}</Text>
333 {hide}
334 </Box>
335 )
336 }
337
338 const color = remaining > ttl * 0.5 ? GREEN : remaining > 60_000 ? AMBER : RED
339 const pings = await read($, pingCount)
340 const ping_ = await read($, lastPing)
341
342 return (
343 <Box flexDirection="column" paddingX={1}>
344 <Box gap={1}>
345 <Text color={color} weight="bold">{`⏱ Cache warm ${formatClock(remaining)}`}</Text>
346 <Text dimColor>{`(${label} lifetime)`}</Text>
347 {toggle}
348 {hide}
349 </Box>
350 {since > 0 && (
351 <Text color={GREEN} wrap="truncate-end">
352 {`Keep warm on · stops in ${formatClock(MAX_KEEP_MS - (now - since))} · ${pings} pings${ping_ ? ` · last ${ping_}` : ''}`}
353 </Text>
354 )}
355 </Box>
356 )
357 })
358}
359types/index.d.ts 23 lines1declare module 'claude-code' {
2 interface PluginState {
3 'cache-clock': {
4 // Cache lifetime read from the API's reply, in ms; 0 until a reply showed one.
5 ttlMs: number
6 // The lifetime as the reply spelled it ('5m', '1h'); '' until known.
7 ttlLabel: string
8 // When the last request that used the chat's cache started, in $.clock.now() ms; 0 before any.
9 lastRequestAt: number
10 isTurnRunning: boolean
11 // Prompt tokens the next request re-sends, from the last main-thread reply.
12 contextTokens: number
13 // When keep warm was switched on, in ms; 0 when off.
14 keepWarmSince: number
15 pingCount: number
16 lastPing: string
17 isHidden: boolean
18 }
19 }
20}
21
22export type Ttl = { ms: number; label: string }
23