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…

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.
When the context window hits a threshold (50% by default):
.claude/handoff.md), and drafts compaction instructions plus a kickoff prompt./compact with those instructions, so the summary keeps what matters.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.
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./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.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).[context-handoff] Context window: 34% used (68.0k of 200.0k); handoff at 50%. Your own text isn't changed./compact, once per handoff, after Claude has called handoff_ready and the turn has finished. It passes Claude's compaction instructions as the argument.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./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.MIT
hooks/register.ts 302 lines1import { 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}
302types/index.d.ts 18 lines1// 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