Replaces compaction with a structured handoff, then asks before Claude carries on working.

Replaces Claude Code's compaction summary with a structured handoff, and asks you before Claude carries on working after a compaction.
/plugin install handoff@kumpan-mods
Based on trytofly94/handoff-compact (MIT). The difference: after an automatic compaction, Claude Code normally keeps working straight away from the summary. Here it stops at its first tool call and asks.
Whenever Claude Code compacts (automatically when the context fills up, or when you run /compact):
~/.claude/handoffs/<session>-<time>.md. A toast shows the path.Text after /compact (e.g. /compact keep the test plan) is passed to the fork as what to stress.
claude -p, scheduled jobs): there is nobody to answer, so it stops. Don't install it where sessions must carry on unattended.~/.claude/handoffs when you like.hooks/register.ts: the compaction hook, the question, and the tool gate.test/handoff.test.ts: tests (claude plugin test handoff).LICENSE: MIT, from handoff-compact.hooks/register.ts 118 lines1import type { Engine, Register, SessionMessage } from 'claude-code'
2
3// A compaction replaces the conversation with one handoff message written along this outline,
4// instead of Claude Code's generic summary. Based on trytofly94/handoff-compact (MIT).
5const OUTLINE = [
6 '1. GOAL: what should exist at the end, in one sentence.',
7 '2. STATE: what is done, and what proves it (test, command, commit).',
8 '3. IN PROGRESS: which step, which file, and why this one.',
9 '4. NEXT STEP: concrete enough for a stranger to carry out.',
10 '5. DECISIONS AND REASONS: what the code itself does not tell.',
11 '6. RULED OUT: approaches tried or rejected, so nobody tries them again.',
12 '7. BLOCKED / OPEN QUESTIONS: including what you meant to ask the user.',
13 '8. FILES AND COMMITS TOUCHED.',
14 '9. VERIFY: the command that shows whether the state is what this says.',
15]
16const KEEP_VERBATIM = 10
17const PREFIX = '[handoff]'
18
19const FORK_PROMPT = [
20 'Write the handoff for this session NOW. It is about to replace the entire conversation;',
21 'whatever it leaves out is gone afterwards. Use no tools, reply with the handoff only.',
22 'Prefer exact paths, commands, commit hashes and numbers over descriptions.',
23 'The latest user message wins: if it changes the task or the plan, the next step follows it.',
24 'Write in the language the conversation is in.',
25 '',
26 'Outline. Every section must appear, with "nothing" if that is the answer:',
27 ...OUTLINE,
28].join('\n')
29
30const stripReminders = (t: string) => t.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
31const clip = (t: string, max: number) => (t.length > max ? t.slice(0, max) + ' […]' : t)
32
33// The latest prompts and answers word for word: the part that does not depend on the model remembering.
34// ponytail: an earlier handoff's own tail is not carried over, so prompts older than the last compaction drop out.
35export const verbatimTail = (messages: readonly SessionMessage[]) => {
36 const prompts: string[] = []
37 const answers: string[] = []
38 for (const m of messages) {
39 const text = stripReminders(m.text ?? '')
40 if (!text || text.startsWith(PREFIX)) continue
41 if (m.role === 'user' && !m.toolResults?.length) prompts.push(clip(text, 1000))
42 if (m.role === 'assistant') answers.push(clip(text, 600))
43 }
44 return { prompts: prompts.slice(-KEEP_VERBATIM), answers: answers.slice(-KEEP_VERBATIM) }
45}
46
47const buildHandoff = async ($: Engine, messages: readonly SessionMessage[], trigger: string, instructions?: string) => {
48 const fork = await $.model.fork({
49 prompt: FORK_PROMPT + (instructions ? '\n\nWhen compacting, the user asked to stress: ' + instructions : ''),
50 })
51 if (!fork.isAnswered || !fork.text?.trim()) return null
52 const tail = verbatimTail(messages)
53 const now = new Date().toISOString()
54 const doc = [
55 `# Handoff ${now} (${trigger})`,
56 '',
57 fork.text.trim(),
58 '',
59 `## Last ${tail.prompts.length} user prompts (verbatim, oldest first)`,
60 ...tail.prompts.map(t => '> ' + t.replace(/\n/g, '\n> ') + '\n'),
61 `## Last ${tail.answers.length} answers (shortened, oldest first)`,
62 ...tail.answers.map(t => '- ' + t.replace(/\n/g, ' ')),
63 ].join('\n')
64
65 // A copy for people; the message below carries the handoff even when this fails.
66 const path = `${await $.env.get('HOME')}/.claude/handoffs/${await $.session.id()}-${now.replace(/[:.]/g, '-')}.md`
67 const saved = await $.fs.write(path, doc + '\n').then(() => true, () => false)
68 return {
69 path: saved ? path : undefined,
70 message: `${PREFIX} This session was compacted. The handoff below replaces the earlier conversation${saved ? ` (saved at ${path})` : ''}.\n\n${doc}`,
71 }
72}
73
74export const register: Register = on => {
75 // After a compaction the first tool call asks the user before Claude carries on.
76 // 'ask' until answered; 'stopped' denies every tool until the user writes again.
77 let gate: { state: 'ask' | 'stopped'; path?: string } | undefined
78 let asking: Promise<boolean> | undefined
79
80 on('session.compact', async ($, e, next) => {
81 if (e.agentId) return next(e) // subagents compact their own transcript the usual way
82 if (e.trigger === 'precompute') return { skip: 'handoff writes the handoff at compaction time' }
83 const handoff = await buildHandoff($, e.messages, e.trigger, e.instructions).catch(err => { $.ui.log('handoff: ' + String(err), { to: 'debug' }); return null })
84 gate = { state: 'ask', path: handoff?.path }
85 if (!handoff) {
86 // Fork failed (cold cache, API error): Claude Code summarizes, along the outline.
87 const outline = 'Structure the summary exactly along these sections:\n' + OUTLINE.join('\n')
88 return next({ ...e, instructions: e.instructions ? e.instructions + '\n\n' + outline : outline })
89 }
90 $.ui.toast('Compacted into a handoff' + (handoff.path ? ': ' + handoff.path : ''))
91 return { messages: [{ role: 'user', text: handoff.message, toolUses: [] }] }
92 })
93
94 // Anything the user types themselves is their go-ahead or new direction: no question needed.
95 on('prompt.submit', ($, e, next) => {
96 if (!e.command && !e.isMeta) gate = undefined
97 return next(e)
98 })
99
100 on('tool.call', async ($, e, next) => {
101 if (!gate || e.agentId || e.tool === 'AskUserQuestion') return next(e)
102 if (gate.state === 'ask') {
103 // Parallel tool calls share one question.
104 const question = `Context was compacted into a handoff${gate.path ? ` (${gate.path})` : ''}. Let Claude continue working from it?`
105 asking ??= $.ui
106 .ask(question, { header: 'Handoff', options: ['Continue', 'Stop and wait'] })
107 .then(a => a === 'Continue', () => false) // dismissed, or nobody to ask: stop
108 const go = await asking
109 asking = undefined
110 gate = go ? undefined : { ...gate, state: 'stopped' }
111 if (go) return next(e)
112 }
113 return {
114 deny: 'The user chose to stop after the context was compacted. Run no tools. Say in two lines where things stand (from the handoff) and wait for their next message.',
115 }
116 })
117}
118