SLOPSHOPPER

cache-shot-clock

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…

newbandspinnertoastprocesstimer
★ 1v0.2.0no licenseupdated 2026-10-02cjavdev/mods/cache-shot-clock
A shopper browsing a rack in a slop shop
README

cache-shot-clock

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.

A buzzer-beater as the prompt cache's shot clock runs out: the arena clock and the terminal's LED clock count down together, then SHOT CLOCK VIOLATION

<sub>The terminal half is a real capture of the mod counting down to the buzzer. The player is fictional.</sub>

What it shows

Most of the time, just a small clock at the end of the bar under the prompt:

The 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:

The big LED shot clock above the prompt

At zero, the buzzer:

SHOT CLOCK VIOLATION, with how many tokens the next turn re-writes

Ten seconds later it folds back into the bar as ⏱ 00:00, until your next turn restarts it.

  • The clock restarts on every model request of the main conversation. A request that reads the cache refreshes the TTL just like one that writes it. Subagent requests don't move it, since they cache their own prefixes.
  • The TTL is 5 minutes or 1 hour. The mod reads it from the session transcript: each response records whether its cache write went to the 5-minute or the 1-hour bucket. Until the first turn ends it assumes 5 minutes.
  • The token count on the big clock is what the next request re-sends. On a cold cache, all of it is written again at the cache-write rate (1.25× base input for 5m, 2× for 1h).
  • A toast warns you when an idle cache has 60 seconds left.

A model switch, /compact or /clear resets the clock, because the new prefix has nothing cached yet.

Install

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.

Settings

In /config, or under pluginConfigs["cache-shot-clock"].options in settings:

OptionDefault
enabledtrueTurns the clock off without unloading the mod.
bigAt15Seconds left when the big LED clock appears. 0 keeps the clock in the bar.
ttlautoauto, 5m or 1h. Forces the countdown length instead of reading it from the transcript.
warnSeconds60When to toast before an idle cache expires. 0 turns the toast off.
testTtlSeconds0For testing: count down from this many seconds instead of the real TTL. 0 is off.

Test it without waiting an hour

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.

How it works

hooks/register.tsx:

  • A turn.step hook reads each main-thread response's usage and restarts the clock.
  • A model.fork hook restarts it when another mod's fork reads the main prefix, such as cache-saver.
  • A classic.Stop hook tails the transcript after each turn and reads the newest cache_creation bucket to find the TTL.
  • A one-second $.clock.every tick redraws the clock and raises the warning. The last ten seconds tick every 100ms.
  • A ui.render hook on PromptHint adds the small clock as the hint line's tail.
  • A 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.

Develop

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.

Source 3 files
hooks/register.tsx 281 lines
1import { 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}
281
hooks/clock.ts 89 lines
1// 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}
89
types/index.d.ts 22 lines
1export 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