SLOPSHOPPER

context-handoff

Hands a long session off cleanly: at a context threshold Claude updates the docs and writes a handoff, the session compacts with its instructions, then a…

newguardcommandtoaststatusprompt
★ 1v0.1.1MITupdated 2026-10-04Akash001uts/claude-mods/plugins/context-handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-handoff
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /handoff ⎿ context-handoff: Handoff started. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ context-handoff: Handoff: updating docs…
README

context-handoff

Once a session gets long, Claude tends to get worse at keeping track of things, and auto-compact doesn't always keep the parts you actually need. This mod hands the work off properly instead. It's for longer pieces of work that don't fit in one context window, like a full assignment, a project spread over a few days, or a big refactor. Without it I was writing "here's where we're up to" notes by hand before every compact.

How it works

When the context window hits a threshold (50% by default):

  1. Claude gets asked to wrap up. It updates the docs your project already has, writes a handoff file (.claude/handoff.md), and drafts compaction instructions plus a kickoff prompt.
  2. The mod runs /compact with those instructions, so the summary keeps what matters.
  3. Once compaction finishes, it sends the kickoff prompt and Claude picks up from the handoff file.

It also lets Claude see how full the context is. Each prompt gets a hidden line like Context window: 34% used (68.0k of 200.0k), and Claude's told to suggest wrapping up (once, and only when a task is actually finished) if the window is filling up. If you run /context-handoff:wrap-up yourself at the end of a session, the kickoff prompt will be waiting in the prompt box next time you open a session in that project.

Commands and settings

  • The status line shows Handoff at 50% so you know it's armed, and shows what step it's on while a handoff runs.
  • /handoff starts a handoff straight away, whatever the context level.
  • /handoff 60 (or /handoff at 60%) changes when the automatic handoff starts. Anything from 5% to 95% works, and it saves to the plugin's settings so it sticks between sessions.
  • /handoff off and /handoff on pause or resume the automatic handoff for the current session.
  • /handoff status shows the current reading and settings, and /handoff resume puts a saved kickoff prompt back in the box.
  • In /config you can change mode: auto (the default) does everything itself, ask puts the handoff prompt in the box for you to send, and off just shows Claude the reading. handoffPath changes where the handoff file goes.
  • It only hands off from the main conversation, and only after a turn finishes normally. If you interrupt a turn it cancels, and Claude Code's own auto-compact never triggers the kickoff prompt.

What it runs and stores

  • It reads the session's context usage from Claude Code (percentage used, tokens used, window size). It doesn't send anything outside Claude Code.
  • Prompts it submits. When a handoff starts, it submits the wrap-up prompt. That prompt contains the current context percentage and window size, the handoff file path, and the steps above, nothing else. After compaction it submits the kickoff prompt Claude wrote. In ask mode it only fills the prompt box and you decide whether to send. At the start of a session it can also put a saved kickoff prompt in the prompt box (it doesn't send it).
  • What it changes in prompts. Each prompt you send gets one extra hidden line with the context reading, like [context-handoff] Context window: 34% used (68.0k of 200.0k); handoff at 50%. Your own text isn't changed.
  • What it adds to the system prompt. A short guide telling Claude what that line means, to suggest wrapping up once at the natural end of a task, and where the handoff file goes.
  • Commands it runs. Only /compact, once per handoff, after Claude has called handoff_ready and the turn has finished. It passes Claude's compaction instructions as the argument.
  • Its own tool. It adds one tool, handoff_ready, which Claude calls at the end of a wrap-up. The mod answers that call itself: it saves the kickoff prompt and compaction instructions (or, at the end of a session, saves the kickoff prompt for next time) and replies to Claude. It doesn't touch any other tool. Your normal permission rules apply to it, so you may get a prompt the first time; add mcp__context-handoff__handoff_ready to your allow rules if you don't want to be asked.
  • Settings it changes. /handoff 60 saves the threshold as this plugin's threshold setting (the same one in /config). It doesn't set anything else or touch environment variables.
  • Claude (not the mod) edits your project's existing docs and writes the handoff file during a wrap-up, using its normal tools, so your usual permission prompts still apply.
  • The kickoff prompt is saved in Claude Code's plugin storage, keyed to the project, so it can be offered next session. It's deleted once it's used.

Licence

MIT

Source 2 files
hooks/register.ts 302 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pending, Phase, Saved } from '../types'
5
6const PLUGIN = 'context-handoff'
7const TOOL = 'handoff_ready'
8export const TOOL_NAME = 'mcp__context-handoff__handoff_ready'
9const SKILL = `${PLUGIN}:wrap-up`
10
11const isAuto = atom({ plugin: 'context-handoff', key: 'isAuto' } as const, true)
12const phase = atom({ plugin: 'context-handoff', key: 'phase' } as const, 'idle')
13const hasFired = atom({ plugin: 'context-handoff', key: 'hasFired' } as const, false)
14const pending = atom({ plugin: 'context-handoff', key: 'pending' } as const, null)
15
16const STATUS: Record<Exclude<Phase, 'idle'>, string> = {
17  wrapping: 'Handoff: updating docs…',
18  ready: 'Handoff: compacting when this turn ends',
19  compacting: 'Handoff: compacting…',
20}
21
22export const short = (n: number) =>
23  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
24
25// The kickoff prompt saved for the next session in this project.
26const savedKey = (root: string) => `kickoff:${root.replace(/\\/g, '/').toLowerCase()}`
27
28const cfg = { threshold: 50, mode: 'auto' as 'auto' | 'ask' | 'off', handoffPath: '.claude/handoff.md' }
29let isInteractive = false
30
31
32// Constant for the session, so it sits after the cache boundary without busting it turn to turn.
33const guide = () => [
34  '# Context handoff',
35  'Each user message carries a `[context-handoff]` line saying how full the context window is. Use it to judge how long the session has run; do not comment on it otherwise.',
36  `- When the current task reaches its natural end state (what was asked for is done and checked) and the window is past about 30%, suggest once, in one short line, wrapping up: \`/${SKILL}\` writes a handoff for a fresh session, \`/handoff\` compacts and carries on here. Never suggest it mid-task, never twice for the same task, and drop it if the user keeps going.`,
37  `- A prompt from the ${PLUGIN} plugin asking for a handoff means the window crossed ${cfg.threshold}%: follow it, call \`${TOOL_NAME}\` last, then end your turn.`,
38  `- This project's handoff file is \`${cfg.handoffPath}\` (relative to the project root).`,
39].join('\n')
40
41// Always on show, like the context bar: armed at its threshold, off, or which step a handoff is on.
42async function showStatus($: EngineInterface) {
43  const now = await read($, phase)
44  if (now !== 'idle') return $.ui.status(STATUS[now])
45  if (!isInteractive) return
46  const isArmed = cfg.mode !== 'off' && (await read($, isAuto))
47  await $.ui.status(isArmed ? `Handoff at ${cfg.threshold}%${cfg.mode === 'ask' ? ' (ask)' : ''}` : 'Handoff off')
48}
49
50async function setPhase($: EngineInterface, next: Phase) {
51  await update($, phase, () => next)
52  await showStatus($)
53}
54
55async function reading($: EngineInterface) {
56  const { context } = await $.session.usage()
57  return { percent: context.percent, tokens: context.tokens, window: context.window }
58}
59
60const handoffPrompt = (percent: number | undefined, window: number) =>
61  [
62    `The context window is at ${percent ?? '?'}% of ${short(window)}. Time to hand off before it compacts.`,
63    `Run the \`${SKILL}\` skill in continue mode. If it isn't available, do this:`,
64    '1. Update the docs this project already keeps (status, pending work, decisions) to match where things stand. Don\'t create new ones.',
65    `2. Write the handoff file at \`${cfg.handoffPath}\`: goal, where things stand, next steps in order, key files, decisions and why, open questions.`,
66    '3. Draft compaction instructions: what the summary must keep (the task, decisions, file paths, next step) and what it can drop.',
67    `4. Draft a kickoff prompt that resumes the work from \`${cfg.handoffPath}\` with no other context.`,
68    `5. Call \`${TOOL_NAME}\` with mode "continue", then end your turn. The plugin compacts and sends the kickoff prompt itself.`,
69  ].join('\n')
70
71async function startHandoff($: EngineInterface, how: 'auto' | 'ask') {
72  const { percent, window } = await reading($)
73  const text = handoffPrompt(percent, window)
74  if (how === 'ask') {
75    // Never overwrite a half-typed prompt: say so and leave /handoff to the person.
76    if ((await $.prompt.read()).text.trim()) {
77      await $.ui.toast(`Context at ${percent ?? '?'}%: run /handoff when you're ready`)
78      return
79    }
80    await $.prompt.fill({ text, mode: 'replace' })
81    await $.ui.toast(`Context at ${percent ?? '?'}%: press Enter to hand off`)
82    return
83  }
84  await setPhase($, 'wrapping')
85  // From a timer: a submit from inside a command.run hook would wait on the turn that hook holds.
86  $.clock.after(0, () => {
87    void $.prompt.submit({ text }).catch(() => cancel($, "Handoff couldn't start: run /handoff to try again"))
88  })
89}
90
91async function cancel($: EngineInterface, why: string) {
92  await update($, pending, () => null)
93  await update($, hasFired, () => true)
94  await setPhase($, 'idle')
95  await $.ui.toast(why)
96}
97
98async function fillSaved($: EngineInterface) {
99  const key = savedKey(await $.session.root())
100  const saved = (await $.store.get(key)) as Saved | undefined
101  if (!saved?.kickoff) return false
102  const { isFilled } = await $.prompt.fill({ text: saved.kickoff, mode: 'replace' })
103  if (!isFilled) return false
104  await $.store.delete(key)
105  await $.ui.toast('Kickoff prompt from your last session is in the prompt box')
106  return true
107}
108
109// Saved as the plugin's threshold setting, as /config would; the change reloads the module with it.
110// Where the setting can't be written, it holds for this session only.
111async function setThreshold($: EngineInterface, percent: number) {
112  if (percent < 5 || percent > 95) return 'Pick a threshold between 5% and 95%.'
113  cfg.threshold = percent
114  await update($, isAuto, () => true)
115  await update($, hasFired, () => false)
116  const saved = await $.config.set({ key: 'context-handoff.threshold', value: percent }).catch((err: unknown) => ({ deny: String(err) }))
117  const where = saved.deny === undefined ? 'Saved for future sessions too.' : `For this session only (couldn't save the setting: ${saved.deny}).`
118  const off = cfg.mode === 'off' ? ' Mode is off in /config, so set it to auto or ask for this to take effect.' : ''
119  const now = (await reading($)).percent
120  const soon = now !== undefined && now >= percent ? ` The window is already at ${now}%, so it starts after your next turn.` : ''
121  await showStatus($)
122  return `Auto handoff at ${percent}%. ${where}${off}${soon}`
123}
124
125export const register: Register = (on, options) => {
126  cfg.threshold = Number(options.threshold ?? 50)
127  // The setting is free text, so anything but ask or off means auto.
128  const mode = String(options.mode ?? 'auto').trim().toLowerCase()
129  cfg.mode = mode === 'ask' || mode === 'off' ? mode : 'auto'
130  cfg.handoffPath = String(options.handoffPath ?? '.claude/handoff.md')
131
132  on('session.start', async ($, e, next) => {
133    isInteractive = e.isInteractive
134    // On from the first prompt, like the context bar, and says so in the status line.
135    await showStatus($)
136    await $.command.register({
137      name: 'handoff',
138      description: 'Hand off now; /handoff 60 sets when the auto handoff kicks in; also on | off | status | resume',
139      argumentHint: '[now | <percent> | on | off | status | resume]',
140    })
141    await $.tool.register({
142      name: TOOL,
143      description:
144        'Call last in a handoff, once the docs and the handoff file are written. mode "continue": the plugin compacts the conversation with compactInstructions, then sends kickoffPrompt to carry on. mode "end": the session is finishing; kickoffPrompt is put in the prompt box when the next session opens in this project.',
145      inputSchema: {
146        type: 'object',
147        properties: {
148          mode: { type: 'string', enum: ['continue', 'end'] },
149          handoffPath: { type: 'string', description: 'The handoff file written, relative to the project root' },
150          compactInstructions: { type: 'string', description: 'What the compaction summary must keep (mode "continue")' },
151          kickoffPrompt: { type: 'string', description: 'The prompt that resumes the work from the handoff file' },
152        },
153        required: ['mode', 'handoffPath', 'kickoffPrompt'],
154      },
155    })
156    const started = await next(e)
157    // A kickoff saved by the last session in this project goes in the prompt box; the box may not be up yet.
158    if (isInteractive)
159      void fillSaved($).then(isDone => {
160        if (!isDone) $.clock.after(1500, () => void fillSaved($).catch(() => {}))
161      }, () => {})
162    return started
163  })
164
165  // Claude sees the reading with every prompt; the person doesn't.
166  on('prompt.submit', async ($, e, next) => {
167    const { percent, tokens, window } = await reading($).catch(() => ({ percent: undefined, tokens: undefined, window: 0 }))
168    if (window <= 0) return next(e)
169    const auto = cfg.mode !== 'off' && (await read($, isAuto)) ? `; handoff at ${cfg.threshold}%` : ''
170    const line =
171      percent === undefined
172        ? `[context-handoff] Context window: not measured yet since the start or the last compaction (${short(window)} window)${auto}.`
173        : `[context-handoff] Context window: ${percent}% used (${short(tokens ?? 0)} of ${short(window)})${auto}.`
174    return next({ ...e, context: [...(e.context ?? []), line] })
175  })
176
177  on('prompt.compose', async ($, e, next) => {
178    const composed = await next(e)
179    return { ...composed, sections: [...composed.sections, { id: `${PLUGIN}:guide`, text: guide(), scope: 'session' as const }] }
180  })
181
182  // The plugin's own tool is always in the model's list; whether it prompts is up to the person's permission rules.
183  on('tool.describe', { tool: 'mcp__context-handoff__handoff_ready' }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
184
185  // A regex: the typed matcher only names tools that were connected when the types were last laid.
186  on('tool.call', { tool: /^mcp__context-handoff__handoff_ready$/ }, async ($, e) => {
187    if (e.agentId) return { deny: 'Only the main conversation hands off.' }
188    const input = e as unknown as Record<string, unknown>
189    const kickoff = typeof input.kickoffPrompt === 'string' ? input.kickoffPrompt.trim() : ''
190    const path = typeof input.handoffPath === 'string' ? input.handoffPath : cfg.handoffPath
191    if (!kickoff) return { deny: 'kickoffPrompt is empty.' }
192
193    if (input.mode === 'end') {
194      const saved: Saved = { kickoff, path, savedAt: await $.clock.now() }
195      await $.store.set(savedKey(await $.session.root()), saved)
196      await setPhase($, 'idle')
197      return { result: 'Saved. The kickoff prompt goes in the prompt box when the next session opens in this project. Show the user the kickoff prompt too, then stop.' }
198    }
199
200    const instructions = typeof input.compactInstructions === 'string' ? input.compactInstructions.trim() : ''
201    const next: Pending = { instructions, kickoff, path }
202    await update($, pending, () => next)
203    await setPhase($, 'ready')
204    return { result: 'Saved. Compaction starts when this turn ends, then the kickoff prompt is sent. End your turn now with a one-line summary and no more tool calls.' }
205  })
206
207  on('turn.complete', async ($, e, next) => {
208    const done = await next(e)
209    if (e.agentId) return done
210    const now = await read($, phase)
211
212    if (e.isAborted || e.reason !== 'answer') {
213      if (now === 'wrapping' || now === 'ready') await cancel($, 'Handoff cancelled: run /handoff to start it again')
214      return done
215    }
216
217    if (now === 'ready') {
218      const p = await read($, pending)
219      if (!p) {
220        await setPhase($, 'idle')
221        return done
222      }
223      await setPhase($, 'compacting')
224      // From a timer, outside the hook the turn waits on; the run itself waits for the session to go idle.
225      $.clock.after(0, () => {
226        void $.command
227          .run({ command: 'compact', args: p.instructions })
228          .catch(() => cancel($, 'Handoff: compaction failed. Run /compact yourself, then paste the kickoff prompt'))
229      })
230      return done
231    }
232
233    if (now === 'wrapping') {
234      await cancel($, "Handoff didn't finish (no handoff tool call): run /handoff to try again")
235      return done
236    }
237
238    if (now !== 'idle' || !isInteractive || cfg.mode === 'off' || !(await read($, isAuto))) return done
239    const { percent } = await reading($)
240    if (percent === undefined) return done
241    if (percent < cfg.threshold) {
242      if (await read($, hasFired)) await update($, hasFired, () => false)
243      return done
244    }
245    if (await read($, hasFired)) return done
246    await update($, hasFired, () => true)
247    await startHandoff($, cfg.mode === 'ask' ? 'ask' : 'auto')
248    return done
249  })
250
251  on('session.compact', async ($, e, next) => {
252    if (e.agentId || e.trigger === 'precompute') return next(e)
253    const now = await read($, phase)
254    const p = await read($, pending)
255    const r = await next(e)
256    if (r.skip !== undefined) {
257      if (now === 'compacting') await cancel($, `Handoff: compaction skipped (${r.skip})`)
258      return r
259    }
260    // A fresh window: the threshold can fire again.
261    await update($, hasFired, () => false)
262    // Only a compaction this plugin started sends the kickoff; the engine's own autocompact doesn't.
263    if (now === 'compacting' && p) {
264      await update($, pending, () => null)
265      await setPhase($, 'idle')
266      // From a timer: this compaction runs under /compact's command.run, which a submit here would wait on.
267      $.clock.after(0, () => {
268        void $.prompt.submit({ text: p.kickoff }).catch(() => $.ui.toast('Handoff: compacted, but the kickoff prompt failed. Paste it from the handoff file'))
269      })
270    }
271    return r
272  })
273
274  on('session.end', async ($, e, next) => {
275    // /clear starts a new window too.
276    await update($, hasFired, () => false)
277    return next(e)
278  })
279
280  on('command.run', { command: 'handoff' }, async ($, e) => {
281    const arg = e.args.trim().toLowerCase() || 'now'
282    if (arg === 'on' || arg === 'off') {
283      await update($, isAuto, () => arg === 'on')
284      await showStatus($)
285      return { text: arg === 'on' ? `Auto handoff on at ${cfg.threshold}%.` : 'Auto handoff off for this session.' }
286    }
287    if (arg === 'status') {
288      const { percent, window } = await reading($)
289      const auto = cfg.mode === 'off' ? 'off (plugin setting)' : (await read($, isAuto)) ? `${cfg.mode} at ${cfg.threshold}%` : 'off for this session'
290      return { text: `Context ${percent ?? '?'}% of ${short(window)}. Handoff: ${auto}. Now: ${await read($, phase)}. File: ${cfg.handoffPath}` }
291    }
292    if (arg === 'resume') return { text: (await fillSaved($)) ? 'Kickoff prompt is in the prompt box.' : 'No saved kickoff prompt for this project.' }
293    const at = /^(?:at\s+)?(\d{1,3})\s*%?$/.exec(arg)
294    if (at) return { text: await setThreshold($, Number(at[1])) }
295    if (arg !== 'now') return { text: 'Usage: /handoff [now | <percent> | on | off | status | resume], e.g. /handoff 60' }
296    if ((await read($, phase)) !== 'idle') return { text: 'A handoff is already running.' }
297    await update($, hasFired, () => true)
298    await startHandoff($, 'auto')
299    return { text: 'Handoff started.' }
300  })
301}
302
types/index.d.ts 18 lines
1// idle: watching. wrapping: the handoff prompt is out and Claude is writing the docs.
2// ready: Claude called the tool; compaction starts when its turn ends. compacting: /compact is running.
3export type Phase = 'idle' | 'wrapping' | 'ready' | 'compacting'
4export type Pending = { instructions: string; kickoff: string; path: string }
5export type Saved = { kickoff: string; path: string; savedAt: number }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'context-handoff': {
10      isAuto: boolean
11      phase: Phase
12      // Set once the threshold has fired, so a skipped or cancelled handoff doesn't fire every turn.
13      hasFired: boolean
14      pending: Pending | null
15    }
16  }
17}
18