SLOPSHOPPER

cache-clock

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.

newbandcommandtoastpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-clock
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /keep ⎿ cache-clock: Use /keep warm to keep the cache warm, or /keep off to stop. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Cache Clock

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.

What it does

  • Countdown. When Claude finishes a reply, a countdown above the prompt shows how long the cache stays warm. It restarts after every reply.
  • Lifetime from the API. The length is read from the cache fields of Anthropic's own reply (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.
  • Counted from the request. The clock starts when the last request that used the cache started, not when the reply finished, so a long reply does not make the timer optimistic.
  • Keep warm. The 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.

Commands

  • /keep warm switches keep warm on, /keep off switches it off.
  • /keep-warm toggles it.

Notes

  • Claude Code only. Written as a mod for the Claude Code interface.
  • The countdown sits in the band above the prompt box.

Install

/plugin install cache-clock --marketplace FCisco95/claude-code-mods

Or copy plugins/cache-clock to ~/.claude/skills/cache-clock (see the repo README).

Source 2 files
hooks/register.tsx 359 lines
1import { 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}
359
types/index.d.ts 23 lines
1declare 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