A shot clock for the prompt cache's TTL: a small countdown in the bar under the prompt, and a big LED clock above it for the last seconds before the next turn…

A Claude Code mod that puts a shot clock on your prompt cache: how long until it expires and your next turn pays to write the whole conversation into the cache again.

<sub>The terminal half is a real capture of the mod counting down to the buzzer. The player is fictional.</sub>
Most of the time, just a small clock at the end of the bar under the prompt:

With 15 seconds left, the big LED clock appears above the prompt. It counts whole seconds in amber, then tenths in red for the last ten:

At zero, the buzzer:

Ten seconds later it folds back into the bar as ⏱ 00:00, until your next turn restarts it.
A model switch, /compact or /clear resets the clock, because the new prefix has nothing cached yet.
You need Claude Code 2.1.287 or newer.
git clone https://github.com/cjavdev/mods.git ~/mods
claude --plugin-dir ~/mods/cache-shot-clock
To load it in every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (separate several folders with :):
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/mods/cache-shot-clock:~/mods/context-meter" } }
It shares the bar with context-meter, and the big clock stacks under anything else above the prompt.
The bar under the prompt is drawn by the terminal only for now. In the desktop app you get the big clock for the last 15 seconds and nothing before it.
In /config, or under pluginConfigs["cache-shot-clock"].options in settings:
| Option | Default | |
|---|---|---|
enabled | true | Turns the clock off without unloading the mod. |
bigAt | 15 | Seconds left when the big LED clock appears. 0 keeps the clock in the bar. |
ttl | auto | auto, 5m or 1h. Forces the countdown length instead of reading it from the transcript. |
warnSeconds | 60 | When to toast before an idle cache expires. 0 turns the toast off. |
testTtlSeconds | 0 | For testing: count down from this many seconds instead of the real TTL. 0 is off. |
testTtlSeconds makes the clock count down from any number of seconds. This runs a 40-second clock, so you see the big clock and the buzzer in under a minute:
claude --settings '{"pluginConfigs":{"cache-shot-clock":{"options":{"testTtlSeconds":40}}}}'
testTtlSeconds and ttl only change what the clock counts. They don't change how long Anthropic keeps your cache. To get a real 5-minute cache for a session, start Claude Code with FORCE_PROMPT_CACHING_5M=1; the clock picks that up by itself.
turn.step hook reads each main-thread response's usage and restarts the clock.model.fork hook restarts it when another mod's fork reads the main prefix, such as cache-saver.classic.Stop hook tails the transcript after each turn and reads the newest cache_creation bucket to find the TTL.$.clock.every tick redraws the clock and raises the warning. The last ten seconds tick every 100ms.ui.render hook on PromptHint adds the small clock as the hint line's tail.ui.render hook on AbovePrompt draws the LED panel for the last seconds.The parsing, formatting and LED font are pure functions in hooks/clock.ts.
claude plugin validate ~/mods/cache-shot-clock
claude plugin test ~/mods/cache-shot-clock
The tests run a whole countdown on a mocked clock: the bar clock, the big clock taking over at 15 seconds, tenths, the buzzer, and folding back.
hooks/register.tsx 281 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Clock, Ttl, TtlSource } from '../types'
5import { TTL_MS, bigDigits, compact, fmtAgo, fmtClock, fmtShot, prefixOf, ttlFromTranscript } from './clock'
6
7const clock = atom({ plugin: 'cache-shot-clock', key: 'clock' } as const, null)
8const now = atom({ plugin: 'cache-shot-clock', key: 'now' } as const, 0)
9
10// How far past 5m a cache hit must land before it proves the TTL is 1h.
11const SLACK_MS = 15_000
12// The transcript tail read for the TTL: plenty of rows, never the whole file.
13const TAIL_BYTES = '262144'
14
15type Usage = Parameters<typeof prefixOf>[0]
16
17// What we know between cache touches. Module variables start over on a
18// reload; session.start seeds them back from state.
19const mem = {
20 forced: null as Ttl | null,
21 ttl: '5m' as Ttl,
22 ttlSource: 'assumed' as TtlSource,
23 isWorking: false,
24 warnMs: 60_000,
25 // The big LED clock shows from this much time left until BIG_AFTER_MS past
26 // the buzzer; 0 keeps it to the bar under the prompt.
27 bigMs: 15_000,
28 // For testing: count down from this instead of the real TTL; 0 is off.
29 testMs: 0,
30 warnedFor: 0,
31 buzzedFor: 0,
32 // The 100ms timer that runs the tenths through the last ten seconds.
33 fast: null as { cancel: () => void } | null,
34}
35
36// How long the clock runs: the cache's TTL, or the test override.
37const ttlMs = (c: Clock) => mem.testMs || TTL_MS[c.ttl]
38
39// LED amber on the floor, red when it's under ten.
40const AMBER = '#ffb000'
41const RED = '#ff3030'
42const DARK_RED = '#7a1010'
43const PANEL = '#000000'
44// How long the violation stays up after the buzzer before the clock folds
45// back into the bar.
46const BIG_AFTER_MS = 10_000
47
48// Whether the big LED clock is up: the last seconds and just past the buzzer.
49const isBig = (left: number) => mem.bigMs > 0 && left <= mem.bigMs && left > -BIG_AFTER_MS
50
51// The bar's clock: 04:59, 59:53, 1:02:03 past an hour, 00:00 once cold.
52const barClock = (left: number) => fmtClock(left).padStart(5, '0')
53
54// One redraw: the time, the warning, the buzzer, and the tenths timer.
55async function tick($: EngineInterface) {
56 const at = await $.clock.now()
57 await update($, now, () => at)
58 const c = await read($, clock)
59 if (c === null) return
60 const left = c.lastAt + ttlMs(c) - at
61
62 if (mem.warnMs > 0 && mem.warnMs < ttlMs(c) && !mem.isWorking && left > 0 && left <= mem.warnMs && mem.warnedFor !== c.lastAt) {
63 mem.warnedFor = c.lastAt
64 $.ui.toast(`Shot clock: ${fmtClock(left)} left on the prompt cache (${compact(c.prefixTokens)} tokens)`)
65 }
66
67 if (left <= 0 && left > -5000 && mem.buzzedFor !== c.lastAt) {
68 mem.buzzedFor = c.lastAt
69 $.ui.toast(`BZZZT! Shot clock violation: the prompt cache expired. The next turn re-writes ${compact(c.prefixTokens)} tokens.`)
70 }
71
72 if (left > 0 && left <= 11_000 && mem.fast === null) {
73 mem.fast = $.clock.every(100, () => void tick($))
74 } else if ((left <= 0 || left > 11_000) && mem.fast !== null) {
75 mem.fast.cancel()
76 mem.fast = null
77 }
78}
79
80async function learnTtl($: EngineInterface, next: Ttl, source: TtlSource) {
81 if (mem.forced) return
82 mem.ttl = next
83 mem.ttlSource = source
84 await update($, clock, prev =>
85 prev && (prev.ttl !== next || prev.ttlSource !== source) ? { ...prev, ttl: next, ttlSource: source } : prev,
86 )
87}
88
89// A main-thread request touched the cache: restart the clock.
90async function touch($: EngineInterface, usage: Usage) {
91 const at = await $.clock.now()
92 const prev = await read($, clock)
93
94 // A hit after a gap only a 1h entry survives settles the TTL by itself.
95 if (
96 prev &&
97 mem.ttl === '5m' &&
98 usage.cache_read_input_tokens > 0 &&
99 at - prev.lastAt > TTL_MS['5m'] + SLACK_MS
100 ) {
101 await learnTtl($, '1h', 'observed')
102 }
103
104 const next: Clock = { lastAt: at, prefixTokens: prefixOf(usage), ttl: mem.ttl, ttlSource: mem.ttlSource }
105 await update($, clock, () => next)
106 await update($, now, () => at)
107}
108
109async function reset($: EngineInterface) {
110 await update($, clock, () => null)
111}
112
113export const register: Register = (on, options) => {
114 // Off in /config: hook nothing at all.
115 if (options.enabled === false) return
116
117 mem.forced = options.ttl === '5m' || options.ttl === '1h' ? options.ttl : null
118 mem.ttl = mem.forced ?? '5m'
119 mem.ttlSource = mem.forced ? 'setting' : 'assumed'
120 mem.warnMs = Math.max(0, Number(options.warnSeconds ?? 60)) * 1000
121 mem.fast = null
122 mem.bigMs = Math.max(0, Number(options.bigAt ?? 15)) * 1000
123 mem.testMs = Math.max(0, Number(options.testTtlSeconds ?? 0)) * 1000
124
125 on('session.start', async ($, e, next) => {
126 const result = await next(e)
127 const prev = await read($, clock)
128 if (prev && !mem.forced) {
129 mem.ttl = prev.ttl
130 mem.ttlSource = prev.ttlSource
131 }
132 await update($, now, () => Date.now())
133
134 // One tick a second; the last ten seconds tick in tenths.
135 $.clock.every(1000, () => void tick($))
136 return result
137 })
138
139 on('turn.start', async ($, e, next) => {
140 mem.isWorking = true
141 return next(e)
142 })
143
144 on('turn.complete', async ($, e, next) => {
145 if (e.agentId === undefined) mem.isWorking = false
146 return next(e)
147 })
148
149 // Every main-thread request reads and extends the cache; subagents keep
150 // their own prefixes and leave the main one alone.
151 on('turn.step', async function* ($, e, next) {
152 const result = yield* next(e)
153 if (e.agentId === undefined && result.usage) await touch($, result.usage)
154 return result
155 })
156
157 // A fork (another mod's `$.model.fork`) re-reads the main prefix; a hit
158 // refreshes the entry just as a turn would.
159 on('model.fork', async ($, e, next) => {
160 const result = await next(e)
161 // A call on `$` reaches its hooks as `{ value }` (or `{ deny }`).
162 const reply = result.value
163 if (reply?.isAnswered && reply.usage.cache_read_input_tokens > 0) {
164 const prev = await read($, clock)
165 const at = await $.clock.now()
166 if (prev) await update($, clock, () => ({ ...prev, lastAt: at }))
167 }
168 return result
169 })
170
171 // After each main turn the transcript holds the API's cache_creation
172 // buckets, which say outright whether the write was 5m or 1h.
173 on('classic.Stop', async ($, e, next) => {
174 const result = await next(e)
175 if (!mem.forced) {
176 const tail = await $.process
177 .run(['tail', '-c', TAIL_BYTES, e.transcript_path], { timeoutMs: 5000 })
178 .catch(() => null)
179 const found = tail?.exitCode === 0 ? ttlFromTranscript(tail.stdout) : null
180 if (found) await learnTtl($, found, 'transcript')
181 }
182 return result
183 })
184
185 // A switch forfeits the cache; the hook input also names the TTL.
186 on('classic.PostModelSwitch', async ($, e, next) => {
187 const result = await next(e)
188 await reset($)
189 if (!mem.forced) {
190 mem.ttl = e.cache_ttl
191 mem.ttlSource = 'model-switch'
192 }
193 return result
194 })
195
196 // A compaction or /clear starts a new prefix: nothing is cached for it yet.
197 on('session.compact', async ($, e, next) => {
198 const result = await next(e)
199 if (e.agentId === undefined && e.trigger !== 'precompute' && result.messages) await reset($)
200 return result
201 })
202
203 on('session.end', async ($, e, next) => {
204 if (e.reason === 'clear') await reset($)
205 return next(e)
206 })
207
208 // Most of the time: just `⏱ 04:59` at the end of the hint row under the
209 // prompt, beside the mode and the running shells. It steps aside while the
210 // big clock is up.
211 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
212 const c = await read($, clock)
213 if (c === null) return next(e)
214
215 const at = (await read($, now)) ?? Date.now()
216 const left = c.lastAt + ttlMs(c) - at
217 if (isBig(left)) return next(e)
218
219 const tail = `⏱ ${barClock(left)}`
220 return next({ ...e, props: { ...e.props, tail: e.props.tail ? `${e.props.tail} · ${tail}` : tail } })
221 })
222
223 // The last seconds: the big LED clock above the prompt, through the buzzer.
224 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
225 const below = await next(e)
226 if (e.props.hasSurvey) return below
227
228 const c = await read($, clock)
229 if (c === null) return below
230
231 const at = (await read($, now)) ?? Date.now()
232 const left = c.lastAt + ttlMs(c) - at
233 if (!isBig(left)) return below
234
235 const { Box, Text } = $.ui.resolve(e)
236 const isViolation = left <= 0
237 const digitColor = left <= 10_000 ? RED : AMBER
238 const rows = bigDigits(fmtShot(left))
239 const ttlLabel = mem.testMs ? `${mem.testMs / 1000}s test TTL` : `${c.ttl}${c.ttlSource === 'assumed' ? '?' : ''} TTL`
240 const tokens = compact(c.prefixTokens)
241
242 const side = isViolation
243 ? [
244 <Text color={RED} bold>
245 SHOT CLOCK VIOLATION
246 </Text>,
247 <Text dimColor>{`Prompt cache cold for ${fmtAgo(-left)}`}</Text>,
248 <Text dimColor>{`Next turn re-writes ${tokens} tokens (${ttlLabel})`}</Text>,
249 ]
250 : [
251 <Text bold>SHOT CLOCK</Text>,
252 <Text dimColor>{`Prompt cache · ${ttlLabel}`}</Text>,
253 <Text dimColor>{`${tokens} tokens on the line`}</Text>,
254 ]
255
256 const mine = (
257 <Box key="cache-shot-clock">
258 <Box borderStyle="bold" borderColor="#444444" backgroundColor={PANEL} flexDirection="column" paddingX={1}>
259 {rows.map(row => (
260 <Text color={isViolation && Math.floor(-left / 1000) % 2 === 1 ? DARK_RED : digitColor} backgroundColor={PANEL} bold>
261 {row}
262 </Text>
263 ))}
264 </Box>
265 <Box flexDirection="column" paddingLeft={2} paddingTop={1}>
266 {side}
267 </Box>
268 </Box>
269 )
270
271 return below ? (
272 <Box flexDirection="column">
273 {below}
274 {mine}
275 </Box>
276 ) : (
277 mine
278 )
279 })
280}
281hooks/clock.ts 89 lines1// Pure helpers: no `$`, so tests can call them directly.
2
3import type { Ttl } from '../types'
4
5export const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
6
7type Usage = {
8 input_tokens: number
9 output_tokens: number
10 cache_read_input_tokens: number
11 cache_creation_input_tokens: number
12}
13
14// What the next request re-sends: everything this one was answered over plus
15// what it generated. On a cold cache all of it is written afresh.
16export const prefixOf = (u: Usage): number =>
17 u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
18
19// The TTL the newest cache write used, from the tail of a session transcript
20// (JSONL). Each assistant row's usage carries
21// "cache_creation":{"ephemeral_1h_input_tokens":N,"ephemeral_5m_input_tokens":M};
22// a row that wrote nothing says nothing, so the last row that wrote decides.
23export const ttlFromTranscript = (tail: string): Ttl | null => {
24 let found: Ttl | null = null
25 for (const m of tail.matchAll(/"cache_creation":\{([^}]*)\}/g)) {
26 const body = m[1] ?? ''
27 const oneHour = Number(/"ephemeral_1h_input_tokens":(\d+)/.exec(body)?.[1] ?? 0)
28 const fiveMin = Number(/"ephemeral_5m_input_tokens":(\d+)/.exec(body)?.[1] ?? 0)
29 if (oneHour > 0) found = '1h'
30 else if (fiveMin > 0) found = '5m'
31 }
32 return found
33}
34
35// m:ss (a full hour reads 60:00), or h:mm:ss past it.
36export const fmtClock = (ms: number): string => {
37 const total = Math.max(0, Math.ceil(ms / 1000))
38 const h = total > 3600 ? Math.floor(total / 3600) : 0
39 const m = Math.floor((total - h * 3600) / 60)
40 const s = String(total % 60).padStart(2, '0')
41 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`
42}
43
44// "45s", "3m", "1h 5m": how long the cache has been cold.
45export const fmtAgo = (ms: number): string => {
46 const s = Math.max(0, Math.floor(ms / 1000))
47 if (s < 60) return `${s}s`
48 const m = Math.floor(s / 60)
49 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`
50}
51
52export const compact = (tokens: number): string =>
53 tokens >= 1_000_000
54 ? `${+(tokens / 1_000_000).toFixed(1)}M`
55 : tokens >= 1000
56 ? `${Math.round(tokens / 1000)}k`
57 : `${tokens}`
58
59// What the shot clock reads, as an arena clock would: m:ss above a minute,
60// whole seconds below it, tenths in the last ten, 0.0 at the buzzer.
61export const fmtShot = (ms: number): string => {
62 if (ms <= 0) return '0.0'
63 if (ms <= 10_000) return (Math.ceil(ms / 100) / 10).toFixed(1)
64 const total = Math.ceil(ms / 1000)
65 return total >= 60 ? fmtClock(ms) : String(total)
66}
67
68// A three-row seven-segment font in half blocks.
69const FONT: Record<string, readonly [string, string, string]> = {
70 '0': ['█▀█', '█ █', '▀▀▀'],
71 '1': [' █', ' █', ' ▀'],
72 '2': ['▀▀█', '█▀▀', '▀▀▀'],
73 '3': ['▀▀█', ' ▀█', '▀▀▀'],
74 '4': ['█ █', '▀▀█', ' ▀'],
75 '5': ['█▀▀', '▀▀█', '▀▀▀'],
76 '6': ['█▀▀', '█▀█', '▀▀▀'],
77 '7': ['▀▀█', ' █', ' ▀'],
78 '8': ['█▀█', '█▀█', '▀▀▀'],
79 '9': ['█▀█', '▀▀█', '▀▀▀'],
80 ':': [' ', '▀', '▀'],
81 '.': [' ', ' ', '▀'],
82}
83
84// `text` drawn three rows tall, one column between glyphs.
85export const bigDigits = (text: string): [string, string, string] => {
86 const glyphs = [...text].map(ch => FONT[ch] ?? FONT['0']!)
87 return [0, 1, 2].map(row => glyphs.map(g => g[row]).join(' ')) as [string, string, string]
88}
89types/index.d.ts 22 lines1export type Ttl = '5m' | '1h'
2
3// Where the TTL came from: the transcript's cache_creation buckets, the
4// setting, a model switch, a cache hit after a gap only 1h survives, or the
5// 5m guess before any of those.
6export type TtlSource = 'transcript' | 'setting' | 'model-switch' | 'observed' | 'assumed'
7
8export type Clock = {
9 // When the main thread's last request touched the cache (ms since epoch).
10 lastAt: number
11 // Tokens the next request re-sends: what a cold cache writes afresh.
12 prefixTokens: number
13 ttl: Ttl
14 ttlSource: TtlSource
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'cache-shot-clock': { clock: Clock | null; now: number }
20 }
21}
22