SLOPSHOPPER

handoff

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

newguardtoastpromptmodel
v0.1.0MITupdated 2026-10-22kumpan/claude-mods/handoff
A shopper browsing a rack in a slop shop
README

handoff

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.

What happens on a compaction

Whenever Claude Code compacts (automatically when the context fills up, or when you run /compact):

  1. A fork of the session writes a handoff along a fixed outline: goal, state and what proves it, in progress, next step, decisions and reasons, ruled out, open questions, files and commits, how to verify. The fork reads the conversation from the prompt cache and has no tools.
  2. Your last 10 prompts and Claude's last 10 answers are added word for word (answers shortened).
  3. The conversation is replaced by that one message, and a copy is saved to ~/.claude/handoffs/<session>-<time>.md. A toast shows the path.
  4. The first time Claude tries to use a tool afterwards, you get a question: Continue or Stop and wait.
  5. Continue: it carries on from the handoff.
  6. Stop and wait: every tool is refused until you write again. Claude says in two lines where things stand and waits.
  7. If you type something yourself instead, that counts as your answer: no question.

Text after /compact (e.g. /compact keep the test plan) is passed to the fork as what to stress.

Good to know

  • If the fork fails (cold cache, API error), Claude Code compacts the usual way, told to follow the same outline. You still get the question afterwards.
  • Headless runs (claude -p, scheduled jobs): there is nobody to answer, so it stops. Don't install it where sessions must carry on unattended.
  • Subagents compact their own transcripts the usual way and are never asked about.
  • Claude Code's background pre-compaction is turned off, since its result would be thrown away.
  • The handoff costs one model call over the cached conversation, about what Claude Code's own summary costs.
  • Handoff files are never cleaned up; delete old ones in ~/.claude/handoffs when you like.
  • Prompts from before an earlier compaction are not carried into the next handoff's word-for-word part; the handoff text itself carries them on.

Files

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