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.

newbandcommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-expiry-notifier
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /keepwarm ⎿ cache-expiry-notifier: 🔥 Keep-warm enabled for this session. Will send · before the cache expires to reset the clock. Run /keepwarm ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cache-expiry-notifier: 🔥 keepwarm
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).
  • /keepwarm — opt-in keep-warm mode that automatically sends · before the cache expires, resetting the clock without you having to do anything.

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
cache-expiry-notifier.minTokens50000Only 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

Keep-warm mode

Run /keepwarm inside any Claude Code session to toggle automatic cache keep-warm on or off. When enabled:

  • The mod sends · (a single middle dot) before the cache expires, resetting the clock
  • The status bar shows 🔥 keepwarm while active
  • The normal warning notification still fires so you know it happened
  • If you're in the middle of a turn, the · is queued and sent when the turn finishes
  • Session-specific — resets to off on /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.

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: 40 tests, 0 failures across ttl-detection, timer-notifications, above-prompt, and keepwarm suites.

Source 2 files
hooks/register.tsx 256 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)
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}
256
types/index.d.ts 13 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      keepWarmEnabled: boolean
10    }
11  }
12}
13