SLOPSHOPPER

cold-cache-guard

Asks what to do before Claude Code re-sends a large conversation whose prompt cache has expired: on a cold resume, and on the first prompt after an idle spell.

newprompttimer
v0.1.0MITupdated 2026-09-27mediavee/cold-cache-guard
A shopper browsing a rack in a slop shop
README

cold-cache-guard

A Claude Code mod that asks what to do before a large conversation whose prompt cache has expired is sent again.

When a conversation sits idle past the prompt cache lifetime (one hour on a subscription, five minutes on an API key by default), the next message re-processes the whole context as a cache write. On a long session that one short message can cost more than the rest of the day. cold-cache-guard stops at that moment and lets you choose:

 ☐ Cache
│ Cold prompt cache: last answer 17h38 ago, ~109k tokens to cache again (~$0.54). What now?
❯ 1. Send as is
  2. Compact first
  3. Start over (/clear)
  4. Cancel

It asks in two places:

  • On a cold resume (claude --resume, --continue, /resume), before you type anything: keep the session as is, compact now, or start over. The idle time, the token count and the price estimate are Claude Code's own.
  • On the first prompt typed after an idle spell in a session that stayed open, the case a resume never sees: send as is, compact first, start over, or cancel. Compact, start over and cancel put your prompt back in the box.

Messages that are not typed by a person (another session's message, a scheduled task, a -p run) go through untouched. The dialog follows Claude Code's language setting (English and French so far).

Install

cold-cache-guard is a mod: a plugin built on Claude Code's function hooks, which are in early access. It needs function hooks enabled; it was built and tested on Claude Code 2.1.283.

claude plugin marketplace add mediavee/cold-cache-guard
claude plugin install cold-cache-guard@cold-cache-guard

Then enable function hooks, either for one launch:

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude

or for good, in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

Sessions started before that change do not load it.

Configure

/plugin configure cold-cache-guard@cold-cache-guard, or the config menu:

OptionDefaultMeaning
ttlMinutes60How long an idle conversation stays cached, for the prompt check. Set 5 on an API key, a cloud provider or usage credits unless you raised promptCacheTtl. Resumes use Claude Code's own estimate instead.
minTokens30000Below this context size, nothing is asked.

How it works

  • A classic.SessionStart hook reads what Claude Code reports on a resume (seconds_since_last_response, context_tokens, prompt_cache_likely_expired, estimated_cache_write_usd) and asks while the session loads.
  • A turn.complete hook records when the last answer arrived, in the session's $.state, so a reload of the mod keeps it.
  • A prompt.submit hook compares that time with ttlMinutes and the live context size with minTokens, then asks through $.ui.ask. A prompt hook cannot compact or run /clear while it holds the prompt, so those run right after the prompt is dropped, and the prompt comes back to the box rather than being resubmitted.
  • A choice holds until the next answer, so a cancelled prompt asks again and an accepted one does not.

Limits

  • Function hooks are early access: the API can change with any Claude Code release. If a hook fails, Claude Code skips it and the prompt goes through as if the mod were not there.
  • The prompt check uses your ttlMinutes, not the live cache state, which mods cannot read yet. A wrong setting means asking too early or too late.
  • Anything typed into the terminal counts as a person's prompt, including text a terminal multiplexer or an orchestrator sends by keystrokes.
  • On Pro and Max plans, Claude Code's own "Resume from summary" dialog can also appear for a resumed session over 100k tokens. Answer "Don't ask me again" there if you prefer this one.
  • Changing the model or the effort level also rebuilds the cache; Claude Code already asks before those while the cache is warm, so this mod does not.

Related

cache-tax takes another approach to the same cost: it keeps the cache warm with periodic pings while you are away, and refuses a cold send once with its price.

Develop

claude plugin validate .claude-plugin/plugin.json
claude plugin test .

To type-check, run /plugin-types in a Claude Code session opened in this folder (it writes .claude/types), then npx -p typescript tsc -p ..

License

MIT

Source 2 files
hooks/register.ts 179 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
3
4const lastAnswerAtMs = atom({ plugin: 'cold-cache-guard', key: 'lastAnswerAtMs' } as const, null)
5const resumedTokens = atom({ plugin: 'cold-cache-guard', key: 'resumedTokens' } as const, null)
6const isSettled = atom({ plugin: 'cold-cache-guard', key: 'isSettled' } as const, false)
7
8type Cold = { idleMs: number; tokens: number; usd?: number }
9
10const TEXTS = {
11  en: {
12    question: (cold: Cold) =>
13      `Cold prompt cache: last answer ${durationOf(cold.idleMs)} ago, ` +
14      `~${Math.round(cold.tokens / 1000)}k tokens to cache again` +
15      (cold.usd === undefined ? '' : ` (~$${cold.usd.toFixed(2)})`) +
16      '. What now?',
17    keep: 'Keep the session as is',
18    compactNow: 'Compact now',
19    send: 'Send as is',
20    compactFirst: 'Compact first',
21    clear: 'Start over (/clear)',
22    cancel: 'Cancel',
23    compacting: 'Compacting; your prompt will be back in the box.',
24    cleared: 'Session cleared; your prompt is back in the box.',
25    cancelled: 'Not sent; your prompt is back in the box.',
26  },
27  fr: {
28    question: (cold: Cold) =>
29      `Cache froid : dernière réponse il y a ${durationOf(cold.idleMs)}, ` +
30      `~${Math.round(cold.tokens / 1000)}k tokens à remettre en cache` +
31      (cold.usd === undefined ? '' : ` (~${cold.usd.toFixed(2)} $)`) +
32      '. Que faire ?',
33    keep: 'Garder la session telle quelle',
34    compactNow: 'Compacter maintenant',
35    send: 'Envoyer tel quel',
36    compactFirst: "Compacter d'abord",
37    clear: 'Repartir à vide (/clear)',
38    cancel: 'Annuler',
39    compacting: 'Compaction en cours, le prompt reviendra dans le champ.',
40    cleared: 'Session vidée, prompt remis dans le champ.',
41    cancelled: 'Envoi annulé, prompt remis dans le champ.',
42  },
43}
44
45type Texts = (typeof TEXTS)['en']
46
47function durationOf(ms: number): string {
48  const minutes = Math.floor(ms / 60_000)
49  return minutes < 60
50    ? `${minutes} min`
51    : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
52}
53
54function isPerson(origin: PromptOrigin): boolean {
55  return origin.kind === 'composer' || origin.kind === 'bridge'
56}
57
58async function textsOf($: EngineInterface): Promise<Texts> {
59  const { language } = await $.settings.read()
60  return typeof language === 'string' && /^(fr|french|fran[cç]ais)/i.test(language)
61    ? TEXTS.fr
62    : TEXTS.en
63}
64
65/**
66 * The dialog of a cold resume, before anything is typed.
67 */
68async function askAtResume($: EngineInterface, cold: Cold): Promise<void> {
69  const texts = await textsOf($)
70  const choice = await $.ui
71    .ask(texts.question(cold), {
72      header: 'Cache',
73      options: [texts.keep, texts.compactNow, texts.clear],
74    })
75    .catch(() => undefined)
76  if (choice === undefined) {
77    return
78  }
79  await update($, isSettled, () => true)
80  if (choice === texts.compactNow) {
81    await $.session.compact()
82  } else if (choice === texts.clear) {
83    await $.command.run({ command: 'clear' })
84  }
85}
86
87// A prompt.submit hook cannot compact or run a command itself: both would wait
88// on the turn it holds. These run once its dispatch has ended.
89
90async function compactThenRefill($: EngineInterface, text: string): Promise<void> {
91  await $.session.compact()
92  // Refilled rather than resubmitted: the model would read a plugin's prompt
93  // as the plugin's message, not the person's.
94  await $.prompt.fill({ text })
95}
96
97async function clearThenRefill($: EngineInterface, text: string): Promise<void> {
98  await $.command.run({ command: 'clear' })
99  await $.prompt.fill({ text })
100}
101
102export const register: Register = (on, options) => {
103  const ttlMs = Number(options.ttlMinutes ?? 60) * 60_000
104  const minTokens = Number(options.minTokens ?? 30_000)
105
106  // Claude Code measures a resumed transcript itself, with the session's real
107  // cache lifetime; that estimate beats the configured one.
108  on('classic.SessionStart', async ($, e, next) => {
109    const result = await next(e)
110    const seconds = e.seconds_since_last_response
111    if (seconds === undefined) {
112      return result
113    }
114    const now = await $.clock.now()
115    await update($, lastAnswerAtMs, () => now - seconds * 1000)
116    await update($, resumedTokens, () => e.context_tokens ?? null)
117    await update($, isSettled, () => false)
118    const tokens = e.context_tokens ?? 0
119    if (e.prompt_cache_likely_expired && tokens >= minTokens) {
120      // Not awaited: the session goes on loading while the dialog waits.
121      void askAtResume($, {
122        idleMs: seconds * 1000,
123        tokens,
124        usd: e.estimated_cache_write_usd,
125      }).catch(error => $.ui.log(`resume question failed: ${error}`, { to: 'debug' }))
126    }
127    return result
128  })
129
130  on('turn.complete', async ($, e, next) => {
131    const result = await next(e)
132    const now = await $.clock.now()
133    await update($, lastAnswerAtMs, () => now)
134    await update($, resumedTokens, () => null)
135    await update($, isSettled, () => false)
136    return result
137  })
138
139  on('prompt.submit', async ($, e, next) => {
140    if (!isPerson(e.origin) || e.turnId !== undefined || (await read($, isSettled))) {
141      return next(e)
142    }
143    const last = await read($, lastAnswerAtMs)
144    if (last === null) {
145      return next(e)
146    }
147    const idleMs = (await $.clock.now()) - last
148    const tokens =
149      (await $.session.usage()).context.tokens ?? (await read($, resumedTokens)) ?? 0
150    if (idleMs < ttlMs || tokens < minTokens) {
151      return next(e)
152    }
153
154    const texts = await textsOf($)
155    const choice = await $.ui
156      .ask(texts.question({ idleMs, tokens }), {
157        header: 'Cache',
158        options: [texts.send, texts.compactFirst, texts.clear, texts.cancel],
159      })
160      .catch(() => texts.cancel)
161
162    if (choice === texts.send) {
163      await update($, isSettled, () => true)
164      return next(e)
165    }
166    if (choice === texts.compactFirst) {
167      await update($, isSettled, () => true)
168      $.clock.after(0, () => void compactThenRefill($, e.text).catch(() => {}))
169      return { drop: texts.compacting }
170    }
171    if (choice === texts.clear) {
172      $.clock.after(0, () => void clearThenRefill($, e.text).catch(() => {}))
173      return { drop: texts.cleared }
174    }
175    await $.prompt.fill({ text: e.text })
176    return { drop: texts.cancelled }
177  })
178}
179
types/index.d.ts 13 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'cold-cache-guard': {
4      /** Epoch ms of the session's last main-thread answer; null before the first one. */
5      lastAnswerAtMs: number | null
6      /** Tokens a resumed transcript's next request re-sends, until a live figure exists. */
7      resumedTokens: number | null
8      /** The person already chose for the current cold spell; the next answer clears it. */
9      isSettled: boolean
10    }
11  }
12}
13