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

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:
$TMPDIR/handoff-<session>-<time>.md./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.
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.
claude plugin validate .
claude plugin test .
The timings are PING_AFTER_MS and HANDOFF_AFTER_MS at the top of hooks/register.ts.
hooks/register.ts 122 lines1import 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