Shows how long the prompt cache stays warm and refreshes it just before it expires

A Claude Code plugin marketplace with my mods and plugins.
/plugin marketplace add CatraMyBeloved/catras-claude-code-collection
/plugin install agent-comic@catras-claude-code-collection
/plugin install cache-keepalive@catras-claude-code-collection
The repo is private, so git must be authenticated (e.g. gh auth login). If the SSH attempt fails, set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1.
A pixel comic in the band above the prompt: a little Claude acts out what the agent is doing.
hub_add, deferred, so it costs no context until used); the hub keeps six. /comic-hub lists them./comic-pet: he lights up, and a headpat is sent as your message ("Here, have a headpat. You are doing amazing!"), which Claude answers.hybrid (default) has the model set up the session's world and stage each turn's opening and wrap-up in it, and plays canned scenes in between; full stages everything with the model; off uses no tokens at all. Set it with /config, along with the model, pace and whether the comic stays up between turns.$.comic (a garden, a pond, a minigame). Between turns, wooden signs in the hub's top corners lead to them (a/d once the band has focus); while Claude works the comic takes the band back. With no place installed nothing changes. How to build one: EXTENDING.md; a complete example is pond-place.Commands: /comic (on/off), /comic-pet, /comic-hub, /comic-stats, /comic-feel <mood>, /comic-demo.
Keeps Claude Code's prompt cache warm while you think, so the next prompt reads the conversation from the cache instead of paying to write it again.
cache ▰▰▰▰▰▰▱▱ 41:12 (1h), in calm tones: sage green, sand yellow for the last 10 minutes, dusty rose for the last 3 (a 5-minute cache keeps the same proportions). The timer restarts with every request of the main conversation.auto follows Claude Code's own rules: FORCE_PROMPT_CACHING_5M, then CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting and ENABLE_PROMPT_CACHING_1H; otherwise 1 hour on a subscription within plan limits and 5 minutes on an API key, a cloud provider or usage credits. The mod cannot always tell when a subscription draws on credits; set the lifetime to 5m in /config then./keepalive opens a pane with the bar, the expiry time, the cached prefix size, a timeline of hits, misses and keep-alives, and buttons to ping now (p) or pause (k).Commands: /keepalive (gauge), /keepalive status, /keepalive on|off, /keepalive now. Set the mode (prompt or off), lifetime, lead and idle cutoff with /config.
Why a message and not an invisible side request: a side request ($.model.fork) re-sends the conversation, but Claude Code caches it as a separate entry, so it never kept the main conversation's cache warm in testing.
claude plugin validate .
claude plugin test plugins/cache-keepalive
claude plugin test examples/pond-place
examples/ holds example plugins that are not in the marketplace; copy one as a starting point.
CACHE_KEEPALIVE_HEADLESS=1 lets cache-keepalive run under claude -p --plugin-dir plugins/cache-keepalive, for checking its cache behaviour without an interactive session.
GPL-3.0. Copyright (c) 2026 Ole Stein.
hooks/register.tsx 304 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CacheEvent, Ttl } from '../types'
5import type { Verdict } from './keepalive'
6import {
7 ACK_PROMPT,
8 CALM,
9 TTL_MS,
10 bar,
11 decide,
12 emptyClock,
13 formatLeft,
14 formatTokens,
15 gaugeColor,
16 resolveTtl,
17 statusText,
18 withEvent,
19} from './keepalive'
20
21const PLUGIN = 'cache-keepalive'
22const PANE = 'cache-keepalive'
23const clock = atom({ plugin: 'cache-keepalive', key: 'clock' } as const, emptyClock)
24
25type Mode = 'prompt' | 'off'
26
27/** A queued acknowledgement prompt holds further asks this long while its turn gets going. */
28const ASK_HOLD_MS = 60_000
29
30/** Per load: what a reload loses here is re-learned from the next request. */
31type Live = {
32 mode: Mode
33 leadMs: number
34 maxIdleMs: number
35 configuredTtl: string
36 requests: number
37 isTurnRunning: boolean
38 isPinging: boolean
39 /** When the last prompt keep-alive was queued; its turn's request has not landed yet. */
40 askedAt: number | null
41 isCachingOff: boolean
42 ttlSources: { force5m?: string; envTtl?: string; settingsTtl?: unknown; enable1h?: string }
43}
44
45const EVENT_LOOK: Record<CacheEvent, { glyph: string; color: string }> = {
46 hit: { glyph: '●', color: CALM.green },
47 miss: { glyph: '●', color: CALM.red },
48 ping: { glyph: '◆', color: CALM.blue },
49}
50
51async function currentTtl($: EngineInterface, live: Live): Promise<Ttl> {
52 const { rateLimits } = await $.session.usage()
53 return resolveTtl({ configured: live.configuredTtl, rateLimits, ...live.ttlSources })
54}
55
56async function ping($: EngineInterface, live: Live): Promise<void> {
57 // Two ticks can both decide to ping before either starts: the second stands down.
58 if (live.isPinging) return
59 live.isPinging = true
60 try {
61 const startedAt = await $.clock.now()
62 live.askedAt = startedAt
63 await update($, clock, c => ({
64 ...c,
65 pings: c.pings + 1,
66 lastPing: `${new Date(startedAt).toLocaleTimeString()}: asked Claude for an acknowledgement`,
67 history: withEvent(c.history, 'ping'),
68 }))
69 // Its turn's request reads the cache and restarts the clock through the turn.step hook.
70 await $.prompt.submit({ text: ACK_PROMPT })
71 } finally {
72 live.isPinging = false
73 }
74}
75
76async function assess($: EngineInterface, live: Live): Promise<{ verdict: Verdict; ttl: Ttl }> {
77 const [now, c, ttl] = await Promise.all([$.clock.now(), read($, clock), currentTtl($, live)])
78 const verdict = decide({
79 clock: c,
80 now,
81 ttlMs: TTL_MS[ttl],
82 // A lead as long as the lifetime would ping right after every request, the acknowledgement's own included.
83 leadMs: Math.min(live.leadMs, TTL_MS[ttl] / 2),
84 maxIdleMs: live.maxIdleMs,
85 isRequesting: live.requests > 0,
86 isTurnRunning: live.isTurnRunning,
87 mode: live.mode,
88 isPinging: live.isPinging || (live.askedAt !== null && now - live.askedAt < ASK_HOLD_MS),
89 })
90 return { verdict, ttl }
91}
92
93async function tick($: EngineInterface, live: Live): Promise<void> {
94 if (live.isCachingOff) return
95 const { verdict } = await assess($, live)
96 // The footer bar and the pane read the clock, not state: redraw them each second.
97 $.ui.invalidate('ui.render')
98 if (verdict.kind === 'ping') await ping($, live)
99}
100
101async function statusLines($: EngineInterface, live: Live): Promise<string> {
102 const [now, c, ttl] = await Promise.all([$.clock.now(), read($, clock), currentTtl($, live)])
103 const left = c.refreshedAt === null ? null : c.refreshedAt + TTL_MS[ttl] - now
104 const lines = [
105 live.isCachingOff ? 'Prompt caching is disabled (DISABLE_PROMPT_CACHING).' : null,
106 `TTL: ${ttl}${live.configuredTtl === 'auto' ? ' (auto)' : ''} · keep-alive: ${live.mode === 'off' ? 'off' : 'prompt'} · lead: ${live.leadMs / 1000}s`,
107 left === null ? 'Cache: no request yet' : left > 0 ? `Cache: warm, ${formatLeft(left)} left` : 'Cache: cold',
108 `Keep-alive: ${c.isPaused ? 'paused' : 'on'} · ${c.pings} ping(s) since your last prompt`,
109 c.lastPing ? `Last ping: ${c.lastPing}` : null,
110 ]
111 return lines.filter(Boolean).join('\n')
112}
113
114export const register: Register = (on, options) => {
115 const live: Live = {
116 mode: options.mode === 'off' ? 'off' : 'prompt',
117 leadMs: Math.max(5, Number(options.leadSeconds) || 20) * 1000,
118 maxIdleMs: Math.max(0, Number(options.maxIdleMinutes) || 0) * 60_000,
119 configuredTtl: String(options.ttl ?? 'auto'),
120 requests: 0,
121 isTurnRunning: false,
122 isPinging: false,
123 askedAt: null,
124 isCachingOff: false,
125 ttlSources: {},
126 }
127
128 on('session.start', async ($, e, next) => {
129 const result = await next(e)
130 // Headless runs (claude -p) only for testing the mod itself.
131 if (!e.isInteractive && (await $.env.get('CACHE_KEEPALIVE_HEADLESS')) !== '1') return result
132
133 const [force5m, envTtl, enable1h, disabled, settings] = await Promise.all([
134 $.env.get('FORCE_PROMPT_CACHING_5M'),
135 $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
136 $.env.get('ENABLE_PROMPT_CACHING_1H'),
137 $.env.get('DISABLE_PROMPT_CACHING'),
138 $.settings.read(),
139 ])
140 live.ttlSources = { force5m, envTtl, enable1h, settingsTtl: (settings as Record<string, unknown>).promptCacheTtl }
141 live.isCachingOff = disabled !== undefined && disabled !== '' && disabled !== '0'
142 // State outlives a reload: give a value an older version wrote the fields this one added.
143 // An activity time from the start, so the idle cutoff holds even if the person never prompts.
144 const startedAt = await $.clock.now()
145 await update($, clock, c => ({ ...emptyClock, ...c, activeAt: c.activeAt ?? startedAt }))
146 // The engine draws plugin status lines in its own color; the bar lives in the footer instead.
147 $.ui.status(undefined)
148
149 await $.command.register({
150 name: 'keepalive',
151 description: 'Prompt cache keep-alive: open the gauge, or status, on, off, now',
152 argumentHint: '[status|on|off|now]',
153 })
154 $.clock.every(1000, () => {
155 tick($, live).catch(err => $.ui.log(`keep-alive tick failed: ${String(err)}`, { to: 'debug' }))
156 })
157
158 return result
159 })
160
161 on('session.end', async ($, e, next) => {
162 if (e.reason === 'clear') await update($, clock, c => ({ ...emptyClock, isPaused: c.isPaused }))
163 return next(e)
164 })
165
166 on('prompt.submit', async ($, e, next) => {
167 const isOurs = e.origin.kind === 'plugin' && e.origin.name === PLUGIN
168 if (!isOurs) {
169 const now = await $.clock.now()
170 await update($, clock, c => ({ ...c, activeAt: now, pings: 0 }))
171 }
172 return next(e)
173 })
174
175 on('turn.start', async ($, e, next) => {
176 live.isTurnRunning = true
177 return next(e)
178 })
179
180 on('turn.complete', async ($, e, next) => {
181 if (e.agentId === undefined) live.isTurnRunning = false
182 return next(e)
183 })
184
185 // Every main-thread request reads and extends the cache: its start is when the lifetime restarts.
186 on('turn.step', async function* ($, e, next) {
187 if (e.agentId !== undefined) return yield* next(e)
188 const startedAt = await $.clock.now()
189 live.requests += 1
190 live.askedAt = null
191 try {
192 const result = yield* next(e)
193 const usage = result.usage
194 if (usage !== null) {
195 const { cache_read_input_tokens: hit, cache_creation_input_tokens: wrote } = usage
196 const isCached = hit > 0 || wrote > 0
197 await update($, clock, c => ({
198 ...c,
199 refreshedAt: isCached ? Math.max(c.refreshedAt ?? 0, startedAt) : null,
200 cachedTokens: hit + wrote,
201 // A request that mostly wrote found the prefix cold: a miss.
202 history: withEvent(c.history, hit >= wrote ? 'hit' : 'miss'),
203 }))
204 }
205 return result
206 } finally {
207 live.requests -= 1
208 }
209 })
210
211 on('command.run', { command: 'keepalive' }, async ($, e) => {
212 const arg = e.args.trim().toLowerCase()
213 if (arg === 'on' || arg === 'off') {
214 await update($, clock, c => ({ ...c, isPaused: arg === 'off' }))
215 return { text: `Cache keep-alive ${arg === 'off' ? 'paused' : 'resumed'}.` }
216 }
217 if (arg === 'now') {
218 if (live.isPinging) return { text: 'A keep-alive is already running.' }
219 await ping($, live)
220 return { text: `Keep-alive sent. ${(await read($, clock)).lastPing ?? ''}`.trim() }
221 }
222 if (arg === 'status') return { text: await statusLines($, live) }
223
224 const opened = await $.ui.open({ id: PANE, title: 'Prompt cache' })
225 return { text: opened.isPlaced ? 'Prompt cache gauge opened.' : await statusLines($, live) }
226 })
227
228 // The mode labels at the right of the prompt footer: kept, with the cache bar beside them in a calm tone.
229 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
230 if (live.isCachingOff) return next(e)
231 const { verdict, ttl } = await assess($, live)
232 const label = statusText(verdict, ttl)
233 if (label === undefined) return next(e)
234 const { Box, Text } = $.ui.resolve(e)
235 const color = verdict.kind === 'cold' ? CALM.red : verdict.kind === 'unknown' ? CALM.green : gaugeColor(verdict.leftMs, TTL_MS[ttl])
236
237 return (
238 <Box flexDirection="row">
239 {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')} · </Text>}
240 <Text color={color}>{label}</Text>
241 </Box>
242 )
243 })
244
245 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
246 const { Box, Text, Button } = $.ui.resolve(e)
247 const [now, c, ttl] = await Promise.all([$.clock.now(), read($, clock), currentTtl($, live)])
248 const ttlMs = TTL_MS[ttl]
249 const left = c.refreshedAt === null ? null : c.refreshedAt + ttlMs - now
250 const width = Math.max(10, Math.min(60, (e.props.bodyColumns ?? 40) - 2))
251 const color = gaugeColor(left ?? 0, ttlMs)
252 const state = live.isCachingOff
253 ? 'caching disabled'
254 : left === null
255 ? 'waiting for the first request'
256 : left <= 0
257 ? 'cold: the next request re-writes it'
258 : `warm · ${formatLeft(left)} left`
259 const expires = left !== null && left > 0 ? new Date(now + left).toLocaleTimeString() : null
260 const keepalive = c.isPaused
261 ? 'paused'
262 : live.mode === 'off'
263 ? 'off (timer only)'
264 : `on · asks ${live.leadMs / 1000}s before expiry, once Claude is idle`
265
266 return (
267 <Box flexDirection="column">
268 <Text bold>{state}</Text>
269 <Text color={left !== null && left > 0 ? color : 'inactive'}>{bar(left ?? 0, ttlMs, width, '█', '░')}</Text>
270 <Text dimColor>{`TTL ${ttl}${live.configuredTtl === 'auto' ? ' (auto)' : ''}${expires ? ` · expires ${expires}` : ''}`}</Text>
271 <Text> </Text>
272 <Text>
273 cached prefix <Text bold>{formatTokens(c.cachedTokens)}</Text> tokens
274 </Text>
275 <Text>
276 keep-alive <Text bold>{keepalive}</Text>
277 </Text>
278 <Text dimColor>{c.pings} ping(s) since your last prompt</Text>
279 {c.lastPing && <Text dimColor wrap="truncate-end">last: {c.lastPing}</Text>}
280 <Text> </Text>
281 <Text dimColor>recent requests</Text>
282 <Text>
283 {c.history.length === 0 ? <Text dimColor>none yet</Text> : c.history.slice(-width).map(ev => (
284 <Text color={EVENT_LOOK[ev].color}>{EVENT_LOOK[ev].glyph}</Text>
285 ))}
286 </Text>
287 <Text dimColor>
288 <Text color={CALM.green}>●</Text> hit <Text color={CALM.red}>●</Text> miss <Text color={CALM.blue}>◆</Text> keep-alive
289 </Text>
290 <Text> </Text>
291 <Box flexDirection="row" gap={1}>
292 <Button key="now" hotkey="p" label="Ping now" onPress={() => void ping($, live)} />
293 <Button
294 key="pause"
295 hotkey="k"
296 label={c.isPaused ? 'Resume' : 'Pause'}
297 onPress={() => update($, clock, s => ({ ...s, isPaused: !s.isPaused }))}
298 />
299 </Box>
300 </Box>
301 )
302 })
303}
304hooks/keepalive.ts 130 lines1import type { CacheClock, CacheEvent, Ttl } from '../types'
2
3export const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
4
5export const ACK_PROMPT =
6 '[cache keep-alive] Automated ping to keep the prompt cache warm. ' +
7 'Respond with only a minimal acknowledgement (one word, e.g. "ok"). ' +
8 'Do not use tools and do not continue any previous task.'
9
10export const emptyClock: CacheClock = {
11 refreshedAt: null,
12 activeAt: null,
13 pings: 0,
14 isPaused: false,
15 lastPing: null,
16 cachedTokens: 0,
17 history: [],
18}
19
20export const withEvent = (history: readonly CacheEvent[], event: CacheEvent): CacheEvent[] => [...history, event].slice(-40)
21
22/** A bar of `width` cells, filled by the share of the lifetime left. */
23export function bar(leftMs: number, ttlMs: number, width: number, full = '▰', empty = '▱'): string {
24 const filled = Math.round(Math.min(1, Math.max(0, leftMs / ttlMs)) * width)
25 return full.repeat(filled) + empty.repeat(width - filled)
26}
27
28/** Muted tones that read on dark and light themes alike: sage, sand, dusty rose, slate blue. */
29export const CALM = { green: '#8DB596', yellow: '#D4BE84', red: '#C98C8C', blue: '#8FA8C2' } as const
30
31/**
32 * The gauge's tone by time left: green, then yellow for the last 10 minutes,
33 * red for the last 3; a shorter lifetime keeps the same proportions of an hour.
34 */
35export function gaugeColor(leftMs: number, ttlMs: number): string {
36 const scale = Math.min(1, ttlMs / TTL_MS['1h'])
37 if (leftMs <= 3 * 60_000 * scale) return CALM.red
38 if (leftMs <= 10 * 60_000 * scale) return CALM.yellow
39 return CALM.green
40}
41
42export function formatTokens(n: number): string {
43 return n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : String(n)
44}
45
46export type TtlSources = {
47 configured: string
48 force5m?: string
49 envTtl?: string
50 settingsTtl?: unknown
51 enable1h?: string
52 /** The rate-limit windows the last response reported; empty off a subscription. */
53 rateLimits: readonly { kind: string; percentUsed: number }[]
54}
55
56const isTtl = (v: unknown): v is Ttl => v === '5m' || v === '1h'
57const isOn = (v?: string) => v !== undefined && v !== '' && v !== '0' && v !== 'false'
58
59/** Claude Code's own precedence: FORCE_5M > env > setting > ENABLE_1H > default by account. */
60export function resolveTtl(s: TtlSources): Ttl {
61 if (isTtl(s.configured)) return s.configured
62 if (isOn(s.force5m)) return '5m'
63 if (isTtl(s.envTtl)) return s.envTtl
64 if (isTtl(s.settingsTtl)) return s.settingsTtl
65 if (isOn(s.enable1h)) return '1h'
66 const plan = s.rateLimits.filter(r => r.kind === 'five_hour' || r.kind === 'seven_day')
67 // A subscription gets 1h within plan usage; once it draws on credits (a window past 100) it drops to 5m.
68 return plan.length > 0 && plan.every(r => r.percentUsed < 100) ? '1h' : '5m'
69}
70
71export type Situation = {
72 clock: CacheClock
73 now: number
74 ttlMs: number
75 leadMs: number
76 maxIdleMs: number
77 /** A main-thread request is in flight right now (it refreshes the cache itself). */
78 isRequesting: boolean
79 /** A turn is running; a keep-alive prompt would only queue behind it. */
80 isTurnRunning: boolean
81 mode: 'prompt' | 'off'
82 isPinging: boolean
83}
84
85export type Verdict =
86 | { kind: 'unknown' }
87 | { kind: 'cold' }
88 | { kind: 'warm'; leftMs: number; held?: 'paused' | 'idle' | 'off' }
89 | { kind: 'ping'; leftMs: number }
90
91export function decide(s: Situation): Verdict {
92 if (s.clock.refreshedAt === null) return { kind: 'unknown' }
93 const leftMs = s.clock.refreshedAt + s.ttlMs - s.now
94 if (leftMs <= 0) return { kind: 'cold' }
95 if (s.mode === 'off') return { kind: 'warm', leftMs, held: 'off' }
96 if (s.clock.isPaused) return { kind: 'warm', leftMs, held: 'paused' }
97 const isIdleTooLong =
98 s.maxIdleMs > 0 && s.clock.activeAt !== null && s.now - s.clock.activeAt >= s.maxIdleMs
99 if (isIdleTooLong) return { kind: 'warm', leftMs, held: 'idle' }
100 // A prompt only queues behind a running turn, and the turn's own requests refresh the cache.
101 const isBlocked = s.isPinging || s.isRequesting || s.isTurnRunning
102 if (leftMs <= s.leadMs && !isBlocked) return { kind: 'ping', leftMs }
103
104 return { kind: 'warm', leftMs }
105}
106
107export function formatLeft(ms: number): string {
108 const total = Math.max(0, Math.ceil(ms / 1000))
109 const h = Math.floor(total / 3600)
110 const m = Math.floor((total % 3600) / 60)
111 const sec = String(total % 60).padStart(2, '0')
112
113 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
114}
115
116export function statusText(v: Verdict, ttl: Ttl): string | undefined {
117 switch (v.kind) {
118 case 'unknown':
119 return undefined
120 case 'cold':
121 return `cache ${bar(0, 1, 8)} cold (${ttl})`
122 case 'ping':
123 return `cache ${bar(v.leftMs, TTL_MS[ttl], 8)} ${formatLeft(v.leftMs)} · refreshing…`
124 case 'warm': {
125 const held = v.held === 'idle' ? ' · idle, letting it lapse' : v.held === 'paused' ? ' · keep-alive off' : ''
126 return `cache ${bar(v.leftMs, TTL_MS[ttl], 8)} ${formatLeft(v.leftMs)} (${ttl})${held}`
127 }
128 }
129}
130types/index.d.ts 28 lines1export type Ttl = '5m' | '1h'
2
3/** One main-thread request or keep-alive, as the pane's timeline draws it. */
4export type CacheEvent = 'hit' | 'miss' | 'ping'
5
6export type CacheClock = {
7 /** When the last request that read or wrote the main cache started (clock ms). */
8 refreshedAt: number | null
9 /** When the person last prompted (clock ms). */
10 activeAt: number | null
11 /** Keep-alives sent since the person last prompted. */
12 pings: number
13 /** Paused with /keepalive off. */
14 isPaused: boolean
15 /** What the last keep-alive came to, for /keepalive. */
16 lastPing: string | null
17 /** Tokens the last main-thread request read from or wrote to the cache: the warm prefix. */
18 cachedTokens: number
19 /** The last requests and keep-alives, oldest first, at most 40. */
20 history: CacheEvent[]
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'cache-keepalive': { clock: CacheClock }
26 }
27}
28