SLOPSHOPPER

cache-clock

Band above the prompt (and status line) showing whether this session's prompt cache is still warm

bandtoaststatusprompttimer
v0.2.0no licenseupdated 2026-10-17dmmmwrd/cache-clock
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 ● cache-clock: cache-clock: read 91000, wrote 4300 ⏺ 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 warm 60:00 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cache-clock: cache warm 60:00

Draws

Band
cache warm 60:00
README

cache-clock

A Claude Code mod that shows, in the status line, whether this session's prompt cache is still warm.

  • cache warm 42:17: counts down the 1-hour cache lifetime (main conversation, Claude subscription).
  • cache: in use: a turn is running.
  • cache cold · expired 12m ago · next prompt re-reads 187k tokens uncached
  • A toast 5 minutes before expiry.

The countdown is an estimate from elapsed time, not a query of the server. If you use an API key, a cloud provider, or usage credits, the real lifetime is 5 minutes: set TTL_MS in hooks/register.ts to 5 * 60 * 1000.

Install

Use Claude Code 2.1.299 or later (the desktop app's engine); older claude commands cannot load it.

claude plugin marketplace add dmmmwrd/cache-clock
claude plugin install cache-clock@cache-clock --scope user

In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with dmmmwrd/cache-clock, then Add plugin.

The countdown shows as a band above the prompt (and in the status line where one is drawn).

Source 2 files
hooks/register.tsx 122 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4// Prompt-cache TTL for the main conversation. Per code.claude.com/docs/en/prompt-caching:
5// one hour on a Claude subscription within plan usage; five minutes on an API key, a cloud
6// provider, or once usage credits are being drawn (unless promptCacheTtl is set to "1h").
7// Change to 5 * 60 * 1000 if you are on any of those.
8const TTL_MS = 60 * 60 * 1000
9
10// Toast this long before the cache expires (once per idle stretch).
11const WARN_MS = 5 * 60 * 1000
12
13const band = atom({ plugin: 'cache-clock', key: 'text' } as const, null)
14
15const clock = (ms: number) => {
16  const s = Math.max(0, Math.ceil(ms / 1000))
17  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
18}
19
20const ago = (ms: number) => {
21  const m = Math.floor(ms / 60_000)
22
23  if (m < 1) {
24    return 'just now'
25  }
26
27  return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`
28}
29
30const tokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : `${n}`)
31
32let lastAt = 0
33let isBusy = false
34let warnedFor = 0
35let lastTokens = 0
36
37async function show($: any, text: string) {
38  $.ui.status(text)
39  await update($, band, () => text)
40}
41
42async function draw($: any) {
43  if (isBusy || lastAt === 0) {
44    return
45  }
46
47  const now = await $.clock.now()
48  const left = TTL_MS - (now - lastAt)
49
50  if (left > 0) {
51    await show($, `cache warm ${clock(left)}`)
52
53    if (left <= WARN_MS && warnedFor !== lastAt) {
54      warnedFor = lastAt
55      $.ui.toast(`Prompt cache expires in ${clock(left)}: send a message to keep it warm`)
56    }
57
58    return
59  }
60
61  // Tokens the next prompt re-sends: the last response's context size. Fall back to the
62  // window percentage, then to the figure captured when the turn ended.
63  const { context } = await $.session.usage()
64  const size = context.tokens || Math.round(((context.percent ?? 0) / 100) * context.window) || lastTokens
65  const cost = size ? ` \u00b7 next prompt re-reads ${tokens(size)} tokens uncached` : ' \u00b7 next prompt re-reads your whole context uncached'
66  await show($, `cache cold \u00b7 expired ${ago(-left)}${cost}`)
67}
68
69export const register: Register = on => {
70  let cancel: (() => void) | null = null
71
72  on('prompt.submit', async ($, e, next) => {
73    isBusy = true
74
75    try {
76      await show($, 'cache: in use')
77    } catch {
78      // a display failure must never block the prompt
79    }
80
81    return next(e)
82  })
83
84  on('turn.complete', async ($, e, next) => {
85    // Subagent turns have their own cache; track the main thread only.
86    if (e.agentId === undefined) {
87      isBusy = false
88
89      if (e.usage) {
90        lastAt = await $.clock.now()
91        const { context } = await $.session.usage()
92        lastTokens = context.tokens ?? lastTokens
93        $.ui.log(`cache-clock: read ${e.usage.cache_read_input_tokens}, wrote ${e.usage.cache_creation_input_tokens}`, { to: 'debug' })
94      }
95
96      if (cancel === null) {
97        cancel = $.clock.every(1000, () => draw($))
98      }
99
100      await draw($)
101    }
102
103    return next(e)
104  })
105
106  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107    const text = await read($, band)
108
109    if (e.props.hasSurvey || text === null) {
110      return next(e)
111    }
112
113    const { Box, Text } = $.ui.resolve(e)
114
115    return (
116      <Box>
117        <Text dimColor>{text}</Text>
118      </Box>
119    )
120  })
121}
122
types/index.d.ts 6 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'cache-clock': { text: string | null }
4  }
5}
6