Keeps the prompt cache warm with a cheap ping while the session is idle; reads the real cache lifetime (5m or 1h) and sizes interval and ping budget from it…

Keeps the Claude Code prompt cache warm while the session sits idle, so the next message reads the conversation from cache instead of paying to write it again.
While armed, it watches how long the session has been idle. Shortly before the cache would expire it sends a one-line ping:
Cache keep-alive ping. Reply with the single word "ok" and use no tools.
That ping is a real turn in the transcript. It costs a cache read of the context plus a one-word reply, and it restarts the cache's lifetime. Off by default.
| Command | Does |
|---|---|
/cache-keepalive on | Arm it for this session |
/cache-keepalive off | Disarm it |
/cache-keepalive status | Model, cache lifetime and where it was read from, pings used / budget, spacing |
While armed, it draws a line above the prompt, under cache-gauge's when that mod is installed:
↻ keepalive 3/24 · $0.09 spent · next ping 2:10 · keeps warm ~1h34m
↻ keepalive 24/24 · $0.72 spent · budget used, cache lapses in 4:12
↻ keepalive 3/24 · waits for the turn to end
↻ keepalive 0/24 · cache already cold, nothing to keep warm
spent is what this idle period's pings have cost, measured from the session's total. keeps warm is how long the cache will stay warm if you stay away: the remaining pings plus one cache lifetime after the last. Off, it draws nothing.
Each ping raises a native notification through your terminal's notification channel (the preferredNotifChannel setting), so you see what idling costs even when you're away from the terminal:
Cache keep-alive
Idle: ping 3/24, $0.09 spent with nobody working; keeps the cache warm ~1h34m more. Reply, or /cache-keepalive off.
When the budget runs out, a last notification says so. Set notifyEvery to 3 to hear only every third ping, or to 0 to turn notifications off. The band line still shows the cost either way.
Pinging forever would cost more than letting the cache go cold once. So each idle period has a budget, the smallest of:
(write − read) / read pings, scaled by budgetFactor. On Claude Opus 5.5 with a 5-minute cache that is (1.25 − 0.05) / 0.05 = 24 pings, about 1h48m of idle time at the default spacing.maxPings, a hard cap.maxHours, the longest an idle period is kept warm. This matters for a 1-hour cache, where break-even alone would allow almost a day.When the budget runs out it notifies you once and lets the cache lapse. Any message of your own starts a new idle period with a fresh budget. Its own pings do not. Subagent turns are ignored, since they say nothing about the main conversation's cache. After the machine sleeps past the expiry it sees the cache is already cold and sends nothing.
With ttl: auto it reads the lifetime from Claude Code's own controls, in Claude Code's precedence:
FORCE_PROMPT_CACHING_5M=1 → 5mCLAUDE_CODE_PROMPT_CACHE_TTL (5m / 1h)promptCacheTtl settingENABLE_PROMPT_CACHING_1H=1 → 1h| Option | Default | Meaning |
|---|---|---|
autoStart | false | Arm it when a session starts |
ttl | auto | auto, 5m or 1h (see above) |
intervalPercent | 90 | Idle share of the lifetime before a ping, clamped to 50–95. 90 is 4:30 on a 5m cache |
budgetFactor | 1 | Scales the break-even budget: 0.5 stops at half of it |
maxPings | 40 | Hard cap on pings per idle period |
maxHours | 4 | Hard cap on how long one idle period is kept warm |
notifyEvery | 1 | Raise a notification after every Nth ping; 0 turns them off |
Read prices come from the model id: 0.025× input for Fable and Mythos, 0.05× for Opus 5.5, 0.1× for everything else.
hooks/register.tsx 295 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { KeepaliveView } from '../types'
5
6import { clock, describe, money, plan, resolveTtl } from './budget'
7import type { PlanOptions, Ttl } from './budget'
8
9const NAME = 'cache-keepalive'
10const TICK_MS = 1000
11const view = atom({ plugin: 'cache-keepalive', key: 'view' } as const, { kind: 'off' } as KeepaliveView)
12// Survives a reload, which starts the module over: idle time counts from the last reply, not the reload.
13const lastReply = atom({ plugin: 'cache-keepalive', key: 'lastReply' } as const, 0)
14const PING = 'Cache keep-alive ping. Reply with the single word "ok" and use no tools.'
15
16type Keepalive = {
17 options: PlanOptions
18 override: unknown
19 isEnabled: boolean
20 isBusy: boolean
21 hasWarned: boolean
22 model: string | undefined
23 ttl: Ttl
24 ttlSource: string
25 lastActivity: number
26 pings: number
27 notifyEvery: number
28 /** Session cost when the ping in flight was sent; null while none is. */
29 pingFromUsd: number | null
30 /** What this idle period's pings have cost, from the session total. */
31 spentUsd: number
32}
33
34const number = (value: unknown, fallback: number) =>
35 typeof value === 'number' && Number.isFinite(value) ? value : fallback
36
37// Re-read where Claude Code takes the cache lifetime from, so a changed setting is picked up.
38async function refreshTtl($: EngineInterface, k: Keepalive) {
39 const settings: Record<string, unknown> = await $.settings.read()
40 const resolved = resolveTtl({
41 override: k.override,
42 force5m: await $.env.get('FORCE_PROMPT_CACHING_5M'),
43 env: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
44 setting: settings.promptCacheTtl,
45 enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
46 })
47
48 k.ttl = resolved.ttl
49 k.ttlSource = resolved.source
50}
51
52// A tick redraws the band only when what it shows changed.
53async function show($: EngineInterface, next: KeepaliveView) {
54 const was = await read($, view)
55 if (JSON.stringify(was) !== JSON.stringify(next)) {
56 await update($, view, () => next)
57 }
58}
59
60async function sessionUsd($: EngineInterface): Promise<number | null> {
61 try {
62 return (await $.session.usage({ breakdown: 'summary' })).cost?.usd ?? null
63 } catch {
64 return null
65 }
66}
67
68// A native notification, so the person away from the terminal sees what idling costs.
69async function notify($: EngineInterface, k: Keepalive, text: string) {
70 if (k.notifyEvery <= 0) {
71 return
72 }
73 try {
74 await $.ui.notify(text, { title: 'Cache keep-alive' })
75 } catch {
76 // A Notification hook denied it, or the write failed: the band still shows it.
77 }
78}
79
80// After a ping's turn: add its cost, and say every `notifyEvery` pings what idling has cost.
81async function settlePing($: EngineInterface, k: Keepalive) {
82 const from = k.pingFromUsd
83 k.pingFromUsd = null
84 const to = await sessionUsd($)
85 if (from !== null && to !== null && to > from) {
86 k.spentUsd += to - from
87 }
88 if (k.notifyEvery <= 0 || k.pings % k.notifyEvery !== 0) {
89 return
90 }
91 const p = plan(k.model, k.ttl, k.options)
92 const left = p.limit - k.pings
93 const tail =
94 left > 0
95 ? `keeps the cache warm ~${clock(left * p.intervalMs + p.ttlMs)} more`
96 : `that was the last, the cache lapses in ${clock(p.ttlMs)}`
97 await notify(
98 $,
99 k,
100 `Idle: ping ${k.pings}/${p.limit}, ${money(k.spentUsd)} spent with nobody working; ${tail}. Reply, or /cache-keepalive off.`,
101 )
102}
103
104async function step($: EngineInterface, k: Keepalive) {
105 if (!k.isEnabled) {
106 await show($, { kind: 'off' })
107 return
108 }
109
110 const p = plan(k.model, k.ttl, k.options)
111 const counts = { pings: k.pings, limit: p.limit, spentUsd: k.spentUsd }
112 const idle = (await $.clock.now()) - k.lastActivity
113
114 if (k.isBusy) {
115 await show($, { kind: 'busy', ...counts })
116 return
117 }
118
119 if (idle >= p.ttlMs) {
120 await show($, { kind: 'cold', ...counts })
121 return
122 }
123
124 if (k.pings >= p.limit) {
125 await show($, { kind: 'spent', ...counts, lapsesInMs: p.ttlMs - idle })
126
127 if (!k.hasWarned) {
128 k.hasWarned = true
129 await notify($, k, `Budget used: ${p.limit} pings cost ${money(k.spentUsd)}. The cache lapses in ${clock(p.ttlMs - idle)}.`)
130 }
131
132 return
133 }
134
135 if (idle >= p.intervalMs) {
136 k.pings += 1
137 k.isBusy = true
138 k.pingFromUsd = await sessionUsd($)
139
140 try {
141 await $.prompt.submit({ text: PING })
142 } catch {
143 k.isBusy = false
144 k.pingFromUsd = null
145 }
146
147 return
148 }
149
150 const nextPingMs = p.intervalMs - idle
151 // The last ping still leaves the cache warm for a full lifetime after it.
152 const warmForMs = nextPingMs + (p.limit - k.pings - 1) * p.intervalMs + p.ttlMs
153
154 await show($, { kind: 'waiting', ...counts, nextPingMs, warmForMs })
155}
156
157export const register: Register = (on, options) => {
158 const k: Keepalive = {
159 options: {
160 factor: number(options.budgetFactor, 1),
161 hardCap: number(options.maxPings, 40),
162 maxHours: number(options.maxHours, 4),
163 intervalPercent: number(options.intervalPercent, 90),
164 },
165 override: options.ttl,
166 isEnabled: options.autoStart === true,
167 isBusy: false,
168 hasWarned: false,
169 model: undefined,
170 ttl: '5m',
171 ttlSource: 'default (assumed)',
172 lastActivity: 0,
173 pings: 0,
174 notifyEvery: Math.max(0, Math.floor(number(options.notifyEvery, 1))),
175 pingFromUsd: null,
176 spentUsd: 0,
177 }
178
179 on('session.start', async ($, e, next) => {
180 const held = await read($, lastReply)
181 k.lastActivity = held > 0 ? held : await $.clock.now()
182 await refreshTtl($, k)
183 // Priced from the start: an unknown model falls back to the 0.1x read price,
184 // which shrinks Opus 5.5's break-even budget from 24 pings to 11.
185 try {
186 k.model = (await $.session.usage({ breakdown: 'summary' })).context.breakdown?.model ?? k.model
187 } catch {
188 // Left unknown until the first reply names it.
189 }
190
191 await $.command.register({
192 name: NAME,
193 description: 'Keep the prompt cache warm while idle: /cache-keepalive on | off | status',
194 })
195
196 $.clock.every(TICK_MS, () => void step($, k))
197
198 return next(e)
199 })
200
201 on('turn.start', ($, e, next) => {
202 k.isBusy = true
203
204 return next(e)
205 })
206
207 on('turn.complete', async ($, e, next) => {
208 // A subagent's turn says nothing about the main conversation's cache.
209 if (e.agentId === undefined) {
210 k.isBusy = false
211 k.lastActivity = await $.clock.now()
212 await update($, lastReply, () => k.lastActivity)
213 k.model = e.usage?.model ?? k.model
214 }
215 const result = await next(e)
216 if (e.agentId === undefined && k.pingFromUsd !== null) {
217 await settlePing($, k)
218 }
219
220 return result
221 })
222
223 on('prompt.submit', ($, e, next) => {
224 const isOwnPing = e.origin.kind === 'plugin' && e.origin.name === NAME
225
226 if (!isOwnPing) {
227 k.pings = 0
228 k.spentUsd = 0
229 k.hasWarned = false
230 }
231
232 return next(e)
233 })
234
235 on('command.run', { command: NAME }, async ($, e) => {
236 const arg = e.args.trim().toLowerCase()
237
238 if (arg === 'on') {
239 await refreshTtl($, k)
240 k.isEnabled = true
241 k.pings = 0
242 k.spentUsd = 0
243 k.hasWarned = false
244 await step($, k)
245
246 const p = plan(k.model, k.ttl, k.options)
247
248 return {
249 text: `Cache keep-alive armed: ${p.ttl} cache (${k.ttlSource}), up to ${p.limit} pings, one every ${clock(p.intervalMs)}.`,
250 }
251 }
252
253 if (arg === 'off') {
254 k.isEnabled = false
255 await show($, { kind: 'off' })
256
257 return { text: 'Cache keep-alive off.' }
258 }
259
260 await refreshTtl($, k)
261 const p = plan(k.model, k.ttl, k.options)
262
263 return {
264 text: `Cache keep-alive ${k.isEnabled ? 'armed' : 'off'}: model ${k.model ?? 'unknown yet'}, ${p.ttl} cache (${k.ttlSource}), ${k.pings}/${p.limit} pings this idle period (${money(k.spentUsd)}), one every ${clock(p.intervalMs)}.`,
265 }
266 })
267
268 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
269 const v = await read($, view)
270 if (e.props.hasSurvey || v.kind === 'off') {
271 return next(e)
272 }
273
274 const { Box, Text } = $.ui.resolve(e)
275 const line = describe(v)
276 const mine = (
277 <Box>
278 <Text color={line.color} dimColor={line.color === undefined}>↻ </Text>
279 <Text dimColor>{line.text}</Text>
280 </Box>
281 )
282 // Wrap, not replace: another mod's line (cache-gauge) stays in the band.
283 const below = await next(e)
284
285 return below ? (
286 <Box flexDirection="column">
287 {below}
288 {mine}
289 </Box>
290 ) : (
291 mine
292 )
293 })
294}
295hooks/budget.ts 91 lines1import type { KeepaliveView } from '../types'
2
3export type Ttl = '5m' | '1h'
4
5const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
6
7// Cache-write price as a multiple of the base input price.
8const WRITE: Record<Ttl, number> = { '5m': 1.25, '1h': 2 }
9
10export type PlanOptions = {
11 factor: number
12 hardCap: number
13 maxHours: number
14 intervalPercent: number
15}
16
17export type Plan = { ttl: Ttl; ttlMs: number; intervalMs: number; limit: number }
18
19const isTtl = (value: unknown): value is Ttl => value === '5m' || value === '1h'
20
21// Cache-read price as a fraction of the base input price, by model id.
22export function readRatio(model: string | undefined): number {
23 const id = (model ?? '').toLowerCase()
24
25 if (id.includes('fable') || id.includes('mythos')) return 0.025
26 if (id.includes('opus-5-5') || id.includes('opus-5.5')) return 0.05
27
28 return 0.1
29}
30
31// The cache lifetime Claude Code requests, by its documented precedence. It cannot
32// see the billing type, so the unset default is the safe 5m: a 1h cache just gets
33// pinged more often than it needs.
34export function resolveTtl(input: {
35 override?: unknown
36 force5m?: string
37 env?: string
38 setting?: unknown
39 enable1h?: string
40}): { ttl: Ttl; source: string } {
41 if (isTtl(input.override)) return { ttl: input.override, source: 'mod option' }
42 if (input.force5m === '1') return { ttl: '5m', source: 'FORCE_PROMPT_CACHING_5M' }
43 if (isTtl(input.env)) return { ttl: input.env, source: 'CLAUDE_CODE_PROMPT_CACHE_TTL' }
44 if (isTtl(input.setting)) return { ttl: input.setting, source: 'promptCacheTtl setting' }
45 if (input.enable1h === '1') return { ttl: '1h', source: 'ENABLE_PROMPT_CACHING_1H' }
46
47 return { ttl: '5m', source: 'default (assumed)' }
48}
49
50// Pings are spaced at a share of the lifetime, and capped where a lapsed cache would
51// have been cheaper: one miss costs (write - read) x context, one ping read x context.
52export function plan(model: string | undefined, ttl: Ttl, options: PlanOptions): Plan {
53 const ratio = readRatio(model)
54 const ttlMs = TTL_MS[ttl]
55 const percent = Math.min(95, Math.max(50, options.intervalPercent))
56 const intervalMs = Math.round((ttlMs * percent) / 100)
57 // The epsilon absorbs float error (1.2 / 0.05 is 23.999...).
58 const breakEven = Math.floor(((WRITE[ttl] - ratio) / ratio) * options.factor + 1e-9)
59 const byTime = Math.floor((options.maxHours * 3_600_000) / intervalMs)
60
61 return { ttl, ttlMs, intervalMs, limit: Math.max(1, Math.min(breakEven, options.hardCap, byTime)) }
62}
63
64export function clock(ms: number): string {
65 const total = Math.max(0, Math.round(ms / 1000))
66 const minutes = Math.floor(total / 60)
67 const seconds = String(total % 60).padStart(2, '0')
68
69 if (minutes >= 60) return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
70
71 return `${minutes}:${seconds}`
72}
73
74export const money = (usd: number): string => (usd < 0.01 ? `$${usd.toFixed(3)}` : `$${usd.toFixed(2)}`)
75
76/** The band line for an armed keep-alive: glyph colour and text. */
77export function describe(v: Exclude<KeepaliveView, { kind: 'off' }>): { color: 'success' | 'warning' | undefined; text: string } {
78 const head = `keepalive ${v.pings}/${v.limit}${v.spentUsd > 0 ? ` · ${money(v.spentUsd)} spent` : ''}`
79
80 switch (v.kind) {
81 case 'waiting':
82 return { color: 'success', text: `${head} · next ping ${clock(v.nextPingMs)} · keeps warm ~${clock(v.warmForMs)}` }
83 case 'spent':
84 return { color: 'warning', text: `${head} · budget used, cache lapses in ${clock(v.lapsesInMs)}` }
85 case 'busy':
86 return { color: 'success', text: `${head} · waits for the turn to end` }
87 case 'cold':
88 return { color: undefined, text: `${head} · cache already cold, nothing to keep warm` }
89 }
90}
91types/index.d.ts 13 lines1/** What the band line shows; `off` draws nothing. */
2export type KeepaliveView =
3 | { kind: 'off' }
4 | { kind: 'busy' | 'cold'; pings: number; limit: number; spentUsd: number }
5 | { kind: 'spent'; pings: number; limit: number; spentUsd: number; lapsesInMs: number }
6 | { kind: 'waiting'; pings: number; limit: number; spentUsd: number; nextPingMs: number; warmForMs: number }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'cache-keepalive': { view: KeepaliveView; lastReply: number }
11 }
12}
13