SLOPSHOPPER

cache-handoff

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…

newcommandtoaststatuspromptmodel
v0.4.2no licenseupdated 2026-10-15pnam16/claude-mods/mods/cache-handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-handoff
› 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 › /handoff ⎿ cache-handoff: cache-handoff: armed, no handoff stored, coldAction new. Usage: /handoff off | on | now ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cache-handoff

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.

How it works

Times count from the end of your last turn, on the usual 1 hour cache.

WhenWhat happens
0 minYour 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 minThe 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 minThe cache is still warm. Come back now and you simply continue the full conversation; the saved note is discarded.
114 minThe 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.
LaterThe 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.

Install

/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).

Use

The mod runs on its own. The /handoff command controls it for the current session:

CommandEffect
/handoffShow whether it is armed and whether a handoff is stored.
/handoff offStop watching this session.
/handoff onStart watching again from the next turn.
/handoff nowOpen the new conversation from the stored handoff right away, for example after you resumed a session with claude --resume.

Settings

Set coldAction in /plugin configure:

ValueWhen the cache goes cold
new (default)Open a new conversation from the handoff, as described above.
noticeOnly show a reminder under the prompt; /handoff now still opens the new conversation when you want it.
offDo 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

Good to know

  • Writing the handoff is one extra model call per long idle gap. It reads the warm cache, so it is cheap, and it also keeps the cache warm for another hour.
  • Only a prompt you type takes the handoff. Messages from other sessions and plugin prompts pass through unchanged.
  • The new conversation does not inherit the old one's todo list, permissions granted during that session, or background tasks.
  • The mod works only while Claude Code is open. A handoff saved before you quit is offered again when you resume that session, and is dropped after 7 days if nobody comes back for it.
  • The handoff is kept in the mod's own local storage inside Claude Code. Nothing is sent anywhere else.
  • The mod reads the session transcript with tail and grep when they are on PATH, to find the cache tier and the conversation name.

More detail

FLOW.md has the full decision flow, the API each step uses, and the live test record.

Source 1 files
hooks/register.ts 411 lines
1import 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