Notifies you X minutes before your prompt cache expires so you can send a turn before paying the full re-cache cost.

A Claude Code mod that alerts you X minutes before your prompt cache expires, so you can send a turn before paying the full re-cache cost.
⚠ Cache expires in M:SS — send a turn to refresh band above the prompt when you're inside the warning window.❄ Cache cold — next turn re-caches band after expiry.ENABLE_PROMPT_CACHING_1H)./keepwarm — opt-in keep-warm mode that automatically sends · before the cache expires, resetting the clock without you having to do anything.CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/cache-expiry-notifier"
}
}
Multiple mods: separate paths with :.
/config to confirm the mod is active — you should see cache-expiry-notifier.warnMinutes and cache-expiry-notifier.cacheTtl in the list.Each developer can configure independently via /config inside Claude Code:
| Setting | Default | Description |
|---|---|---|
cache-expiry-notifier.warnMinutes | 10 | Minutes before expiry to fire the alert (min 1) |
cache-expiry-notifier.cacheTtl | auto | Override TTL detection: auto, 5m, or 1h |
cache-expiry-notifier.minTokens | 50000 | Only warn when the session holds at least this many tokens |
Examples:
/config cache-expiry-notifier.warnMinutes=5
/config cache-expiry-notifier.cacheTtl=1h
/config cache-expiry-notifier.minTokens=10000
Run /keepwarm inside any Claude Code session to toggle automatic cache keep-warm on or off. When enabled:
· (a single middle dot) before the cache expires, resetting the clock🔥 keepwarm while active· is queued and sent when the turn finishes/clear or when you start a new session/keepwarm # enable for this session
/keepwarm # run again to disable
Keep-warm is opt-in for a reason. It's designed for when you're actively working and step away briefly. Don't use it to keep a session alive overnight or through a long break — close the laptop and let the cache expire. Re-caching tomorrow is cheap. Your evenings are not.
The mod hooks into three Claude Code plugin events:
turn.step — records lastRequestAt each time a main-thread model response finishes. This is the moment the prompt cache is refreshed.turn.complete — scans the session transcript tail to detect whether the active cache TTL is 5 minutes or 1 hour, and persists it for the next session.ui.render (AbovePrompt) — renders the warning/cold band based on elapsed time since the last request.A $.clock.after timer fires at lastRequestAt + TTL − warnMinutes, triggers the toast and system notification, then drives cascading redraws (every 30 s when > 10 min remain, every 15 s between 2–10 min, every 1 s in the final 2 minutes).
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 for tests)osascript (toast works on all platforms)Run the unit tests:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test /path/to/cache-expiry-notifier
Expected output: 40 tests, 0 failures across ttl-detection, timer-notifications, above-prompt, and keepwarm suites.
hooks/register.tsx 256 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { CacheTtl } from '../types'
5
6const TAIL_BYTES = 1024 * 1024
7const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
8const FAST_BELOW_MS = 2 * 60_000
9const SLOW_STEP_MS = 15_000
10const SLOWER_ABOVE_MS = 10 * 60_000
11const SLOWER_STEP_MS = 30_000
12
13const lastRequestAt = atom({ plugin: 'cache-expiry-notifier', key: 'lastRequestAt' } as const, null as number | null)
14const detectedTtl = atom({ plugin: 'cache-expiry-notifier', key: 'detectedTtl' } as const, null as CacheTtl | null)
15const notifiedAt = atom({ plugin: 'cache-expiry-notifier', key: 'notifiedAt' } as const, null as number | null)
16const keepWarmEnabled = atom({ plugin: 'cache-expiry-notifier', key: 'keepWarmEnabled' } as const, false)
17
18// Module-level timer state — lost on hot reload, re-armed in session.start
19let tick: Timer | undefined
20let armedFor: number | null = null
21
22export const register: Register = (on, options) => {
23 const override: CacheTtl | null =
24 options.cacheTtl === '5m' || options.cacheTtl === '1h' ? options.cacheTtl : null
25 const warnMinutes: number = typeof options.warnMinutes === 'number' ? options.warnMinutes : 10
26 const minTokens: number = typeof options.minTokens === 'number' ? options.minTokens : 50_000
27
28 on('session.start', async ($, e, next) => {
29 const result = await next(e)
30 if ((await read($, detectedTtl)) === null) {
31 const known = await $.store.get('detectedTtl').catch(() => undefined)
32 if (known === '5m' || known === '1h') await update($, detectedTtl, () => known)
33 }
34 const since = await read($, lastRequestAt)
35 if (since !== null) {
36 const now = await $.clock.now()
37 await restartTimer($, since, now, await ttlMsFor($, override), warnMinutes)
38 }
39 if (await read($, keepWarmEnabled)) $.ui.status('🔥 keepwarm')
40 await $.command.register({
41 name: 'keepwarm',
42 description: 'Toggle automatic cache keep-warm for this session. When on, sends · before expiry to reset the clock.',
43 })
44 return result
45 })
46
47 on('session.end', async ($, e, next) => {
48 if (e.reason === 'clear' || e.reason === 'resume') {
49 stop()
50 await update($, lastRequestAt, () => null)
51 await update($, notifiedAt, () => null)
52 await update($, keepWarmEnabled, () => false)
53 }
54 return next(e)
55 })
56
57 on('command.run', { command: 'keepwarm' }, async ($) => {
58 const isOn = !(await read($, keepWarmEnabled))
59 await update($, keepWarmEnabled, () => isOn)
60 $.ui.status(isOn ? '🔥 keepwarm' : '')
61 return {
62 text: isOn
63 ? '🔥 Keep-warm enabled for this session. Will send · before the cache expires to reset the clock. Run /keepwarm again to turn off.'
64 : 'Keep-warm off.',
65 }
66 })
67
68 on('turn.step', async function* ($, e, next) {
69 const result = yield* next(e)
70 if (!e.agentId && result.stopReason !== null) {
71 const now = await $.clock.now()
72 await update($, lastRequestAt, () => now)
73 const { context } = await $.session.usage()
74 if ((context?.tokens ?? 0) >= minTokens) {
75 await restartTimer($, now, now, await ttlMsFor($, override), warnMinutes)
76 } else {
77 stop() // session too small — cancel any running timer
78 }
79 }
80 return result
81 })
82
83 on('turn.complete', async ($, e, next) => {
84 const result = await next(e)
85 if (!e.agentId && override === null) {
86 const found = ttlFromTranscript(await readTail($))
87 if (found !== null) await detectTtl($, found, warnMinutes)
88 }
89 return result
90 })
91
92 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
93 const rest = await next(e)
94 const since = await read($, lastRequestAt)
95 if (since === null) return rest
96 const { context } = await $.session.usage()
97 if ((context?.tokens ?? 0) < minTokens) return rest
98
99 const now = await $.clock.now()
100 const ttlMs = await ttlMsFor($, override)
101 const left = since + ttlMs - now
102 const warnMs = warnMinutes * 60_000
103
104 if (left > warnMs) return rest
105
106 const { Box, Text } = $.ui.resolve(e)
107
108 if (left <= 0) {
109 return (
110 <Box flexDirection="column">
111 <Box paddingX={1}>
112 <Text color="red">{'❄ Cache cold — next turn re-caches'}</Text>
113 </Box>
114 {rest}
115 </Box>
116 ) as any
117 }
118
119 const secs = Math.ceil(left / 1000)
120 const countdown = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
121
122 return (
123 <Box flexDirection="column">
124 <Box paddingX={1}>
125 <Text color="yellow">{`⚠ Cache expires in ${countdown} — send a turn to refresh`}</Text>
126 </Box>
127 {rest}
128 </Box>
129 ) as any
130 })
131}
132
133// --- Module-level helpers (all functions that receive $ must be top-level declarations) ---
134
135function stop() {
136 tick?.cancel()
137 tick = undefined
138 armedFor = null
139}
140
141async function ttlMsFor($: EngineInterface, override: CacheTtl | null): Promise<number> {
142 return TTL_MS[override ?? (await read($, detectedTtl)) ?? '5m']
143}
144
145async function detectTtl($: EngineInterface, ttl: CacheTtl, warnMinutes: number) {
146 // Only called when override === null (guarded at call site), so TTL_MS[ttl] is always correct.
147 if ((await read($, detectedTtl)) === ttl) return
148 await update($, detectedTtl, () => ttl)
149 await $.store.set('detectedTtl', ttl).catch(() => {})
150 const now = await $.clock.now()
151 const since = await read($, lastRequestAt)
152 if (since !== null) await restartTimer($, since, now, TTL_MS[ttl], warnMinutes)
153}
154
155async function restartTimer($: EngineInterface, since: number, now: number, ttlMs: number, warnMinutes: number) {
156 const warnMs = warnMinutes * 60_000
157 const warnAt = since + ttlMs - warnMs
158 const expiryAt = since + ttlMs
159 stop()
160 armedFor = since
161 if (now < warnAt) {
162 tick = $.clock.after(warnAt - now, () => {
163 $.clock.now().then(t => onWarn($, since, t, ttlMs, warnMinutes)).catch(() => {})
164 })
165 } else if (now < expiryAt) {
166 await onWarn($, since, now, ttlMs, warnMinutes)
167 } else {
168 $.ui.invalidate('ui.render')
169 }
170}
171
172async function onWarn($: EngineInterface, since: number, now: number, ttlMs: number, warnMinutes: number) {
173 $.ui.invalidate('ui.render') // immediately show the warning band before anything else
174 const lastNotified = await read($, notifiedAt)
175 if (lastNotified !== null && lastNotified >= since) {
176 arm($, since, now, ttlMs)
177 return
178 }
179 await update($, notifiedAt, () => now)
180
181 const isKeepWarm = await read($, keepWarmEnabled)
182
183 if (isKeepWarm) {
184 $.ui.toast('🔥 Cache expiring — sending · to keep it warm')
185 await $.process.run([
186 'osascript', '-e',
187 'display notification "Sending · to reset the cache clock" with title "Claude Code" subtitle "Cache Keepwarm"',
188 ]).catch(() => {})
189 $.prompt.submit({ text: '·' }).catch(() => {})
190 } else {
191 $.ui.toast(`⚠ Cache expires in ${warnMinutes}m — send a turn to stay warm`)
192 await $.process.run([
193 'osascript', '-e',
194 `display notification "Cache expires in ${warnMinutes} minutes — send a turn to refresh" with title "Claude Code" subtitle "Prompt cache expiring soon"`,
195 ]).catch(() => {})
196 }
197
198 arm($, since, now, ttlMs)
199}
200
201function arm($: EngineInterface, since: number, now: number, ttlMs: number) {
202 if (armedFor !== since) return
203 tick?.cancel()
204 const left = since + ttlMs - now
205 if (left <= 0) {
206 $.ui.invalidate('ui.render')
207 return
208 }
209 const isFast = left <= FAST_BELOW_MS
210 const step = isFast ? 1_000 : left > SLOWER_ABOVE_MS ? SLOWER_STEP_MS : SLOW_STEP_MS
211 tick = $.clock.after(step, () => {
212 $.ui.invalidate('ui.render')
213 $.clock.now().then(t => arm($, since, t, ttlMs)).catch(() => {})
214 })
215}
216
217async function readTail($: EngineInterface): Promise<string> {
218 try {
219 const path = await transcriptPath($)
220 if (path === null) return ''
221 const { size } = await $.fs.stat(path)
222 if (size <= TAIL_BYTES) return await $.fs.read(path)
223 const { exitCode, stdout } = await $.process.run(['tail', '-c', String(TAIL_BYTES), path])
224 return exitCode === 0 ? stdout : ''
225 } catch {
226 return ''
227 }
228}
229
230async function transcriptPath($: EngineInterface): Promise<string | null> {
231 const home = await $.env.get('HOME')
232 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
233 if (!configDir) return null
234 const project = (await $.session.root()).replace(/[^a-zA-Z0-9]/g, '-')
235 return `${configDir}/projects/${project}/${await $.session.id()}.jsonl`
236}
237
238function ttlFromTranscript(text: string): CacheTtl | null {
239 const lines = text.split('\n')
240 for (let i = lines.length - 1; i >= 0; i--) {
241 let entry: unknown
242 try { entry = JSON.parse(lines[i] ?? '') } catch { continue }
243 const msg = entry as {
244 type?: string
245 isSidechain?: boolean
246 message?: { usage?: { cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number } } }
247 }
248 if (msg?.type !== 'assistant' || msg.isSidechain || !msg.message?.usage) continue
249 const written = msg.message.usage.cache_creation
250 if (Number(written?.ephemeral_1h_input_tokens) > 0) return '1h'
251 if (Number(written?.ephemeral_5m_input_tokens) > 0) return '5m'
252 return null
253 }
254 return null
255}
256types/index.d.ts 13 lines1export type CacheTtl = '5m' | '1h'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'cache-expiry-notifier': {
6 lastRequestAt: number | null
7 detectedTtl: CacheTtl | null
8 notifiedAt: number | null
9 keepWarmEnabled: boolean
10 }
11 }
12}
13