SLOPSHOPPER

cache-clock

A one-line band above the prompt: context used, and a countdown until the prompt cache goes cold

newbandcommandtoastpromptprocess
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 › /cache-clock ⎿ cache-clock: cache: no request yet (1h ttl assumed) | ctx 97k/200k 49% ▰▰▰▰▰▱▱▱▱▱ ◷ cache: no request yet · ctx 97k/200k 49% ▰▰▰▰▰▱▱▱▱▱ · 1h ttl assumed ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◷ cache: no request yet · ctx 97k/200k 49% ▰▰▰▰▰▱▱▱▱▱ · 1h ttl assumed
README

cache-clock

A Claude Code mod that puts one line above the prompt: how long until the prompt cache goes cold, how full the context window is, and a separate clock for subagents while one is warm.

cache-clock: the band above the Claude Code prompt, showing 59:08 left on the cache, context at 54k of 1M, a subagent clock and the measured 1h TTL

Every request to the model writes the conversation to the prompt cache. If the next request arrives before the cache expires, everything so far is read back at a fraction of the price. If it arrives after, the whole context is sent again at full price. Claude Code never shows you which side of that line you are on. This does.

  • The countdown restarts on every model request. Subagent requests run their own sub clock.
  • It turns yellow in the last quarter and red near the end, and pops a toast when the warning time is reached (optionally with a sound).
  • At zero it says cache cold, and a toast tells you the next message is a full-price write.
  • The TTL is read from the session itself: the API records whether each cache write was a 1 hour or a 5 minute one, and the band says measured once it has seen one.
  • At the warning, if Claude is idle and you haven't replied, it acts while the cache is still warm. You pick what it does in the plugin's settings (onWarning):
  • handoff (the default): Claude writes a handoff note, HANDOFF-<first 8 characters of the session id>.md, in the project root, in case you don't come back. Each session has its own file, so parallel sessions in one folder don't overwrite each other.
  • compact: the session compacts, as /compact does. The summary request reads the warm cache, so it is cheap, and your next message after the cache goes cold re-sends a few thousand tokens instead of the whole context.
  • off: just the toast.

It runs once per idle stretch and re-arms when you send a message.

  • /cache-clock prints the same line as text.

The handoff is the only thing it sends to Claude: one short turn on a warm cache. Compact sends no turn; it is the same compaction /compact runs.

Install

claude plugin marketplace add ray-amjad/claude-code-mods
claude plugin install cache-clock@claude-code-mods

Or inside Claude Code: /plugin marketplace add ray-amjad/claude-code-mods, then /plugin install cache-clock@claude-code-mods.

Settings

onWarning is a plugin setting: set it with /plugin configure cache-clock@claude-code-mods. The rest are optional environment variables. Minutes may be fractional.

| Variable | Default | What it does | | :- | :- | :- | | CACHE_CLOCK_MAIN_TTL_MIN | 60 | Main session TTL. Setting it pins it; otherwise the mod reads it from the transcript. | | CACHE_CLOCK_SUB_TTL_MIN | 5 | Subagent TTL | | CACHE_CLOCK_WARN_MIN | 5 | Toast, the onWarning action and the colour red when this much is left | | CACHE_CLOCK_ON_WARNING | the setting | handoff, compact or off; overrides the plugin setting | | CACHE_CLOCK_AUTO_HANDOFF | on | 0 means off (kept from older versions) | | CACHE_CLOCK_HANDOFF_DIR | project root | Folder the handoff notes go to | | CACHE_CLOCK_SOUND | off | 1 plays sounds/cooling.wav with the warning toast |

To watch the whole cycle in two minutes:

CACHE_CLOCK_MAIN_TTL_MIN=2 CACHE_CLOCK_WARN_MIN=1.5 claude

How it works

  • Every turn.step (one per model request) restarts the main clock, or the sub clock when the step carries an agentId. A one second $.clock.every redraws the band and checks the warning.
  • After each turn it reads the end of the session transcript for the newest main-thread usage.cache_creation: tokens in ephemeral_1h mean 1 hour, ephemeral_5m mean 5 minutes. On --resume it seeds the clock from the session's seconds_since_last_response.
  • Context comes from $.session.usage() at start, then the pushed session.measure figures.
  • The warning action: when the warning fires, no turn is running (turn.start/turn.complete) and nothing has run since your last prompt, handoff calls $.prompt.submit and compact calls $.session.compact, then reads the smaller context back from $.session.usage(). A prompt.submit hook re-arms it on your next prompt.

Limits

  • The clock counts from when each request was sent, so it can run a few seconds conservative.
  • A message sent while the band says cold still works; it is just a full-price cache write.
  • If none of the last 300 transcript lines wrote to the cache, the TTL keeps its previous or default value and is labelled assumed.
  • State lives in memory, so after a hot reload the clock waits for the next turn.
  • A later handoff in the same session overwrites that session's note, and it fires even if you have a draft typed but not sent (the draft stays in the box).
  • In a narrow terminal the line drops pieces from the right (the TTL label, then sub, then ctx) instead of wrapping.

Develop

claude --plugin-dir ./cache-clock
claude plugin validate .
claude plugin test .
Source 1 files
hooks/register.js 234 lines
1// cache-clock: one line above the prompt that shows how full the context is
2// and how long until the prompt cache goes cold.
3
4// Defaults, in minutes. Each can be changed with an environment variable.
5const DEFAULT_MAIN_TTL_MIN = 60 // main session cache (matches what the transcripts record)
6const DEFAULT_SUB_TTL_MIN = 5 // subagent cache
7const DEFAULT_WARN_MIN = 5 // toast (and the onWarning action) when this much is left
8
9const MIN = 60_000
10
11// State shared by the hooks below
12const cfg = { mainTtl: DEFAULT_MAIN_TTL_MIN * MIN, subTtl: DEFAULT_SUB_TTL_MIN * MIN, warn: DEFAULT_WARN_MIN * MIN, sound: false, pinned: false, onWarning: 'handoff', handoffDir: '' }
13let ttlSource = 'assumed' // 'measured' once we read it from the transcript
14let mainAt = 0 // when the main session last sent a request (ms)
15let subAt = 0 // when a subagent last sent one
16let warned = false // toast already shown for this warm period
17let wentCold = false // "went cold" already shown
18let working = false // a turn is running
19let handedOff = false // handoff or compaction done since the user last sent something
20let compacted = null // { before, after } tokens, when the warning compacted the session
21let context = null // { tokens, window, percent } from $.session.usage()
22let transcript = null // path to this session's .jsonl
23
24// Read a number of minutes from an env var, or keep the default
25function minutes(value, fallback) {
26  const n = parseFloat(value ?? '')
27  return Number.isFinite(n) && n > 0 ? n * MIN : fallback
28}
29
30const ACTIONS = ['handoff', 'compact', 'off']
31
32// What the warning does: the env var, then the old CACHE_CLOCK_AUTO_HANDOFF=0, then the setting
33export function pickAction(envAction, envAutoHandoff, setting) {
34  const action = envAction ?? (envAutoHandoff === '0' ? 'off' : setting)
35  return ACTIONS.includes(action) ? action : 'handoff'
36}
37
38async function loadConfig($, options) {
39  const main = await $.env.get('CACHE_CLOCK_MAIN_TTL_MIN')
40  cfg.mainTtl = minutes(main, DEFAULT_MAIN_TTL_MIN * MIN)
41  cfg.pinned = main !== undefined // a value you set wins over the transcript
42  cfg.subTtl = minutes(await $.env.get('CACHE_CLOCK_SUB_TTL_MIN'), DEFAULT_SUB_TTL_MIN * MIN)
43  cfg.warn = minutes(await $.env.get('CACHE_CLOCK_WARN_MIN'), DEFAULT_WARN_MIN * MIN)
44  cfg.sound = (await $.env.get('CACHE_CLOCK_SOUND')) === '1'
45  cfg.handoffDir = (await $.env.get('CACHE_CLOCK_HANDOFF_DIR')) ?? ''
46  // The plugin setting, unless an env var overrides it. CACHE_CLOCK_AUTO_HANDOFF=0 still means off.
47  cfg.onWarning = pickAction(await $.env.get('CACHE_CLOCK_ON_WARNING'), await $.env.get('CACHE_CLOCK_AUTO_HANDOFF'), options?.onWarning)
48}
49
50// The real TTL: each assistant row in the transcript says which cache bucket
51// it wrote to (ephemeral_1h or ephemeral_5m). Read the newest main-thread one.
52async function readTtl($) {
53  if (!transcript || cfg.pinned) return
54  const { stdout } = await $.process.run(['tail', '-n', '300', transcript])
55  const rows = stdout.split('\n').reverse()
56  for (const row of rows) {
57    if (!row.includes('"cache_creation"')) continue
58    let msg
59    try { msg = JSON.parse(row) } catch { continue }
60    if (msg.type !== 'assistant' || msg.isSidechain) continue
61    const cc = msg.message?.usage?.cache_creation ?? {}
62    if (cc.ephemeral_1h_input_tokens > 0) { cfg.mainTtl = 60 * MIN; ttlSource = 'measured'; return }
63    if (cc.ephemeral_5m_input_tokens > 0) { cfg.mainTtl = 5 * MIN; ttlSource = 'measured'; return }
64    // a pure cache read does not say which bucket; look further back
65  }
66}
67
68// 47:05 or 3:09
69function clock(ms) {
70  const s = Math.max(0, Math.ceil(ms / 1000))
71  return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
72}
73
74function fmtTokens(n) {
75  if (n >= 1_000_000) return +(n / 1_000_000).toFixed(1) + 'M'
76  return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
77}
78
79// Everything the band shows, as plain pieces so /cache-clock can print it too
80function snapshot(now) {
81  const left = mainAt ? mainAt + cfg.mainTtl - now : 0
82  const subLeft = subAt ? subAt + cfg.subTtl - now : 0
83  // Colour: green, then yellow in the last quarter, red inside the warning window
84  let color = 'green'
85  if (!mainAt || left <= 0) color = 'gray'
86  else if (left <= cfg.warn) color = 'red'
87  else if (left <= cfg.mainTtl / 4) color = 'yellow'
88  const ttl = (cfg.mainTtl >= 60 * MIN ? cfg.mainTtl / (60 * MIN) + 'h' : cfg.mainTtl / MIN + 'm') + ' ttl ' + (cfg.pinned ? 'set' : ttlSource)
89  const cache = !mainAt ? 'cache: no request yet' : left > 0 ? 'cache ' + clock(left) + ' left' : 'cache cold'
90  let ctx = 'ctx --'
91  if (context?.tokens) {
92    const pct = context.percent ?? Math.round((context.tokens / context.window) * 100)
93    const filled = Math.round(pct / 10)
94    ctx = 'ctx ' + fmtTokens(context.tokens) + '/' + fmtTokens(context.window) + ' ' + pct + '% ' + '▰'.repeat(filled) + '▱'.repeat(10 - filled)
95  }
96  const sub = subLeft > 0 ? 'sub ' + clock(subLeft) : null
97  return { color, cache, ttl, ctx, sub, left }
98}
99
100function asText(s) {
101  return [s.cache + ' (' + s.ttl + ')', s.ctx, s.sub].filter(Boolean).join('  |  ')
102}
103
104// The handoff prompt. The marker lets prompt.submit tell it from the user's own prompts.
105const HANDOFF_MARKER = 'cache-clock handoff'
106
107// One note per session, so parallel sessions in one folder never overwrite
108// each other's. It goes to CACHE_CLOCK_HANDOFF_DIR, else the project root
109// (not the shell's current folder, which a `cd` can move into a nested repo).
110export function handoffPath(dir, sessionId) {
111  return dir.replace(/\/+$/, '') + '/HANDOFF-' + String(sessionId).slice(0, 8) + '.md'
112}
113function handoffText(file) {
114  return '(' + HANDOFF_MARKER + ') The prompt cache goes cold soon and the user has not replied. While the cache is still warm, write a short handoff note to ' + file + ': the goal, what is done, what is next, open questions, and the key files. Keep it brief, and write it even if the session has been short. Do nothing else. A fresh session will start from it if the user does not come back.'
115}
116
117// Compact while the cache is warm: the summary request reads the cached
118// conversation, and the next message after the cache goes cold re-sends only
119// the summary instead of the whole context.
120const COMPACT_INSTRUCTIONS = 'The user stepped away. Keep the goal, what is done, what is next, open questions, and the key files, so the work can pick up from here.'
121async function compactWarm($, left) {
122  $.ui.toast('Prompt cache goes cold in ' + clock(left) + '. No reply, so the session is compacting while it is warm.', { timeoutMs: 10_000 })
123  try {
124    const result = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS })
125    if (result.skip) return $.ui.toast('Compaction skipped: ' + result.skip)
126    compacted = { before: result.tokensBefore ?? context?.tokens ?? 0, after: result.tokensAfter ?? 0 }
127    context = (await $.session.usage()).context ?? context
128    $.ui.toast('Compacted ' + fmtTokens(compacted.before) + ' to ' + fmtTokens(compacted.after) + ' tokens while the cache was warm.', { timeoutMs: 10_000 })
129  } catch (err) {
130    handedOff = false // a turn started in the meantime; try again next warning
131  }
132}
133
134// Once a second: warn near expiry, note when it goes cold, redraw the band
135async function tick($) {
136  const s = snapshot(await $.clock.now())
137  if (mainAt && s.left > 0 && s.left <= cfg.warn && !warned) {
138    warned = true
139    // Idle and nothing done since the user's last message: act while the cache is warm
140    if (cfg.onWarning !== 'off' && !working && !handedOff) {
141      handedOff = true
142      if (cfg.onWarning === 'compact') compactWarm($, s.left)
143      else {
144        const file = handoffPath(cfg.handoffDir || (await $.session.root()), await $.session.id())
145        $.ui.toast('Prompt cache goes cold in ' + clock(s.left) + '. No reply, so Claude is writing ' + file.split('/').pop() + ' while it is warm.', { timeoutMs: 10_000 })
146        $.prompt.submit({ text: handoffText(file) }).catch(() => {}) // waits for idle; don't await in the tick
147      }
148    } else {
149      $.ui.toast('Prompt cache goes cold in ' + clock(s.left) + '. Send something to keep it warm.', { timeoutMs: 10_000 })
150    }
151    if (cfg.sound) $.audio.play({ asset: 'sounds/cooling.wav' }).catch(() => {})
152  }
153  if (mainAt && s.left <= 0 && !wentCold) {
154    wentCold = true
155    $.ui.toast(compacted
156      ? 'Prompt cache is cold, but the session was compacted to ' + fmtTokens(compacted.after) + ', so the next message is a small write.'
157      : 'Prompt cache is cold. The next message re-sends the whole context at full price.')
158  }
159  $.ui.invalidate('ui.render')
160}
161
162export function register(on, options) {
163  on('session.start', async ($, e, next) => {
164    await loadConfig($, options)
165    context = (await $.session.usage()).context
166    await $.command.register({ name: 'cache-clock', description: 'Print the cache countdown and context use' })
167    $.clock.every(1000, () => tick($))
168    return next(e)
169  })
170
171  // On a resume, the session tells us how long ago the last answer was,
172  // so the clock picks up where it left off
173  on('classic.SessionStart', async ($, e, next) => {
174    transcript = e.transcript_path ?? transcript
175    if (e.seconds_since_last_response != null) mainAt = (await $.clock.now()) - e.seconds_since_last_response * 1000
176    await readTtl($)
177    return next(e)
178  }).catch(($, e, next) => (next.called ? undefined : next(e))) // never block the session on our error
179
180  // Track whether a turn is running, so the warning action never queues behind one
181  on('turn.start', async ($, e, next) => { working = true; return next(e) })
182  on('turn.complete', async ($, e, next) => { working = false; return next(e) })
183
184  // A prompt from the user (not our own handoff) re-arms the warning action
185  on('prompt.submit', async ($, e, next) => {
186    if (!String(e.text ?? '').includes(HANDOFF_MARKER)) { handedOff = false; compacted = null }
187    return next(e)
188  })
189
190  // Every request to the model refreshes its cache: restart the right clock
191  on('turn.step', async function* ($, e, next) {
192    const now = await $.clock.now()
193    if (e.agentId) subAt = now
194    else { mainAt = now; warned = false; wentCold = false }
195    yield* next(e)
196  })
197
198  // After each turn, check which TTL the API actually used
199  on('classic.Stop', async ($, e, next) => {
200    transcript = e.transcript_path ?? transcript
201    await readTtl($)
202    return next(e)
203  }).catch(($, e, next) => (next.called ? undefined : next(e)))
204
205  // Context figures, pushed after each turn
206  on('session.measure', async ($, e, next) => {
207    context = e.context
208    return next(e)
209  })
210
211  // The band itself: one line above the prompt
212  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
213    if (e.props.hasSurvey) return next(e)
214    const { Box, Text } = $.ui.resolve(e)
215    const s = snapshot(await $.clock.now())
216    // Pieces in order of importance; drop from the end until it fits one line
217    const pieces = [
218      { text: '◷ ' + s.cache, style: { color: s.color, bold: s.color === 'red' } },
219      { text: s.ctx, style: {} },
220      s.sub && { text: s.sub, style: { dimColor: true } },
221      { text: s.ttl, style: { dimColor: true } },
222    ].filter(Boolean)
223    const SEP = '  ·  '
224    while (pieces.length > 1 && pieces.map((p) => p.text).join(SEP).length > e.props.bodyColumns) pieces.pop()
225    const parts = pieces.flatMap((p, i) => i ? [h(Text, { dimColor: true }, SEP), h(Text, p.style, p.text)] : [h(Text, p.style, p.text)])
226    return h(Box, {}, h(Text, { wrap: 'truncate-end' }, ...parts))
227  })
228
229  on('command.run', { command: 'cache-clock' }, async ($) => {
230    await readTtl($)
231    return { text: asText(snapshot(await $.clock.now())) }
232  })
233}
234