Pauses at a context threshold, writes a handoff file, puts a continue prompt on the clipboard, and can start a fresh session from it.

A Claude Code mod. When the context window passes a threshold (default 70%) it pauses the turn, writes a handoff file under .claude/handoffs/, copies a continue prompt to the clipboard, and offers to continue in a fresh session (/clear + the prompt).
Commands: /handoff (hand off now), /handoff-continue, /handoff-off (disable the automatic trigger for this session, e.g. unattended runs), /handoff-on.
Settings (userConfig): threshold, mode (ask | auto | clipboard), folder.
In a terminal Claude Code session:
/plugin install context-handoff --marketplace rperdiga/claude-context-handoff
Answer y to add the marketplace and pick the user scope. It then loads in every session on that machine, including the desktop app's Code tab.
hooks/register.tsx 249 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Handoff } from '../types'
5
6const pending = atom({ plugin: 'context-handoff', key: 'pending' } as const, null)
7const isBusy = atom({ plugin: 'context-handoff', key: 'isBusy' } as const, false)
8const isOff = atom({ plugin: 'context-handoff', key: 'isOff' } as const, false)
9
10const HANDOFF_PROMPT = `The context window is nearly full and this session is being handed off to a fresh one.
11Write a handoff document in Markdown for the next session, which will see nothing of this conversation but this file.
12Include, under headings:
13- Goal: what the user asked for, in their own terms, and any constraints or preferences they stated.
14- Status: what is done, what is in progress (exactly where you stopped), what is left.
15- Key files and locations: absolute paths, functions, commands, URLs that matter.
16- Decisions and findings: what was decided and why, dead ends already ruled out.
17- Next steps: a numbered list the next session should do first.
18- Open questions for the user, if any.
19Reply with the document alone, no preamble.`
20
21const CLIPBOARD_TOOLS = [['clip'], ['pbcopy'], ['wl-copy'], ['xclip', '-selection', 'clipboard']]
22
23type Settings = { threshold: number; mode: string; folder: string }
24
25function joinPath(root: string, rel: string) {
26 const full = /^([a-zA-Z]:[\\/]|\/)/.test(rel) ? rel : `${root.replace(/[\\/]+$/, '')}/${rel}`
27 return /^[a-zA-Z]:\\/.test(full) || root.includes('\\') ? full.replace(/\//g, '\\') : full
28}
29
30function stamp(ms: number) {
31 return new Date(ms).toISOString().replace(/[:.]/g, '-').slice(0, 19)
32}
33
34async function percentOf($: EngineInterface) {
35 return (await $.session.usage()).context.percent ?? 0
36}
37
38// The surface's clipboard first; the desktop app has none yet, so fall back to the OS tool.
39async function copyText($: EngineInterface, text: string) {
40 const copied = await $.ui.copy({ text }).catch(() => ({ isCopied: false }))
41 if (copied.isCopied) return true
42 for (const argv of CLIPBOARD_TOOLS) {
43 const ran = await $.process.run(argv, { stdin: text }).catch(() => undefined)
44 if (ran?.exitCode === 0) return true
45 }
46 return false
47}
48
49// The status line shows 'auto-handoff off' while the automatic trigger is disabled.
50async function showStatus($: EngineInterface) {
51 $.ui.status((await read($, isOff)) ? 'auto-handoff off' : undefined)
52}
53
54async function writeHandoff($: EngineInterface, percent: number, folder: string): Promise<Handoff | undefined> {
55 $.ui.status(`context ${percent}%: writing handoff…`)
56 let reply = await $.model.fork({ prompt: HANDOFF_PROMPT })
57 if (!reply.isAnswered) {
58 // Nothing cached to fork from: summarize the transcript text instead.
59 const messages = await $.session.messages()
60 const transcript = messages
61 .map(m => `${m.role.toUpperCase()}: ${m.text}`)
62 .join('\n\n')
63 .slice(-150_000)
64 reply = await $.model.complete({
65 model: await $.session.model(),
66 system: HANDOFF_PROMPT,
67 prompt: `<transcript>\n${transcript}\n</transcript>`,
68 })
69 }
70 if (!reply.isAnswered) {
71 await showStatus($)
72 $.ui.toast(`context-handoff: could not write the handoff (${reply.reason})`)
73 return undefined
74 }
75
76 const root = await $.session.root()
77 const path = joinPath(root, `${folder}/handoff-${stamp(await $.clock.now())}.md`)
78 const header = `<!-- context-handoff: session ${await $.session.id()} at ${percent}% context -->\n\n`
79 await $.fs.write(path, header + reply.text.trim() + '\n')
80
81 const prompt = `Continue the work from the previous session. Read the handoff file first: ${path}\nThen confirm in two lines where things stand and carry on with the next steps it lists.`
82 const isCopied = await copyText($, prompt)
83 await showStatus($)
84 $.ui.toast(`Handoff written at ${percent}% context: ${path}${isCopied ? ' (continue prompt copied)' : ''}`)
85 return { path, prompt, percent }
86}
87
88async function freshSession($: EngineInterface, handoff: Handoff) {
89 await update($, pending, () => null)
90 await $.command.run({ command: 'clear' })
91 await $.prompt.submit({ text: handoff.prompt, asUser: true })
92}
93
94async function trigger($: EngineInterface, percent: number, settings: Settings) {
95 if ((await read($, isBusy)) || (await read($, pending)) !== null) return
96 await update($, isBusy, () => true)
97 try {
98 const handoff = await writeHandoff($, percent, settings.folder)
99 if (handoff === undefined) return
100 if (settings.mode === 'auto') {
101 await freshSession($, handoff).catch(() =>
102 $.ui.toast('context-handoff: could not start a fresh session; /clear and paste the copied prompt'),
103 )
104 } else if (settings.mode === 'ask') {
105 await update($, pending, () => handoff)
106 }
107 } finally {
108 await update($, isBusy, () => false)
109 }
110}
111
112export const register: Register = (on, options) => {
113 const settings: Settings = {
114 threshold: Number(options.threshold ?? 70),
115 mode: String(options.mode ?? 'ask'),
116 folder: String(options.folder ?? '.claude/handoffs'),
117 }
118
119 let turnId: string | undefined
120 let hasFired = false
121
122 on('session.start', async ($, e, next) => {
123 await $.command.register({
124 name: 'handoff',
125 description: 'Write a handoff file now and copy a continue prompt.',
126 })
127 await $.command.register({
128 name: 'handoff-continue',
129 description: 'Clear and continue from the pending handoff in a fresh session.',
130 })
131 await $.command.register({
132 name: 'handoff-off',
133 description: 'Turn off the automatic handoff for this session (for unattended runs).',
134 })
135 await $.command.register({
136 name: 'handoff-on',
137 description: 'Turn the automatic handoff back on for this session.',
138 })
139 await showStatus($)
140 return next(e)
141 })
142
143 // A /clear starts a new conversation: arm again for it.
144 on('session.end', async ($, e, next) => {
145 hasFired = false
146 return next(e)
147 })
148
149 on('turn.start', async ($, e, next) => {
150 turnId = e.turnId
151 return next(e)
152 })
153
154 // Mid-turn: once a tool call lands over the threshold, stop the turn, then hand off
155 // outside this hook (a hook the turn waits on may not /clear or submit).
156 on('tool.call', async ($, e, next) => {
157 const result = await next(e)
158 if (hasFired || e.agentId !== undefined || (await read($, isOff))) return result
159 const percent = await percentOf($)
160 if (percent >= settings.threshold) {
161 hasFired = true
162 if (turnId !== undefined) await $.turn.abort({ turnId }).catch(() => undefined)
163 $.clock.after(250, () => void trigger($, percent, settings))
164 }
165 return result
166 }).catch(($, e, next) => next(e)) // an observer: never blocks a tool call
167
168 // Between turns: a turn that ended over the threshold without a tool call.
169 on('turn.complete', async ($, e, next) => {
170 const result = await next(e)
171 if (hasFired || e.agentId !== undefined || (await read($, isOff))) return result
172 const percent = await percentOf($)
173 if (percent >= settings.threshold) {
174 hasFired = true
175 $.clock.after(250, () => void trigger($, percent, settings))
176 }
177 return result
178 })
179
180 on('command.run', { command: 'handoff' }, async $ => {
181 const percent = await percentOf($)
182 hasFired = true
183 $.clock.after(250, () => void trigger($, percent, { ...settings, mode: settings.mode === 'auto' ? 'ask' : settings.mode }))
184 return { text: `Writing a handoff at ${percent}% context…` }
185 })
186
187 on('command.run', { command: 'handoff-continue' }, async $ => {
188 const handoff = await read($, pending)
189 if (handoff === null) return { text: 'No pending handoff. Run /handoff first.' }
190 $.clock.after(250, () => void freshSession($, handoff))
191 return { text: `Starting fresh from ${handoff.path}` }
192 })
193
194 on('command.run', { command: 'handoff-off' }, async $ => {
195 await update($, isOff, () => true)
196 await showStatus($)
197 return { text: 'Automatic handoff is off for this session. /handoff still works by hand; /handoff-on turns it back on.' }
198 })
199
200 on('command.run', { command: 'handoff-on' }, async $ => {
201 await update($, isOff, () => false)
202 await showStatus($)
203 return { text: `Automatic handoff is on: it triggers at ${settings.threshold}% context.` }
204 })
205
206 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
207 const busy = await read($, isBusy)
208 const handoff = await read($, pending)
209 if (e.props.hasSurvey || (!busy && handoff === null)) return next(e)
210
211 const { Box, Button, Text } = $.ui.resolve(e)
212 if (handoff === null) {
213 return (
214 <Box>
215 <Text color="warning">Context over {settings.threshold}%: writing handoff…</Text>
216 </Box>
217 )
218 }
219 return (
220 <Box flexDirection="column">
221 <Text color="warning">
222 Context at {handoff.percent}%. Handoff saved: {handoff.path}
223 </Text>
224 <Box>
225 <Button
226 key="fresh"
227 variant="primary"
228 hotkey="1"
229 label="Continue in a fresh session"
230 onPress={() => void freshSession($, handoff)}
231 />
232 <Button
233 key="copy"
234 hotkey="2"
235 label="Copy prompt"
236 onPress={() => void copyText($, handoff.prompt)}
237 />
238 <Button
239 key="dismiss"
240 hotkey="3"
241 label="Keep going here"
242 onPress={() => void update($, pending, () => null)}
243 />
244 </Box>
245 </Box>
246 )
247 })
248}
249types/index.d.ts 8 lines1export type Handoff = { path: string; prompt: string; percent: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'context-handoff': { pending: Handoff | null; isBusy: boolean; isOff: boolean }
6 }
7}
8