Writes a session handoff when about 5 minutes of the 1h prompt cache remain on an idle session, then, once that cache has gone cold, opens a new conversation…

A Claude Code mod that saves you from paying to re-cache a long conversation after you step away.
Claude Code keeps your conversation in a prompt cache for about an hour. If you come back after the cache has expired, your next prompt sends the whole transcript again at the full price. cache-handoff writes a short handoff note while the cache is still warm, and once the cache has gone cold it opens a fresh conversation that starts from that note.
Times count from the end of your last turn, on the usual 1 hour cache.
| When | What happens |
|---|---|
| 0 min | Your turn ends. The mod starts watching, but only if the conversation holds 50k context tokens or more. Smaller ones are cheap to re-cache, so it leaves them alone. |
| 54 min | The mod asks Claude for a handoff note (goal, progress, decisions, files, next step) while the cache is still warm. If the work is finished, Claude says so and nothing is saved. |
| 54 to 114 min | The cache is still warm. Come back now and you simply continue the full conversation; the saved note is discarded. |
| 114 min | The cache is cold. The mod runs /clear and renames the new conversation to <old name>_YYYYMMDD_HHMMSS if the old one had a name. A stamp left by an earlier handoff is replaced, so task_20261015_150400 becomes task_20261015_170432, not a longer chain. |
| Later | The first prompt you type there carries the handoff note in front of your text, once. |
Your old conversation is never changed. The carried handoff ends with the command to reopen it in full: claude --resume <old id>.
On the 5 minute cache tier there is not enough time to write a handoff, so the mod only shows a reminder to /clear once that cache has gone cold.
/plugin marketplace add pnam16/claude-mods
/plugin install cache-handoff@ame-mods
This needs a Claude Code build that supports mods (hooks modules loaded from hooks/hooks.json).
The mod runs on its own. The /handoff command controls it for the current session:
| Command | Effect |
|---|---|
/handoff | Show whether it is armed and whether a handoff is stored. |
/handoff off | Stop watching this session. |
/handoff on | Start watching again from the next turn. |
/handoff now | Open the new conversation from the stored handoff right away, for example after you resumed a session with claude --resume. |
Set coldAction in /plugin configure:
| Value | When the cache goes cold |
|---|---|
new (default) | Open a new conversation from the handoff, as described above. |
notice | Only show a reminder under the prompt; /handoff now still opens the new conversation when you want it. |
off | Do nothing at cold. The handoff is still written at 54 min and kept until your next turn. |
To try the whole cycle in a few minutes, shorten the timings before starting Claude Code:
$env:CACHE_HANDOFF_TTL_MIN = 3; $env:CACHE_HANDOFF_LEAD_MIN = 1; claude
CACHE_HANDOFF_TTL_MIN=3 CACHE_HANDOFF_LEAD_MIN=1 claude
tail and grep when they are on PATH, to find the cache tier and the conversation name.FLOW.md has the full decision flow, the API each step uses, and the live test record.
hooks/register.ts 411 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3const MINUTE_MS = 60_000
4const SHORT_CACHE_TTL_MS = 5 * MINUTE_MS
5const POLL_MS = 30_000
6// A conversation smaller than this costs little to re-cache, so the mod
7// leaves it alone.
8const MIN_CONTEXT_TOKENS = 50_000
9// Rows read back from the transcript's end when looking for the cache tier.
10const TIER_SCAN_ROWS = 400
11// Stored handoffs nobody came back for are dropped after this long.
12const STORE_MAX_AGE_MS = 7 * 24 * 60 * MINUTE_MS
13const STORE_PREFIX = 'handoff:'
14const NO_HANDOFF = 'NO_HANDOFF'
15
16const HANDOFF_PROMPT = [
17 'This session has been idle and its prompt cache is about to expire.',
18 'Write a handoff note so a fresh session can resume this work without the transcript.',
19 'Cover: the goal, what is done, decisions made and why, files and paths touched,',
20 'open questions, and the exact next step. Plain Markdown, at most 400 words, no preamble.',
21 `If the work is finished and nothing is left to resume, reply with exactly ${NO_HANDOFF} and nothing else.`,
22].join(' ')
23
24type CacheTier = '1h' | '5m' | 'unknown'
25type ColdAction = 'new' | 'notice' | 'off'
26type StoredHandoff = { text: string; writtenAt: number; cwd: string }
27
28// The settings: coldAction from /plugin configure, the timings from the
29// environment so a live test can shorten them.
30const config = {
31 coldAction: 'new' as ColdAction,
32 cacheTtlMs: 60 * MINUTE_MS,
33 leadMs: 6 * MINUTE_MS,
34}
35
36// The main loop's idle clock; the module's own, so a reload starts it over.
37const idle = {
38 lastTurnEndAt: undefined as number | undefined,
39 isArmed: false,
40 isDisabled: false,
41 isInTurn: false,
42 isWriting: false,
43 transcriptPath: undefined as string | undefined,
44 cacheTtlMs: 0,
45 // The handoff fork read the cache and so refreshed it: the cache goes cold
46 // one TTL after the later of this and the last turn.
47 forkedAt: undefined as number | undefined,
48 // The fork said the work is finished: nothing to do at cold.
49 isFinished: false,
50 isColdHandled: false,
51 isNoticeShown: false,
52 // The handoff the next typed prompt of the new conversation carries, and
53 // the store key it came from.
54 pendingHandoff: undefined as string | undefined,
55 pendingKey: undefined as string | undefined,
56}
57
58let poller: Timer | undefined
59let lastPollAt = 0
60
61const storeKey = (sessionId: string) => `${STORE_PREFIX}${sessionId}`
62
63async function readStored($: EngineInterface, key: string): Promise<StoredHandoff | undefined> {
64 const value = (await $.store.get(key)) as Partial<StoredHandoff> | undefined
65 return typeof value?.text === 'string' && typeof value.writtenAt === 'number'
66 ? { text: value.text, writtenAt: value.writtenAt, cwd: String(value.cwd ?? '') }
67 : undefined
68}
69
70async function pruneStore($: EngineInterface, now: number) {
71 for (const key of await $.store.keys()) {
72 if (!key.startsWith(STORE_PREFIX)) continue
73 const stored = await readStored($, key)
74 if (stored === undefined || now - stored.writtenAt > STORE_MAX_AGE_MS) await $.store.delete(key)
75 }
76}
77
78function minutesToMs(value: string | undefined, fallbackMs: number) {
79 const minutes = Number(value)
80 return value !== undefined && Number.isFinite(minutes) && minutes > 0 ? minutes * MINUTE_MS : fallbackMs
81}
82
83// The tier of the newest main-thread cache write; a turn that only read the
84// cache writes nothing, so older rows are searched until one wrote.
85function cacheTier(transcript: string): CacheTier {
86 const rows = transcript.split('\n')
87 for (let i = rows.length - 1; i >= 0 && i >= rows.length - TIER_SCAN_ROWS; i--) {
88 const row = rows[i]
89 if (row === undefined || !row.includes('"cache_creation"')) continue
90 try {
91 const entry = JSON.parse(row)
92 if (entry.type !== 'assistant' || entry.isSidechain) continue
93 const written = entry.message?.usage?.cache_creation
94 if (written?.ephemeral_1h_input_tokens > 0) return '1h'
95 if (written?.ephemeral_5m_input_tokens > 0) return '5m'
96 } catch {
97 // a row still being written
98 }
99 }
100 return 'unknown'
101}
102
103async function homeDir($: EngineInterface) {
104 return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
105}
106
107// classic.Stop may not reach this plugin (an organization's managed settings
108// keep it from installed plugins), so the path is also derived the way Claude
109// Code names its project folders.
110async function transcriptPath($: EngineInterface) {
111 if (idle.transcriptPath !== undefined) return idle.transcriptPath
112 const project = (await $.session.cwd()).replace(/[^a-zA-Z0-9]/g, '-')
113 return `${await homeDir($)}/.claude/projects/${project}/${await $.session.id()}.jsonl`
114}
115
116async function readTier($: EngineInterface): Promise<CacheTier> {
117 const path = await transcriptPath($)
118 // A long transcript is past the 4 MiB that $.fs.read takes, so read its tail.
119 try {
120 const tail = await $.process.run(['tail', '-n', String(TIER_SCAN_ROWS), path])
121 if (tail.exitCode === 0) return cacheTier(tail.stdout)
122 } catch {
123 // no tail on PATH
124 }
125 try {
126 return cacheTier(String(await $.fs.read(path)))
127 } catch {
128 return 'unknown'
129 }
130}
131
132// The name /rename last gave this session; /rename writes it to the
133// transcript as a custom-title row, anywhere in the file.
134async function sessionTitle($: EngineInterface) {
135 try {
136 const found = await $.process.run(['grep', '-F', '"type":"custom-title"', await transcriptPath($)])
137 const rows = found.stdout.trim().split('\n')
138 const title = JSON.parse(rows[rows.length - 1] ?? '').customTitle
139 return typeof title === 'string' && title.trim() !== '' ? title.trim() : undefined
140 } catch {
141 return undefined
142 }
143}
144
145async function writeHandoff($: EngineInterface, now: number) {
146 const forked = await $.model.fork({ prompt: HANDOFF_PROMPT })
147 if (!forked.isAnswered) {
148 $.ui.log(`cache-handoff: no handoff written (${forked.reason})`)
149 return
150 }
151 idle.forkedAt = now
152 if (forked.text.trim() === NO_HANDOFF) {
153 idle.isFinished = true
154 $.ui.log('cache-handoff: the work is finished, no handoff kept')
155 return
156 }
157 const stored: StoredHandoff = { text: forked.text, writtenAt: now, cwd: await $.session.cwd() }
158 await $.store.set(storeKey(await $.session.id()), stored)
159 $.ui.toast(`Cache handoff written at ${clockTime(now)}.`)
160}
161
162const pad = (n: number) => String(n).padStart(2, '0')
163
164function clockTime(ms: number) {
165 const at = new Date(ms)
166 return `${pad(at.getHours())}:${pad(at.getMinutes())}`
167}
168
169function dateTime(ms: number) {
170 const at = new Date(ms)
171 return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${clockTime(ms)}`
172}
173
174// Appended to a renamed conversation's title: YYYYMMDD_HHMMSS, no spaces.
175function titleStamp(ms: number) {
176 const at = new Date(ms)
177 const date = `${at.getFullYear()}${pad(at.getMonth() + 1)}${pad(at.getDate())}`
178 return `${date}_${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`
179}
180
181// The title without the stamps earlier handoffs appended, in this format or
182// 0.4.0's "_YYYY-MM-DD HH:MM", so a conversation handed off again gets one
183// fresh stamp instead of a growing chain.
184const TRAILING_STAMP = /(?:_\d{8}_\d{6}|[_ ]\d{4}-\d{2}-\d{2} \d{2}:\d{2})$/
185
186function baseTitle(title: string) {
187 let base = title
188 while (TRAILING_STAMP.test(base)) base = base.replace(TRAILING_STAMP, '')
189 return base
190}
191
192function coldNotice(coldAt: number, stored: StoredHandoff | undefined) {
193 const cold = `Prompt cache cold since ${clockTime(coldAt)}`
194 if (stored === undefined) {
195 return `${cold}. The next prompt re-caches the whole transcript; /clear first to start lean.`
196 }
197 return `${cold}. Handoff written at ${clockTime(stored.writtenAt)}; /handoff now opens a new conversation from it.`
198}
199
200function showNotice($: EngineInterface, text: string) {
201 idle.isNoticeShown = true
202 $.ui.status(text)
203 $.ui.toast(text)
204}
205
206// Swaps the cold conversation for a new one that the handoff opens, named
207// after the old one, so the next prompt caches the handoff, not the transcript.
208async function startFromHandoff($: EngineInterface, key: string, stored: StoredHandoff, now: number) {
209 const oldId = await $.session.id()
210 const title = await sessionTitle($)
211 await $.command.run({ command: 'clear' })
212 idle.transcriptPath = undefined
213 idle.lastTurnEndAt = undefined
214 idle.isArmed = false
215 idle.pendingKey = key
216 idle.pendingHandoff = [
217 `<handoff from="session ${oldId}" written="${dateTime(stored.writtenAt)}">`,
218 stored.text,
219 '</handoff>',
220 `The previous conversation is still there: claude --resume ${oldId}`,
221 ].join('\n')
222 if (title !== undefined) {
223 // The new conversation is already open and carries the handoff, so a
224 // refused rename only leaves it unnamed.
225 try {
226 await $.command.run({ command: 'rename', args: `${baseTitle(title)}_${titleStamp(now)}` })
227 } catch (error) {
228 $.ui.log(`cache-handoff: rename refused, the new conversation stays unnamed: ${errorText(error)}`)
229 }
230 }
231 showNotice($, `New conversation started from the handoff of ${clockTime(stored.writtenAt)}; your next prompt carries it.`)
232}
233
234async function handoffIfDue($: EngineInterface, now: number) {
235 if (!idle.isArmed || now - (idle.lastTurnEndAt ?? now) < config.cacheTtlMs - config.leadMs) return
236 // Disarm before forking: the fork reads the cache and so refreshes its TTL,
237 // and only a real turn may arm the next handoff.
238 idle.isArmed = false
239 idle.isWriting = true
240 try {
241 // The turn's end may have come before its cache write reached the
242 // transcript, so the tier is read again here.
243 if ((await readTier($)) === '5m') {
244 idle.cacheTtlMs = SHORT_CACHE_TTL_MS
245 $.ui.log('cache-handoff: 5 minute cache tier, no handoff written')
246 return
247 }
248 await writeHandoff($, now)
249 } finally {
250 idle.isWriting = false
251 }
252}
253
254// After the handoff step, so the cold time counts the fork's refresh.
255async function actIfCold($: EngineInterface, now: number) {
256 if (config.coldAction === 'off' || idle.isFinished || idle.isColdHandled || idle.isArmed) return
257 if (idle.lastTurnEndAt === undefined) return
258 const coldAt = Math.max(idle.lastTurnEndAt, idle.forkedAt ?? 0) + idle.cacheTtlMs
259 if (now < coldAt) return
260 idle.isColdHandled = true
261 const key = storeKey(await $.session.id())
262 const stored = await readStored($, key)
263 if (config.coldAction === 'new' && stored !== undefined) {
264 try {
265 await startFromHandoff($, key, stored, now)
266 return
267 } catch (error) {
268 $.ui.log(`cache-handoff: new conversation failed, showing the notice: ${errorText(error)}`)
269 }
270 }
271 showNotice($, coldNotice(coldAt, stored))
272}
273
274const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error))
275
276async function poll($: EngineInterface) {
277 const now = await $.clock.now()
278 lastPollAt = now
279 if (idle.isInTurn || idle.isWriting || idle.lastTurnEndAt === undefined) return
280 await handoffIfDue($, now)
281 await actIfCold($, now)
282}
283
284// Starts the poll, or starts it again when it has gone quiet: no session.start
285// fires after /clear, so a turn is the next chance to notice.
286async function ensurePoller($: EngineInterface) {
287 const now = await $.clock.now()
288 if (poller !== undefined && now - lastPollAt < 3 * POLL_MS) return
289 poller?.cancel()
290 lastPollAt = now
291 poller = $.clock.every(POLL_MS, () => void poll($))
292}
293
294async function handoffCommand($: EngineInterface, args: string) {
295 const word = args.trim().toLowerCase()
296 if (word === 'off') {
297 idle.isDisabled = true
298 idle.isArmed = false
299 return 'cache-handoff is off for this session.'
300 }
301 if (word === 'on') {
302 idle.isDisabled = false
303 return 'cache-handoff is on; it arms when the next turn ends.'
304 }
305 const key = storeKey(await $.session.id())
306 const stored = await readStored($, key)
307 if (word === 'now') {
308 if (stored === undefined) return 'No handoff is stored for this session.'
309 // $.command.run waits for the session to be idle, which it is not while
310 // this command runs, so the switch happens just after.
311 $.clock.after(0, () => {
312 void (async () => {
313 try {
314 idle.isColdHandled = true
315 await startFromHandoff($, key, stored, await $.clock.now())
316 } catch (error) {
317 $.ui.toast(`cache-handoff: could not open the new conversation: ${errorText(error)}`)
318 }
319 })()
320 })
321 return `Opening a new conversation from the handoff of ${clockTime(stored.writtenAt)}.`
322 }
323 const state = idle.isDisabled ? 'off' : idle.isArmed ? 'armed' : 'waiting for the next turn'
324 const handoff = stored === undefined ? 'no handoff stored' : `handoff stored at ${clockTime(stored.writtenAt)}`
325 return `cache-handoff: ${state}, ${handoff}, coldAction ${config.coldAction}. Usage: /handoff off | on | now`
326}
327
328export const register: Register = (on, options) => {
329 const action = options.coldAction
330 // 0.3.0 called the default new-session.
331 config.coldAction = action === 'notice' || action === 'off' ? action : 'new'
332
333 on('session.start', async ($, e, next) => {
334 config.cacheTtlMs = minutesToMs(await $.env.get('CACHE_HANDOFF_TTL_MIN'), 60 * MINUTE_MS)
335 config.leadMs = minutesToMs(await $.env.get('CACHE_HANDOFF_LEAD_MIN'), 6 * MINUTE_MS)
336 idle.cacheTtlMs = config.cacheTtlMs
337 await $.command.register({
338 name: 'handoff',
339 description: 'Cache handoff: off, on, or now to open a new conversation from the stored handoff',
340 argumentHint: 'off | on | now',
341 })
342 await ensurePoller($)
343 const now = await $.clock.now()
344 await pruneStore($, now)
345 // A session resumed after Claude Code closed before its cache went cold.
346 const stored = await readStored($, storeKey(await $.session.id()))
347 if (stored !== undefined) {
348 showNotice($, `A handoff from ${dateTime(stored.writtenAt)} is stored; /handoff now opens a new conversation from it.`)
349 }
350 return next(e)
351 })
352
353 on('command.run', { command: 'handoff' }, async ($, e) => ({ text: await handoffCommand($, e.args) }))
354
355 // The first prompt the person types in the new conversation opens with the
356 // handoff; a notification or a peer's message does not take it.
357 on('prompt.submit', async ($, e, next) => {
358 if (idle.pendingHandoff === undefined || e.command !== undefined || e.isMeta || e.origin.kind !== 'composer') {
359 return next(e)
360 }
361 const text = `${idle.pendingHandoff}\n\n${e.text}`
362 if (idle.pendingKey !== undefined) await $.store.delete(idle.pendingKey)
363 idle.pendingHandoff = undefined
364 idle.pendingKey = undefined
365 return next({ ...e, text })
366 }).catch(($, e, next) => next(e))
367
368 // Turns raised while the handoff forks are the mod's own, never the person's.
369 on('turn.start', async ($, e, next) => {
370 if (e.agentId === undefined && !idle.isWriting) {
371 idle.isInTurn = true
372 // The person is back in this conversation: its handoff is moot.
373 if (idle.pendingHandoff === undefined) await $.store.delete(storeKey(await $.session.id()))
374 if (idle.isNoticeShown) {
375 idle.isNoticeShown = false
376 $.ui.status(undefined)
377 }
378 }
379 return next(e)
380 })
381
382 on('turn.complete', async ($, e, next) => {
383 if (e.agentId === undefined && !idle.isWriting) {
384 idle.isInTurn = false
385 idle.lastTurnEndAt = await $.clock.now()
386 idle.cacheTtlMs = config.cacheTtlMs
387 idle.forkedAt = undefined
388 idle.isFinished = false
389 const tokens = (await $.session.usage()).context.tokens ?? 0
390 const isWatched = !idle.isDisabled && tokens >= MIN_CONTEXT_TOKENS
391 // A gap the mod sits out ends quietly, with no notice at cold either.
392 idle.isColdHandled = !isWatched
393 // On the 5m tier the cache is cold long before a handoff could be
394 // written, so skip it and let the notice come at the 5m mark.
395 if (isWatched && (await readTier($)) === '5m') {
396 idle.cacheTtlMs = SHORT_CACHE_TTL_MS
397 idle.isArmed = false
398 } else {
399 idle.isArmed = isWatched
400 }
401 await ensurePoller($)
402 }
403 return next(e)
404 })
405
406 on('classic.Stop', ($, e, next) => {
407 idle.transcriptPath = e.transcript_path
408 return next(e)
409 }).catch(($, e, next) => next(e))
410}
411