SLOPSHOPPER

cache-expiry-notifier

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

newbandtoastprocesstimer
A shopper browsing a rack in a slop shop
README

cache-expiry-notifier

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.

What it does

  • Shows a yellow ⚠ Cache expires in M:SS — send a turn to refresh band above the prompt when you're inside the warning window.
  • Shows a red ❄ Cache cold — next turn re-caches band after expiry.
  • Fires an in-app toast and a macOS system notification the moment you enter the warning window.
  • Detects whether your instance uses 5-minute or 1-hour prompt caching automatically (no manual setup needed once you're on ENABLE_PROMPT_CACHING_1H).

Installation

  1. Clone or copy this folder somewhere on disk.
  2. Add it to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json:
   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/cache-expiry-notifier"
     }
   }

Multiple mods: separate paths with :.

  1. Start a new Claude Code session. Run /config to confirm the mod is active — you should see cache-expiry-notifier.warnMinutes and cache-expiry-notifier.cacheTtl in the list.

Configuration

Each developer can configure independently via /config inside Claude Code:

SettingDefaultDescription
cache-expiry-notifier.warnMinutes10Minutes before expiry to fire the alert (min 1)
cache-expiry-notifier.cacheTtlautoOverride TTL detection: auto, 5m, or 1h

Examples:

/config cache-expiry-notifier.warnMinutes=5
/config cache-expiry-notifier.cacheTtl=1h

How it works

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).

Requirements

  • Claude Code with plugin support (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 for tests)
  • macOS for system notifications via osascript (toast works on all platforms)

Development

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.

Source 2 files
hooks/register.tsx 217 lines
1import { 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}
217
types/index.d.ts 12 lines
1export 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