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).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 |
Examples:
/config cache-expiry-notifier.warnMinutes=5
/config cache-expiry-notifier.cacheTtl=1h
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: 32 tests, 0 failures across ttl-detection, timer-notifications, and above-prompt suites.
hooks/register.tsx 217 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)
16
17// Module-level timer state — lost on hot reload, re-armed in session.start
18let tick: Timer | undefined
19let armedFor: number | null = null
20
21export const register: Register = (on, options) => {
22 const override: CacheTtl | null =
23 options.cacheTtl === '5m' || options.cacheTtl === '1h' ? options.cacheTtl : null
24 const warnMinutes: number = typeof options.warnMinutes === 'number' ? options.warnMinutes : 10
25
26 on('session.start', async ($, e, next) => {
27 const result = await next(e)
28 if ((await read($, detectedTtl)) === null) {
29 const known = await $.store.get('detectedTtl').catch(() => undefined)
30 if (known === '5m' || known === '1h') await update($, detectedTtl, () => known)
31 }
32 const since = await read($, lastRequestAt)
33 if (since !== null) {
34 const now = await $.clock.now()
35 await restartTimer($, since, now, await ttlMsFor($, override), warnMinutes)
36 }
37 return result
38 })
39
40 on('session.end', async ($, e, next) => {
41 if (e.reason === 'clear' || e.reason === 'resume') {
42 stop()
43 await update($, lastRequestAt, () => null)
44 await update($, notifiedAt, () => null)
45 }
46 return next(e)
47 })
48
49 on('turn.step', async function* ($, e, next) {
50 const result = yield* next(e)
51 if (!e.agentId && result.stopReason !== null) {
52 const now = await $.clock.now()
53 await update($, lastRequestAt, () => now)
54 await restartTimer($, now, now, await ttlMsFor($, override), warnMinutes)
55 }
56 return result
57 })
58
59 on('turn.complete', async ($, e, next) => {
60 const result = await next(e)
61 if (!e.agentId && override === null) {
62 const found = ttlFromTranscript(await readTail($))
63 if (found !== null) await detectTtl($, found, warnMinutes)
64 }
65 return result
66 })
67
68 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
69 const rest = await next(e)
70 const since = await read($, lastRequestAt)
71 if (since === null) return rest
72
73 const now = await $.clock.now()
74 const ttlMs = await ttlMsFor($, override)
75 const left = since + ttlMs - now
76 const warnMs = warnMinutes * 60_000
77
78 if (left > warnMs) return rest
79
80 const { Box, Text } = $.ui.resolve(e)
81
82 if (left <= 0) {
83 return (
84 <Box flexDirection="column">
85 <Box paddingX={1}>
86 <Text color="red">{'❄ Cache cold — next turn re-caches'}</Text>
87 </Box>
88 {rest}
89 </Box>
90 ) as any
91 }
92
93 const secs = Math.ceil(left / 1000)
94 const countdown = `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
95
96 return (
97 <Box flexDirection="column">
98 <Box paddingX={1}>
99 <Text color="yellow">{`⚠ Cache expires in ${countdown} — send a turn to refresh`}</Text>
100 </Box>
101 {rest}
102 </Box>
103 ) as any
104 })
105}
106
107// --- Module-level helpers (all functions that receive $ must be top-level declarations) ---
108
109function stop() {
110 tick?.cancel()
111 tick = undefined
112 armedFor = null
113}
114
115async function ttlMsFor($: EngineInterface, override: CacheTtl | null): Promise<number> {
116 return TTL_MS[override ?? (await read($, detectedTtl)) ?? '5m']
117}
118
119async function detectTtl($: EngineInterface, ttl: CacheTtl, warnMinutes: number) {
120 // Only called when override === null (guarded at call site), so TTL_MS[ttl] is always correct.
121 if ((await read($, detectedTtl)) === ttl) return
122 await update($, detectedTtl, () => ttl)
123 await $.store.set('detectedTtl', ttl).catch(() => {})
124 const now = await $.clock.now()
125 const since = await read($, lastRequestAt)
126 if (since !== null) await restartTimer($, since, now, TTL_MS[ttl], warnMinutes)
127}
128
129async function restartTimer($: EngineInterface, since: number, now: number, ttlMs: number, warnMinutes: number) {
130 const warnMs = warnMinutes * 60_000
131 const warnAt = since + ttlMs - warnMs
132 const expiryAt = since + ttlMs
133 stop()
134 armedFor = since
135 if (now < warnAt) {
136 tick = $.clock.after(warnAt - now, () => {
137 $.clock.now().then(t => onWarn($, since, t, ttlMs, warnMinutes)).catch(() => {})
138 })
139 } else if (now < expiryAt) {
140 await onWarn($, since, now, ttlMs, warnMinutes)
141 } else {
142 $.ui.invalidate('ui.render')
143 }
144}
145
146async function onWarn($: EngineInterface, since: number, now: number, ttlMs: number, warnMinutes: number) {
147 $.ui.invalidate('ui.render') // immediately show the warning band before anything else
148 const lastNotified = await read($, notifiedAt)
149 if (lastNotified !== null && lastNotified >= since) {
150 arm($, since, now, ttlMs)
151 return
152 }
153 await update($, notifiedAt, () => now)
154 $.ui.toast(`⚠ Cache expires in ${warnMinutes}m — send a turn to stay warm`)
155 await $.process.run([
156 'osascript', '-e',
157 `display notification "Cache expires in ${warnMinutes} minutes — send a turn to refresh" with title "Claude Code" subtitle "Prompt cache expiring soon"`,
158 ]).catch(() => {})
159 arm($, since, now, ttlMs)
160}
161
162function arm($: EngineInterface, since: number, now: number, ttlMs: number) {
163 if (armedFor !== since) return
164 tick?.cancel()
165 const left = since + ttlMs - now
166 if (left <= 0) {
167 $.ui.invalidate('ui.render')
168 return
169 }
170 const isFast = left <= FAST_BELOW_MS
171 const step = isFast ? 1_000 : left > SLOWER_ABOVE_MS ? SLOWER_STEP_MS : SLOW_STEP_MS
172 tick = $.clock.after(step, () => {
173 $.ui.invalidate('ui.render')
174 $.clock.now().then(t => arm($, since, t, ttlMs)).catch(() => {})
175 })
176}
177
178async function readTail($: EngineInterface): Promise<string> {
179 try {
180 const path = await transcriptPath($)
181 if (path === null) return ''
182 const { size } = await $.fs.stat(path)
183 if (size <= TAIL_BYTES) return await $.fs.read(path)
184 const { exitCode, stdout } = await $.process.run(['tail', '-c', String(TAIL_BYTES), path])
185 return exitCode === 0 ? stdout : ''
186 } catch {
187 return ''
188 }
189}
190
191async function transcriptPath($: EngineInterface): Promise<string | null> {
192 const home = await $.env.get('HOME')
193 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
194 if (!configDir) return null
195 const project = (await $.session.root()).replace(/[^a-zA-Z0-9]/g, '-')
196 return `${configDir}/projects/${project}/${await $.session.id()}.jsonl`
197}
198
199function ttlFromTranscript(text: string): CacheTtl | null {
200 const lines = text.split('\n')
201 for (let i = lines.length - 1; i >= 0; i--) {
202 let entry: unknown
203 try { entry = JSON.parse(lines[i] ?? '') } catch { continue }
204 const msg = entry as {
205 type?: string
206 isSidechain?: boolean
207 message?: { usage?: { cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number } } }
208 }
209 if (msg?.type !== 'assistant' || msg.isSidechain || !msg.message?.usage) continue
210 const written = msg.message.usage.cache_creation
211 if (Number(written?.ephemeral_1h_input_tokens) > 0) return '1h'
212 if (Number(written?.ephemeral_5m_input_tokens) > 0) return '5m'
213 return null
214 }
215 return null
216}
217types/index.d.ts 12 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 }
10 }
11}
12