Saves each compaction's summary, with the files edited before it, to ~/.claude/handoffs so the context before /compact can be recovered

Saves a handoff file each time the conversation is compacted, so the context before /compact can be recovered.
One Markdown file per compaction in ~/.claude/handoffs/, named <date>-<time>-<project>-<session>.md:
claude --resume command/compactA short toast names the file once it is saved. Subagent compactions are skipped, and a failed write never blocks the compaction.
claude plugin marketplace add arasovic/claude-code-mods
claude plugin install compact-keeper@claude-code-mods
Restart Claude Code. Nothing to open: the next compaction writes the first file.
claude plugin validate .
claude plugin test .
../typecheck.sh compact-keeperhooks/register.ts 75 lines1import { atom, read, update } from 'claude-code'
2import type { Register, SessionMessage } from 'claude-code'
3
4// Paths edited since the last compaction, listed in the next handoff.
5const edited = atom({ plugin: 'compact-keeper', key: 'edited' } as const, [] as string[])
6
7const pad = (n: number) => String(n).padStart(2, '0')
8const stamp = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
9
10export const fileName = (at: number, cwd: string, sessionId: string) => {
11 const d = new Date(at)
12 const project = (cwd.split('/').filter(Boolean).pop() ?? 'root').replace(/[^\w.-]+/g, '-')
13 return `${stamp(d)}-${pad(d.getHours())}${pad(d.getMinutes())}-${project}-${sessionId.slice(0, 8)}.md`
14}
15
16// The summary is the message core wrote: the one carrying no handle from the transcript it replaced.
17export const summaryOf = (before: readonly SessionMessage[], after: readonly SessionMessage[]) => {
18 const kept = new Set(before.map(m => m.handle).filter(Boolean))
19 return (after.find(m => m.text && !(m.handle && kept.has(m.handle))) ?? after[0])?.text ?? ''
20}
21
22export const handoff = (h: { at: number; cwd: string; sessionId: string; trigger: string; instructions?: string; tokensBefore?: number; tokensAfter?: number; files: readonly string[]; summary: string }) => {
23 const d = new Date(h.at)
24 const size = h.tokensBefore === undefined ? '' : `, ${Math.round(h.tokensBefore / 1000)}k → ${h.tokensAfter === undefined ? '?' : Math.round(h.tokensAfter / 1000)}k tokens`
25 return [
26 `# Handoff: ${h.cwd.split('/').filter(Boolean).pop() ?? h.cwd}`,
27 '',
28 // toTimeString starts with the local HH:MM:SS.
29 `- Compacted: ${stamp(d)} ${d.toTimeString().slice(0, 5)} (${h.trigger})${size}`,
30 `- Directory: ${h.cwd}`,
31 `- Session: ${h.sessionId} (\`claude --resume ${h.sessionId}\`)`,
32 ...(h.instructions ? [`- Instructions: ${h.instructions}`] : []),
33 '',
34 '## Files edited before this compaction',
35 '',
36 ...(h.files.length ? h.files.map(f => `- ${f}`) : ['- none']),
37 '',
38 '## Summary',
39 '',
40 h.summary.trim(),
41 '',
42 ].join('\n')
43}
44
45export const register: Register = on => {
46 on('tool.call', async ($, e, next) => {
47 const result = await next(e)
48 if ((e.tool === 'Edit' || e.tool === 'Write') && result.deny === undefined && !result.isError) {
49 const path = (e as unknown as { file_path: string }).file_path
50 await update($, edited, list => [path, ...list.filter(p => p !== path)].slice(0, 100))
51 }
52 return result
53 })
54
55 on('session.compact', async ($, e, next) => {
56 const result = await next(e)
57 // A precompute may never be installed, and a subagent's compaction is not the conversation's.
58 if (result.skip !== undefined || e.trigger === 'precompute' || e.agentId !== undefined) return result
59 try {
60 const at = await $.clock.now()
61 const cwd = await $.session.cwd()
62 const sessionId = await $.session.id()
63 const home = await $.env.get('HOME')
64 const files = await read($, edited)
65 const path = `${home ?? '.'}/.claude/handoffs/${fileName(at, cwd, sessionId)}`
66 await $.fs.write(path, handoff({ at, cwd, sessionId, trigger: e.trigger, instructions: e.instructions, tokensBefore: result.tokensBefore, tokensAfter: result.tokensAfter, files, summary: summaryOf(e.messages, result.messages) }))
67 await update($, edited, () => [])
68 $.ui.toast(`Handoff saved: ${path.replace(home ?? '\0', '~')}`)
69 } catch {
70 // Saving is a convenience: a failed write must never undo the compaction.
71 }
72 return result
73 })
74}
75types/index.d.ts 8 lines1declare module 'claude-code' {
2 interface PluginState {
3 'compact-keeper': {
4 edited: string[]
5 }
6 }
7}
8