Shows how long the prompt cache stays warm as a countdown above the prompt, with an optional keep-warm mode.

A Claude Code mod that shows how long your prompt cache stays warm, as a countdown above the prompt. It also has an optional keep-warm mode that refreshes the cache before it expires.
Claude Code resends your whole conversation on every request. While the prompt cache is warm, the API serves most of it from cache at a fraction of the cost and latency. Once the cache expires, your next prompt pays for the whole context again. The mod shows how much time is left, so you know whether to reply now or accept a cold start.
Above the prompt:
cache warm 4:32 ████████░░░░ 5m API [Keep warm: off]
cache cold, because the next prompt re-bills your whole context./keepwarm on and /keepwarm off do the same.The cache lives for a different time depending on how you pay.
The setting ttl overrides this with 5m or 1h. If you set ENABLE_PROMPT_CACHING_1H in your environment, auto uses one hour. If you set FORCE_PROMPT_CACHING_5M, auto uses five minutes. Check your own setup if the lifetime shown looks wrong, and set ttl explicitly.
When keep-warm is on, the mod sends one tiny request shortly before the cache expires. That request reads your cached context, which restarts the lifetime.
It is not free. Each request is a cache read of your whole context, billed at the cache-read rate, and on a subscription it counts against your usage limits. So it has guards:
maxPings requests in a row with no activity from you. The default is 6./plugin install cache-clock --marketplace leowei31/cache-clock-mod
Then start a new session.
| Setting | Default | Meaning |
|---|---|---|
ttl | auto | auto, 5m or 1h |
keepWarm | off | Start each session with keep-warm on |
marginSeconds | 60 | How long before expiry the request is sent |
maxPings | 6 | Most keep-warm requests in a row |
/clear starts a new cache, and the countdown restarts with the next request.Run claude plugin test ./cache-clock to run the tests. Run claude plugin validate ./cache-clock to validate the manifest.
hooks/register.tsx 222 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Plan } from '../types'
5
6const MINUTE = 60_000
7const TTL_5M = 5 * MINUTE
8const TTL_1H = 60 * MINUTE
9const BAR_WIDTH = 12
10const PING_PROMPT = 'Reply with a single period and nothing else.'
11
12const lastTouch = atom({ plugin: 'cache-clock', key: 'lastTouch' } as const, 0)
13const ttlMs = atom({ plugin: 'cache-clock', key: 'ttlMs' } as const, TTL_5M)
14const plan = atom({ plugin: 'cache-clock', key: 'plan' } as const, 'api' as Plan)
15const isKeepWarm = atom({ plugin: 'cache-clock', key: 'isKeepWarm' } as const, false)
16const pings = atom({ plugin: 'cache-clock', key: 'pings' } as const, 0)
17const note = atom({ plugin: 'cache-clock', key: 'note' } as const, '')
18const tick = atom({ plugin: 'cache-clock', key: 'tick' } as const, 0)
19
20// auto: a subscription caches for an hour, an API key for five minutes, unless the environment says otherwise.
21const resolveTtl = (mode: string, force1h: boolean, force5m: boolean, isSubscription: boolean) => {
22 if (mode === '5m') {
23 return TTL_5M
24 }
25
26 if (mode === '1h') {
27 return TTL_1H
28 }
29
30 if (force5m) {
31 return TTL_5M
32 }
33
34 return force1h || isSubscription ? TTL_1H : TTL_5M
35}
36
37const clock = (ms: number) => {
38 const total = Math.max(0, Math.ceil(ms / 1000))
39 const hours = Math.floor(total / 3600)
40 const minutes = Math.floor((total % 3600) / 60)
41 const rest = String(total % 60).padStart(2, '0')
42
43 return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${rest}` : `${minutes}:${rest}`
44}
45
46const bar = (fraction: number) => {
47 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * BAR_WIDTH)
48
49 return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
50}
51
52const ttlLabel = (ms: number) => (ms >= TTL_1H ? '1h' : '5m')
53
54export const register: Register = (on, options) => {
55 const mode = String(options.ttl ?? 'auto')
56 const margin = Math.max(5, Number(options.marginSeconds ?? 60)) * 1000
57 const maxPings = Math.max(1, Number(options.maxPings ?? 6))
58 let isBusy = false
59 let isPinging = false
60
61 on('session.start', async ($, e, next) => {
62 await $.command.register({
63 name: 'keepwarm',
64 description: 'Turn keep-warm on or off for the prompt cache',
65 argumentHint: '[on | off]',
66 })
67 await update($, isKeepWarm, () => options.keepWarm === true)
68
69 $.clock.every(1000, async () => {
70 const now = await $.clock.now()
71 const touched = await read($, lastTouch)
72
73 if (touched === 0) {
74 return
75 }
76
77 const ttl = await read($, ttlMs)
78 const left = touched + ttl - now
79
80 if (left > -2000) {
81 await update($, tick, n => n + 1)
82 }
83
84 const wantsPing = (await read($, isKeepWarm)) && !isBusy && !isPinging
85 const count = await read($, pings)
86
87 if (!wantsPing || left <= 0 || left > margin) {
88 return
89 }
90
91 if (count >= maxPings) {
92 await update($, isKeepWarm, () => false)
93 await update($, note, () => `keep-warm stopped after ${maxPings} requests`)
94
95 return
96 }
97
98 isPinging = true
99 const reply = await $.model.fork({ prompt: PING_PROMPT })
100 isPinging = false
101
102 if (reply.isAnswered && reply.usage.cache_read_input_tokens > 0) {
103 const done = await $.clock.now()
104 await update($, lastTouch, () => done)
105 await update($, pings, n => n + 1)
106 await update($, note, () => '')
107
108 return
109 }
110
111 await update($, isKeepWarm, () => false)
112 await update($, note, () => 'keep-warm stopped: the request did not hit the cache')
113 })
114
115 return next(e)
116 })
117
118 on('command.run', { command: 'keepwarm' }, async ($, e) => {
119 const arg = e.args.trim()
120 const isOn = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isKeepWarm))
121 await update($, isKeepWarm, () => isOn)
122 await update($, pings, () => 0)
123 await update($, note, () => '')
124
125 return { text: isOn ? 'Keep-warm on.' : 'Keep-warm off.' }
126 })
127
128 on('turn.start', async ($, e, next) => {
129 if (e.agentId === undefined) {
130 isBusy = true
131 }
132
133 return next(e)
134 })
135
136 on('turn.complete', async ($, e, next) => {
137 if (e.agentId === undefined) {
138 isBusy = false
139 }
140
141 return next(e)
142 })
143
144 // Every main-thread request that reads or writes the cache restarts its lifetime.
145 on('turn.step', async function* ($, e, next) {
146 const out = yield* next(e)
147 const usage = out.usage
148
149 if (e.agentId === undefined && usage && (usage.cache_read_input_tokens > 0 || usage.cache_creation_input_tokens > 0)) {
150 const now = await $.clock.now()
151 const limits = await $.session.usage()
152 const isSubscription = limits.rateLimits.length > 0
153 const force1h = (await $.env.get('ENABLE_PROMPT_CACHING_1H')) !== undefined
154 const force5m = (await $.env.get('FORCE_PROMPT_CACHING_5M')) !== undefined
155
156 await update($, lastTouch, () => now)
157 await update($, ttlMs, () => resolveTtl(mode, force1h, force5m, isSubscription))
158 await update($, plan, () => (isSubscription ? 'subscription' : 'api'))
159 await update($, pings, () => 0)
160 await update($, note, () => '')
161 }
162
163 return out
164 })
165
166 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
167 const touched = await read($, lastTouch)
168
169 if (e.props.hasSurvey || touched === 0) {
170 return next(e)
171 }
172
173 await read($, tick)
174 const { Box, Button, Text } = $.ui.resolve(e)
175 const now = await $.clock.now()
176 const ttl = await read($, ttlMs)
177 const left = touched + ttl - now
178 const kind = await read($, plan)
179 const warm = await read($, isKeepWarm)
180 const count = await read($, pings)
181 const message = await read($, note)
182 const toggle = (
183 <Button
184 key="keepwarm"
185 label={warm ? 'Keep warm: on' : 'Keep warm: off'}
186 onPress={() => {
187 void update($, isKeepWarm, () => !warm)
188 void update($, pings, () => 0)
189 void update($, note, () => '')
190 }}
191 />
192 )
193
194 if (left <= 0) {
195 return (
196 <Box gap={1}>
197 <Text color="red">cache cold</Text>
198 <Text dimColor>the next prompt re-bills your whole context</Text>
199 {toggle}
200 </Box>
201 )
202 }
203
204 const fraction = left / ttl
205 const color = fraction > 0.5 ? 'green' : fraction > 0.2 ? 'yellow' : 'red'
206
207 return (
208 <Box gap={1}>
209 <Text color={color}>
210 cache warm {clock(left)} {bar(fraction)}
211 </Text>
212 <Text dimColor>
213 {ttlLabel(ttl)} {kind === 'subscription' ? 'subscription' : 'API'}
214 {warm ? ` pings ${count}/${maxPings}` : ''}
215 </Text>
216 {message !== '' && <Text color="yellow">{message}</Text>}
217 {toggle}
218 </Box>
219 )
220 })
221}
222types/index.d.ts 16 lines1export type Plan = 'subscription' | 'api'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'cache-clock': {
6 lastTouch: number
7 ttlMs: number
8 plan: Plan
9 isKeepWarm: boolean
10 pings: number
11 note: string
12 tick: number
13 }
14 }
15}
16