Replaces Claude Code's compaction with a structured handoff: a fork of the session writes it from the prompt cache, and the conversation continues from exactly…

A Claude Code mod that replaces compaction with a structured handoff. When a long session fills up, the conversation is replaced by one message that says what the goal is, what is done and how that is proven, what comes next, which decisions were made and why, and which approaches were ruled out.
Status: 0.2, piloted on a live session: six automatic compactions in one long turn,
/compactand/compact classic. It needs Claude Code 2.1.287 or later with mods enabled on your account. It passesclaude plugin validate --strictand the tests intests/(claude plugin test).
Claude Code's own compaction summarizes the conversation with a generic prompt. That keeps what a summarizer finds important. What the next stretch of work needs most usually gets lost: the reason behind a decision, the paths that were already tried and failed, the one command that shows whether the state is real.
A common workaround is to warn the model at some fill level and ask it to write a handoff file. That costs turns in the expensive main context, depends on the model following the warning, and still ends in a second, generic summary.
/compact. The mod answers that compaction instead of Claude Code's summarizer, so every compaction goes through the same path.$.model.fork) writes the handoff along a fixed outline. The fork reads the conversation from the prompt cache and has no tools, so the main context stays untouched.session.compact event with that single handoff message. The handoff is also saved as a Markdown file.Safety net: if the fork fails (cold cache, API error), the compaction goes back to Claude Code with the outline as its instructions. If the mod throws, Claude Code skips it and compacts as usual. Subagents' compactions are never touched.
Claude Code's background pre-compaction is switched off by default (precompute: skip). Its result would be thrown away anyway, so it only costs tokens.
/plugin marketplace add trytofly94/handoff-compact
/plugin install handoff-compact@handoff-compact
This repository is both the plugin and a marketplace that lists it. From a local checkout, use /plugin marketplace add /path/to/handoff-compact for the first line. To try a checkout for one session without installing it:
claude --plugin-dir /path/to/handoff-compact
A --plugin-dir copy shadows the installed one of the same name, so you can work on the mod while your installed version keeps running everywhere else.
claude plugin validate --strict .claude-plugin/plugin.json
claude plugin test # tests/*.test.ts, needs mods enabled
bash tests/offline/run.sh # logic only, simulated hook chain, needs Node
trigger: core (default): Claude Code decides when, at its compact window. Set the window with CLAUDE_CODE_AUTO_COMPACT_WINDOW or autoCompactWindow in your settings. The handoff replaces Claude Code's summary, so a compaction costs one model call: the fork.
trigger: self: the mod decides, at threshold % of window. Claude Code measures the context after every response, also in the middle of a turn, while a compaction is only allowed between turns. So the mod compacts once the main turn ends with an answer (not after a subagent's turn, an interrupt or an error). The catch: Claude Code does not run a plugin's own session.compact hook for a compaction that plugin started. Claude Code therefore writes its own summary as well, and the handoff follows as the next prompt. That costs a second summary and leaves both in the context. Use it only where you can't set Claude Code's window.
/compact classic compacts this one time the way Claude Code does without the mod: its own summary, no fork. Anything after the keyword goes to Claude Code as usual, e.g. /compact classic keep the test plan. The keyword only counts for /compact and only as the first word, so /compact classical music still gets a handoff. To switch the mod off for good, disable it in /plugin.
Set them in /plugin → configure, in /config, or in pluginConfigs in your settings.
| Option | Default | What it does |
|---|---|---|
trigger | core | core (Claude Code decides when) or self (the mod does), see Triggers |
threshold | 70 | self only: compact at this % of the compact window |
window | 0 (auto) | self only: compact window in tokens. Auto order: adapter, CLAUDE_CODE_AUTO_COMPACT_WINDOW, autoCompactWindow in ~/.claude/settings.json, the model's context window |
keepVerbatim | 10 | Latest prompts and answers copied word for word |
autoContinue | never | self only: never, always, or adapter (the adapter decides per session). The handoff always follows as a prompt; with never that prompt asks only for an acknowledgement. Claude Code's own compactions continue the turn anyway |
precompute | skip | skip turns off Claude Code's background pre-compaction, core leaves it on |
handoffDir | ~/.claude/handoffs | Where handoff files are saved |
outlineFile | built-in | Text file with the sections every handoff must have, one per line |
adapter | none | Executable that connects the mod to your setup (see below) |
The built-in outline: goal · state and proof · in progress · next step · decisions and reasons · ruled out · blocked / open questions · files and commits · verify. The fork writes in the conversation's language whatever the outline's language is.
An optional executable for whatever only your setup knows: which compact window a session was started with, which files hold your project's state, whether a session runs unattended. The mod calls it with JSON on stdin and reads JSON from stdout:
// stdin
{ "version": 1, "phase": "check", "sessionId": "…", "cwd": "/path",
"trigger": null, "context": { "tokens": 151000, "window": 1000000, "percent": 15 } }
phase is check after each response with trigger: self (the answer is cached for 5 minutes) or compact while the handoff is being written. Every field of the answer is optional:
// stdout
{ "window": "200k", "threshold": 60, "autoContinue": true, "notes": "extra text for the handoff",
"stateFiles": [ { "label": "plan", "path": "/path/PLAN.md" } ] }
A missing adapter, a non-zero exit, invalid JSON or a timeout (10 s) all count as "no answer". Keep check fast. For example, only look for state files when phase is compact.
The mod is small on purpose. To change what it does, open a Claude Code session in this directory with claude --plugin-dir . and describe the change, for example:
Most of these need no code: the outline is a file, the threshold and paths are options, and per-session decisions belong in an adapter script. hooks/register.js keeps every decision in its own function with a comment saying what it decides. After a change, run claude plugin validate --strict .claude-plugin/plugin.json, claude plugin test and bash tests/offline/run.sh.
claude -p the hooks run, but nothing is drawn.trigger: self, a compaction happens between turns. If a new turn starts while the fork is writing, the attempt is dropped and retried at the end of a later turn (at the earliest two minutes on).MIT
hooks/register.js 344 lines1// handoff-compact — compact a Claude Code session into a structured handoff.
2//
3// Claude Code compacts a long conversation by summarizing it with its own
4// prompt. That summary keeps what seems important to a summarizer and tends to
5// drop what the next stretch of work needs most: the reason behind a decision,
6// the approaches already ruled out, the exact command that proves the state.
7//
8// This mod answers the compaction itself:
9// 1. Whenever Claude Code compacts (automatically, at its compact window, or
10// because you ran /compact), a FORK of the session writes a handoff along
11// a fixed outline. The fork reads the conversation from the prompt cache
12// and has no tools.
13// 2. The last N prompts and answers are added word for word, plus the paths
14// of files that hold the project's state (from the optional adapter).
15// 3. The conversation is replaced by that one handoff message. The handoff is
16// also saved as a Markdown file.
17// 4. With `trigger: self` the mod also decides WHEN: at `threshold` % of the
18// compact window, after the turn. Claude Code then skips this mod's own
19// session.compact hook (it raised the event itself), so Claude Code writes
20// its summary and the handoff follows as the next message: two summaries.
21// Hence the default `core`: let Claude Code trigger, answer with the handoff.
22// Optionally the session continues on its own after a self-triggered one.
23//
24// Safety net: if the fork fails (cold cache, API error) the mod hands the
25// compaction back to Claude Code, with the outline as instructions. If this
26// module throws, Claude Code skips it and compacts as usual.
27//
28// Customizing: everything a user is likely to change is an option in
29// plugin.json (userConfig) or a function below with a comment saying what it
30// decides. The adapter (see README.md) connects the mod to your own setup
31// without editing this file.
32
33// The outline every handoff must follow. Replace it with the `outlineFile`
34// option rather than editing it here, so updates of the mod don't undo it.
35const DEFAULT_OUTLINE = [
36 '1. GOAL: what should exist at the end, in one sentence.',
37 '2. STATE: what is done, and what proves it (test, command, commit).',
38 '3. IN PROGRESS: which step, which file, and why this one.',
39 '4. NEXT STEP: concrete enough for a stranger to carry out.',
40 '5. DECISIONS AND REASONS: what the code itself does not tell.',
41 '6. RULED OUT: approaches tried or rejected, so nobody tries them again.',
42 '7. BLOCKED / OPEN QUESTIONS: including what you meant to ask the user.',
43 '8. FILES AND COMMITS TOUCHED.',
44 '9. VERIFY: the command that shows whether the state is what this says.',
45]
46
47const CONTINUE_TEXT =
48 'The session was compacted; the handoff above replaces the earlier conversation. ' +
49 'Read the state files it lists first, then carry on with its NEXT STEP.'
50
51const WAIT_TEXT =
52 'The session was compacted; the handoff above replaces the earlier conversation. ' +
53 'Reply only with "Handoff read." and wait for the next instruction.'
54
55const ADAPTER_CACHE_MS = 5 * 60 * 1000
56
57let opts = { trigger: 'core', threshold: 70, window: 0, keepVerbatim: 10, autoContinue: 'never', precompute: 'skip', handoffDir: '', outlineFile: '', adapter: '' }
58let armed = true // false while a compaction this mod started is running
59let due = false // the threshold was crossed; compact when the main turn ends
60let cooldownUntil = 0 // after a failed attempt, don't retry on every turn
61let adapterCache = null // { at, value } from the last 'check' call
62
63// ── Small helpers ──────────────────────────────────────────────────────────────
64function parseWindow(raw) {
65 // Same grammar as Claude Code's --autocompact: "200000", "200k", "200" (= thousands)
66 if (raw === undefined || raw === null || raw === '') return null
67 const s = String(raw).trim()
68 if (!s || s.length > 8) return null
69 const suffix = /[kK]$/.test(s)
70 const digits = suffix ? s.slice(0, -1) : s
71 if (!/^\d+$/.test(digits)) return null
72 let n = parseInt(digits, 10)
73 if (suffix || n <= 1000) n *= 1000
74 return n >= 20000 && n <= 1000000 ? n : null
75}
76
77async function readJson($, path) {
78 try {
79 if (!(await $.fs.exists(path))) return null
80 return JSON.parse(await $.fs.read(path))
81 } catch {
82 return null
83 }
84}
85
86function expandHome(path, home) {
87 return path && path.startsWith('~/') ? home + path.slice(1) : path
88}
89
90function stripReminders(text) {
91 return String(text || '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
92}
93
94function clip(text, max) {
95 return text.length > max ? text.slice(0, max) + ' […]' : text
96}
97
98// ── The adapter: your setup's answers, as JSON over stdin/stdout ───────────────
99// Called with { version, phase, sessionId, cwd, trigger, context } on stdin.
100// May answer { window, threshold, stateFiles: [{label, path}], autoContinue, notes }.
101// Any failure (missing, non-zero exit, bad JSON, timeout) counts as "no answer".
102async function askAdapter($, phase, extra) {
103 if (!opts.adapter) return {}
104 if (phase === 'check' && adapterCache && Date.now() - adapterCache.at < ADAPTER_CACHE_MS) return adapterCache.value
105 let value = {}
106 try {
107 const home = await $.env.get('HOME')
108 const input = JSON.stringify({ version: 1, phase, sessionId: await $.session.id(), cwd: await $.session.cwd(), ...extra })
109 const r = await $.process.run([expandHome(opts.adapter, home)], { stdin: input, timeoutMs: 10000 })
110 if (r.exitCode === 0 && r.stdout.trim()) value = JSON.parse(r.stdout) || {}
111 } catch (err) {
112 $.ui.log('handoff-compact: adapter failed: ' + String(err), { to: 'debug' })
113 }
114 if (phase === 'check') adapterCache = { at: Date.now(), value }
115 return value
116}
117
118// Which window the threshold is measured against. Decides WHEN this mod acts.
119async function compactWindow($, adapter, contextWindow) {
120 if (opts.window) return opts.window
121 const fromAdapter = parseWindow(adapter.window)
122 if (fromAdapter) return fromAdapter
123 const fromEnv = parseWindow(await $.env.get('CLAUDE_CODE_AUTO_COMPACT_WINDOW'))
124 if (fromEnv) return fromEnv
125 const settings = await readJson($, (await $.env.get('HOME')) + '/.claude/settings.json')
126 return (settings && parseWindow(settings.autoCompactWindow)) || contextWindow || null
127}
128
129// ── Building the handoff ───────────────────────────────────────────────────────
130async function outline($) {
131 if (!opts.outlineFile) return DEFAULT_OUTLINE
132 try {
133 const text = await $.fs.read(expandHome(opts.outlineFile, await $.env.get('HOME')))
134 const lines = text.split('\n').map(l => l.trimEnd()).filter(l => l.trim())
135 return lines.length ? lines : DEFAULT_OUTLINE
136 } catch {
137 return DEFAULT_OUTLINE
138 }
139}
140
141const HANDOFF_PREFIX = '[handoff-compact]'
142
143// The latest n prompts and answers, verbatim: the part of the handoff that
144// doesn't depend on the model remembering it. A previous handoff in the
145// conversation is no prompt of the user's: its own verbatim tail is carried
146// over instead, so the user's words survive any number of compactions.
147function verbatimTail(messages, n) {
148 if (!n) return { prompts: [], answers: [] }
149 const list = Array.isArray(messages) ? messages : []
150 const prompts = []
151 const answers = []
152 for (const m of list) {
153 const text = stripReminders(m.text)
154 if (m.role === 'user' && !(m.toolResults && m.toolResults.length)) {
155 if (text.startsWith(HANDOFF_PREFIX)) {
156 const carried = carriedTail(text)
157 prompts.push(...carried.prompts)
158 answers.push(...carried.answers)
159 } else if (text) prompts.push(clip(text, 1000))
160 } else if (m.role === 'assistant' && text) answers.push(clip(text, 600))
161 }
162 return { prompts: prompts.slice(-n), answers: answers.slice(-n) }
163}
164
165// Reads the verbatim tail back out of a handoff this mod wrote (see the
166// sections at the end of buildHandoff).
167function carriedTail(text) {
168 const p = [...text.matchAll(/^## Last \d+ user prompts \(verbatim, oldest first\)$/gm)].pop()
169 const a = [...text.matchAll(/^## Last \d+ answers \(shortened, oldest first\)$/gm)].pop()
170 if (!p || !a || a.index < p.index) return { prompts: [], answers: [] }
171 const prompts = text
172 .slice(p.index + p[0].length, a.index)
173 .split(/\n\s*\n/)
174 .map(block => block.split('\n').filter(l => l.startsWith('>')).map(l => l.replace(/^> ?/, '')).join('\n').trim())
175 // A handoff that 0.1 quoted as a prompt is no prompt of the user's either.
176 .filter(t => t && !t.startsWith(HANDOFF_PREFIX))
177 const answers = text
178 .slice(a.index + a[0].length)
179 .split('\n')
180 .filter(l => l.startsWith('- '))
181 .map(l => l.slice(2))
182 return { prompts, answers }
183}
184
185function forkPrompt(sections, instructions) {
186 return [
187 'Write the handoff for this session NOW. It is about to replace the entire conversation;',
188 'whatever it leaves out is gone afterwards. Use no tools, reply with the handoff only.',
189 'Prefer exact paths, commands, commit hashes and numbers over descriptions.',
190 'The latest user message wins: if it changes the task or the plan, the next step follows it.',
191 'Write in the language the conversation is in.',
192 '',
193 'Outline. Every section must appear, with "nothing" if that is the answer:',
194 ...sections,
195 ...(instructions ? ['', 'When compacting, the user asked to stress: ' + instructions] : []),
196 ].join('\n')
197}
198
199async function buildHandoff($, messages, trigger, instructions) {
200 const sections = await outline($)
201 const fork = await $.model.fork({ prompt: forkPrompt(sections, instructions) })
202 if (!fork || !fork.text || !fork.text.trim()) return null
203 const home = await $.env.get('HOME')
204 const id = await $.session.id()
205 const adapter = await askAdapter($, 'compact', { trigger })
206 const files = Array.isArray(adapter.stateFiles) ? adapter.stateFiles.filter(f => f && f.path) : []
207 const tail = verbatimTail(messages, opts.keepVerbatim)
208 const now = new Date().toISOString()
209
210 const doc = [
211 '# Handoff ' + now + ' (' + trigger + ')',
212 '',
213 fork.text.trim(),
214 ...(adapter.notes ? ['', String(adapter.notes).trim()] : []),
215 '',
216 '## State files: read these before continuing',
217 ...(files.length ? files.map(f => '- ' + (f.label || 'file') + ': ' + f.path) : ['- none listed']),
218 '',
219 '## Last ' + tail.prompts.length + ' user prompts (verbatim, oldest first)',
220 ...tail.prompts.map(t => '> ' + t.replace(/\n/g, '\n> ') + '\n'),
221 '## Last ' + tail.answers.length + ' answers (shortened, oldest first)',
222 ...tail.answers.map(t => '- ' + t.replace(/\n/g, ' ')),
223 ].join('\n')
224
225 // The file is a copy for people and for later. If writing it fails, the
226 // message below still carries the handoff, so this doesn't abort.
227 const dir = expandHome(opts.handoffDir, home) || home + '/.claude/handoffs'
228 const path = dir + '/' + id + '-' + now.replace(/[:.]/g, '-') + '.md'
229 let saved = false
230 try {
231 await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5000 })
232 await $.fs.write(path, doc + '\n')
233 saved = true
234 } catch {}
235
236 const message = HANDOFF_PREFIX + ' This session was compacted. The handoff below replaces the earlier conversation' + (saved ? ' (saved at ' + path + ')' : '') + '.\n\n' + doc
237 return { message, path: saved ? path : null, usage: fork.usage, autoContinue: adapter.autoContinue === true }
238}
239
240// "/compact classic [what to stress]": Claude Code's own summary, this once.
241// Only for the person's /compact, and the keyword has to come first and stand
242// alone, so "/compact classical music" is a normal handoff.
243function classicRequest(e) {
244 if (e.trigger !== 'manual' || typeof e.instructions !== 'string') return null
245 const m = /^\s*classic(?:\s+([\s\S]*))?$/i.exec(e.instructions)
246 return m ? { rest: (m[1] || '').trim() } : null
247}
248
249// Whether to start the next turn by itself after a compaction this mod started.
250function shouldContinue(handoff) {
251 if (opts.autoContinue === 'always') return true
252 if (opts.autoContinue === 'adapter') return !!(handoff && handoff.autoContinue)
253 return false
254}
255
256// ── Compacting on our own, between turns ──────────────────────────────────────
257async function run($) {
258 try {
259 const handoff = await buildHandoff($, await $.session.messages(), 'plugin')
260 // Claude Code skips this mod's own session.compact hook for a compaction
261 // the mod raised, so Claude Code summarizes; without a handoff, at least
262 // along the outline.
263 const r = await $.session.compact(handoff ? {} : { instructions: (await outline($)).join('\n') })
264 if (r && r.skip) {
265 $.ui.log('handoff-compact: compaction refused: ' + r.skip, { to: 'debug' })
266 cooldownUntil = Date.now() + 10 * 60 * 1000
267 return
268 }
269 // The handoff follows the summary as the next prompt, so it isn't lost. A
270 // prompt always starts a turn; without autoContinue that turn only
271 // acknowledges it.
272 if (handoff) {
273 $.prompt.submit({ text: handoff.message + '\n\n' + (shouldContinue(handoff) ? CONTINUE_TEXT : WAIT_TEXT) })
274 $.ui.toast('Compacted into a handoff' + (handoff.path ? ': ' + handoff.path : ''))
275 } else if (shouldContinue(handoff)) {
276 $.prompt.submit({ text: CONTINUE_TEXT })
277 }
278 } catch (err) {
279 // Typically a new turn had already started (compact refuses then).
280 // Try again at a later turn end, not on every turn.
281 $.ui.log('handoff-compact: ' + String(err), { to: 'debug' })
282 cooldownUntil = Date.now() + 2 * 60 * 1000
283 } finally {
284 armed = true
285 }
286}
287
288export function register(on, options) {
289 opts = { ...opts, ...(options || {}) }
290
291 if (opts.trigger === 'self') registerSelfTrigger(on)
292
293 // Every compaction passes here: Claude Code's own, /compact, and (from
294 // another plugin's call) a plugin's. Not one this mod raised itself.
295 on('session.compact', async ($, e, next) => {
296 if (e.agentId) return next(e) // subagents compact their own transcript the usual way
297 if (e.trigger === 'precompute') {
298 return opts.precompute === 'core' ? next(e) : { skip: 'handoff-compact writes the handoff at compaction time' }
299 }
300 const classic = classicRequest(e)
301 if (classic) {
302 const { instructions, ...plain } = e
303 return next(classic.rest ? { ...e, instructions: classic.rest } : plain)
304 }
305 const handoff = await buildHandoff($, e.messages, e.trigger, e.instructions)
306 if (!handoff) {
307 const sections = (await outline($)).join('\n')
308 const instructions = e.instructions ? e.instructions + '\n\n' + sections : 'Structure the summary exactly along these sections:\n' + sections
309 return next({ ...e, instructions })
310 }
311 return { messages: [{ role: 'user', text: handoff.message, toolUses: [] }] }
312 })
313}
314
315// trigger: self. Claude Code measures the context after every response, i.e.
316// also in the middle of a turn, while $.session.compact() is refused. So the
317// measurement only marks the compaction as due; turn.complete starts it.
318function registerSelfTrigger(on) {
319 on('session.measure', async ($, e, next) => {
320 const result = await next(e)
321 if (!armed || due || Date.now() < cooldownUntil) return result
322 const used = e.context && e.context.tokens
323 if (!used) return result
324 const adapter = await askAdapter($, 'check', { trigger: null, context: e.context })
325 const window = await compactWindow($, adapter, e.context.window)
326 const threshold = Number(adapter.threshold) || opts.threshold
327 if (!window || (100 * used) / window < threshold) return result
328 due = true
329 return result
330 })
331
332 on('turn.complete', async ($, e, next) => {
333 const result = await next(e)
334 // A subagent's turn is not the end of the session's turn; an interrupted or
335 // failed turn means someone is busy with the session: wait for the next end.
336 if (e.agentId || !due || !armed || e.reason !== 'answer') return result
337 due = false
338 armed = false
339 // Not inside this hook: the turn still ends through it.
340 $.clock.after(0, () => run($))
341 return result
342 })
343}
344