SLOPSHOPPER

cache-keeper

Keeps an idle session's prompt cache warm once at 50 minutes, then writes a handoff doc at 100.

newtoaststatusmodeltimer
v0.2.0MITupdated 2026-10-08razzamatazm/cache-keeper
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keeper
› 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cache-keeper: cache-keeper: warm ping at 09:43
README

cache-keeper

A Claude Code mod for long conversations you walk away from. The prompt cache goes cold after 60 idle minutes, and re-caching a 300k-token conversation is expensive. cache-keeper keeps it warm once, then hands you a clean way to continue.

After Claude finishes a reply, an idle clock starts:

  1. At 50 minutes it replays the conversation with a one-word question, so the API reads it from the cache and the 60-minute window starts over. You pay the cached-read price, not a re-cache.
  2. At 100 minutes, while the cache is still warm from that read, it asks for a handoff doc (goal, current state, next step, decisions, open questions, suggested skills) and saves it to $TMPDIR/handoff-<session>-<time>.md.
  3. In that same call it writes an opening prompt tailored to the conversation: the concrete next step, what done looks like, and any constraint not to miss. It posts the path and that prompt in the chat and copies it to your clipboard. /clear (or open a new window) and paste.

Any new message resets the clock, and /clear stops it. The ping and the handoff request never enter the transcript. The status line shows when the next step is due.

Install

At the prompt of a terminal Claude Code session:

/plugin install cache-keeper --marketplace razzamatazm/cache-keeper

Answer y to add the marketplace, then press Enter to pick the user scope.

Develop

claude plugin validate .
claude plugin test .

The timings are PING_AFTER_MS and HANDOFF_AFTER_MS at the top of hooks/register.ts.

Source 1 files
hooks/register.ts 122 lines
1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3// The prompt cache lapses after 60 idle minutes. One read at 50 refreshes it;
4// at 100 it is still warm from that read, so the handoff costs a cache read too.
5export const PING_AFTER_MS = 50 * 60 * 1000
6export const HANDOFF_AFTER_MS = 50 * 60 * 1000
7
8const NEXT_MARKER = '===NEXT SESSION PROMPT==='
9
10const PING_PROMPT = 'Keep-alive check. Reply with the single word: ok'
11
12const HANDOFF_PROMPT = `Write a handoff document summarising this conversation so a fresh agent with none of this context can continue the work.
13
14- Open with the goal, where things stand, and the exact next step.
15- List decisions made and why, open questions, and anything tried that failed.
16- Reference files, branches, PRs, issues, specs and commits by path or URL instead of copying their content.
17- Include a "Suggested skills" section naming the skills the next agent should call the Skill tool for.
18- Redact secrets, API keys, passwords and personal information.
19
20Reply with the Markdown document, no preamble. Then, on a line of its own, write ${NEXT_MARKER} followed by the opening prompt for the next session: two to four sentences in the user's voice, addressed to the next agent, naming the concrete next step and what done looks like for it, plus any constraint the agent must not miss. It is pasted after a line telling the agent to read the doc, so do not repeat the doc or mention reading it.`
21
22const FALLBACK_NEXT = 'Continue the work from where it leaves off.'
23
24// Splits the fork's reply into the doc and the tailored opening prompt.
25export function splitReply(reply: string) {
26  const at = reply.lastIndexOf(NEXT_MARKER)
27  if (at === -1) return { doc: reply.trim(), next: FALLBACK_NEXT }
28  const next = reply.slice(at + NEXT_MARKER.length).trim()
29  return { doc: reply.slice(0, at).trim(), next: next || FALLBACK_NEXT }
30}
31
32type Timer = { cancel: () => void }
33
34let timer: Timer | undefined
35
36function stop($: Engine) {
37  timer?.cancel()
38  timer = undefined
39  $.ui.status(undefined)
40}
41
42async function at($: Engine, ms: number) {
43  return new Date((await $.clock.now()) + ms).toTimeString().slice(0, 5)
44}
45
46async function handoff($: Engine) {
47  timer = undefined
48  $.ui.status('cache-keeper: writing handoff…')
49  const reply = await $.model.fork({ prompt: HANDOFF_PROMPT })
50  if (!reply.isAnswered) {
51    $.ui.status(undefined)
52    $.ui.log(`cache-keeper: handoff not written (${reply.reason})`)
53    return
54  }
55
56  const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
57  const stamp = new Date(await $.clock.now()).toISOString().slice(0, 16).replace(/[:T]/g, '-')
58  const path = `${tmp}/handoff-${(await $.session.id()).slice(0, 8)}-${stamp}.md`
59  const { doc, next } = splitReply(reply.text)
60  await $.fs.write(path, doc + '\n')
61
62  const resume = `Read the handoff doc at ${path}. ${next}`
63  const copied = await $.ui.copy({ text: resume }).catch(() => ({ isCopied: false }))
64  await $.session.append({
65    message: {
66      type: 'system',
67      content: [
68        {
69          type: 'text',
70          text: [
71            'cache-keeper: this conversation has been idle 100 minutes and its cache lapses in about 10.',
72            `Handoff written to ${path}`,
73            `To continue, /clear (or open a new window) and paste${copied.isCopied ? ' (already on your clipboard)' : ''}:`,
74            '',
75            resume,
76          ].join('\n'),
77        },
78      ],
79    },
80  })
81  $.ui.status(undefined)
82  $.ui.toast('cache-keeper: handoff ready, continue prompt copied')
83}
84
85async function ping($: Engine) {
86  const reply = await $.model.fork({ prompt: PING_PROMPT })
87  if (!reply.isAnswered) {
88    timer = undefined
89    $.ui.status(undefined)
90    if (reply.reason !== 'nothing-to-fork') $.ui.log(`cache-keeper: warm ping failed (${reply.reason})`)
91    return
92  }
93  const read = reply.usage.cache_read_input_tokens ?? 0
94  $.ui.log(`cache-keeper: warm ping read ${Math.round(read / 1000)}k cached tokens`, { to: 'debug' })
95  timer = $.clock.after(HANDOFF_AFTER_MS, () => void handoff($))
96  $.ui.status(`cache-keeper: handoff at ${await at($, HANDOFF_AFTER_MS)}`)
97}
98
99async function arm($: Engine) {
100  stop($)
101  timer = $.clock.after(PING_AFTER_MS, () => void ping($))
102  $.ui.status(`cache-keeper: warm ping at ${await at($, PING_AFTER_MS)}`)
103}
104
105export const register: Register = on => {
106  on('turn.start', ($, e, next) => {
107    stop($)
108    return next(e)
109  })
110
111  on('turn.complete', async ($, e, next) => {
112    const done = await next(e)
113    await arm($)
114    return done
115  })
116
117  on('session.end', ($, e, next) => {
118    stop($)
119    return next(e)
120  })
121}
122