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

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 uncachedThe 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.
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).
hooks/register.tsx 122 lines1import { 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}
122types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'cache-clock': { text: string | null }
4 }
5}
6