SLOPSHOPPER

handoff-compact

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…

newtoastmodelprocesstimer
★ 6v0.2.1MITupdated 2026-10-03trytofly94/handoff-compact
A shopper browsing a rack in a slop shop
README

handoff-compact

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, /compact and /compact classic. It needs Claude Code 2.1.287 or later with mods enabled on your account. It passes claude plugin validate --strict and the tests in tests/ (claude plugin test).

Why

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.

How it works

  1. Trigger. Claude Code compacts when the context reaches its compact window, or when you run /compact. The mod answers that compaction instead of Claude Code's summarizer, so every compaction goes through the same path.
  2. Handoff. A fork of the session ($.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.
  3. Verbatim tail. The last N user prompts and answers are added word for word. That part doesn't depend on the model remembering anything.
  4. Replace. The mod answers the session.compact event with that single handoff message. The handoff is also saved as a Markdown file.
  5. Continue (optional). Sessions that run unattended can pick up the work on their own.

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.

Install

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

Tests

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

Triggers

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.

Claude Code's own summary, once

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

Options

Set them in /plugin → configure, in /config, or in pluginConfigs in your settings.

OptionDefaultWhat it does
triggercorecore (Claude Code decides when) or self (the mod does), see Triggers
threshold70self only: compact at this % of the compact window
window0 (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
keepVerbatim10Latest prompts and answers copied word for word
autoContinueneverself 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
precomputeskipskip turns off Claude Code's background pre-compaction, core leaves it on
handoffDir~/.claude/handoffsWhere handoff files are saved
outlineFilebuilt-inText file with the sections every handoff must have, one per line
adapternoneExecutable 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.

The adapter

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.

Customize it with your AI

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:

  • "Compact at 50 % in sessions whose directory is under ~/work/clients."
  • "Add a section 'Customer-facing changes' to the outline."
  • "Also keep the last three tool results verbatim."
  • "Write the handoff into the repo's docs/handoffs/ instead of my home directory."

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.

Limits

  • Runs where mods run: the CLI and the Desktop app's Code tab. In claude -p the hooks run, but nothing is drawn.
  • The handoff costs one fork per compaction. That is mostly cache reads plus the handoff's own output, about the cost of the summary Claude Code would otherwise write.
  • With 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).

License

MIT

Source 1 files
hooks/register.js 344 lines
1// 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