Write a handover when a Claude Code session gets long, type /clear, and the next session picks up where you left off with nothing to paste.

Clear the chat. Keep the goal, the next step and the decisions.
Before you /clear a long Claude Code chat, type /clear-resume:handover. Claude writes a short note about the work, called a handover: the goal, the next action, where things stand, the decisions made and what already failed. The plugin saves it. After /clear, the fresh session starts with the handover already loaded, so you type go and Claude carries on. Nothing to paste.
In one measured run, Claude built a site across 15 sessions with 91.0M tokens. The same work as one long chat, with no clears, comes to about 565.6M tokens (an estimate, and an upper bound).
/clear-resume:handover: Claude reads your branch, your changes and your last few commits, then writes and saves the handover./clear: the fresh session loads it.go: Claude carries on from it.Two optional settings, both off by default:
auto_nudge): when the chat's context passes Nudge at (180k tokens by default), Claude is asked once per session to save a handover.relay): after Claude saves a handover, the plugin runs /clear and submits the prompt that continues from it, up to the number of clears you allow. It stops when the budget is used or when two continued sessions in a row make no new commit. Needs Claude Code 2.1.275 or later.SessionStart (startup, clear and compact) loads a waiting handover. PostToolUse and Stop check the context size for the nudge and the relay. Each hook runs node on a script in this plugin, and exits quietly if Node 18 or later is missing.git in your repo to read the branch, status and recent commits. It never switches your branch or touches your index or files, and outside web mode (below) it never commits or pushes.~/.clear-resume. Each loaded handover is also copied to ~/.clear-resume/loaded/ for 30 days. Keep secrets out of them.CLEAR_RESUME_WEB=1, for Claude Code on the web) pushes each handover to its own clear-resume/<branch> branch on your repo's origin, and runs one git fetch origin at session start. Handovers found in git are listed as untrusted and never loaded on their own.hooks/relay.ts)A mod is a function-hooks module that runs inside Claude Code. This one runs the relay. It does nothing else.
relay setting, or /relay typed in that window. Otherwise it registers the /relay command and the status line, and nothing more.git rev-parse HEAD in the current repo, with a 5 second timeout (the headOf function). The stall guard compares the commit before and after each continued session, to stop when two in a row make no new commit. If git is missing or fails, it carries on without the check. It starts no other program./clear, once after Claude saves a handover, and only while the number of clears used is below the budget you set.~/.clear-resume/relay/<key>.json (or $CLEAR_RESUME_HOME/relay/<key>.json). Fields: v, key, cwd, sessions (this window's session ids, up to 100), limit, configured, used, stalled, applied, updatedAt. Only the VS Code status bar reads it (the extension that ships with clear-resume), to show the clears left. The mod also reads <key>.set.json beside it, which the status bar writes to change the budget. It writes no settings, build, start-up or instructions file./relay command (off, on, unlimited or a number), the status line entry that shows the clears left, and toasts that report a stop or a refused command.command.run hook. It is the handler for the plugin's own /relay command, and it only answers that command. It does not filter, rewrite or decide on any other command.Node 18 or later and git. On Windows, Claude Code runs plugin hooks through Git Bash, which comes with Git for Windows.
/compact and --resume: https://github.com/m4cd4r4/clear-resumeMIT licence.
hooks/relay.ts 397 lines1import type { Register } from 'claude-code'
2
3// The relay: after Claude saves a handover, run /clear and submit the prompt
4// that continues from it, so nobody has to type anything. Opt-in through the
5// `relay` option, which is also the budget: how many clears this process may
6// run before it stops and leaves the next one to the user. /relay overrides
7// the option for this window (this process) only, and the status line shows
8// what is left while it is on.
9//
10// The module lives in the process and /clear keeps the process, so `pending`
11// and `used` survive the clear. A reload (a config change, an update) starts
12// them over. No session.start fires after a clear: session.end with reason
13// 'clear' is the signal that the new conversation is there, and by then the
14// SessionStart hook has loaded the handover.
15//
16// The VS Code extension's status bar reads this window's relay from a state file
17// the module writes, <store>/relay/<key>.json, and changes this window's budget
18// by writing <key>.set.json beside it, which the module reads before each clear.
19// Its "Hand over" button writes <key>.handover.json; the module polls for it once
20// a second, idle or not, and runs the handover skill, which the relay then clears
21// and continues from as after any other save. With the relay off it only saves.
22
23// What save.mjs prints on a good save (plugin/scripts/save.mjs).
24export const SAVED = 'Saved handover "'
25export const SAVE_SCRIPT = /scripts\/save\.mjs/
26
27export const RESUME_TEXT = 'Continue from the clear-resume handover that was just loaded.'
28
29// What the status bar's "Hand over" button runs, and the prompt sent instead if
30// the command is refused (a host that does not list plugin skills as commands).
31export const HANDOVER_COMMAND = 'clear-resume:handover'
32export const HANDOVER_TEXT = 'Write a clear-resume handover now, with the /clear-resume:handover skill.'
33const POLL_MS = 1000
34
35// "off" or unset is 0, "unlimited" has no cap, a number is that many clears.
36export function budget(raw: unknown): number {
37 const s = String(raw ?? 'off').trim().toLowerCase()
38 if (s === 'unlimited') return Infinity
39 const n = Number(s)
40 return Number.isInteger(n) && n > 0 ? n : 0
41}
42
43// The state file's shape. The extension reads it (plugin/packages/store/relay-state.mjs).
44export type RelayFile = {
45 v: 1
46 key: string
47 cwd: string
48 // This window's session ids, oldest first: one per clear, the chain.
49 sessions: string[]
50 limit: number | 'unlimited'
51 configured: number | 'unlimited'
52 used: number
53 stalled: number
54 // The `at` of the last override taken from <key>.set.json, or of the last
55 // /relay typed in the window; 0 for none.
56 applied: number
57 updatedAt: number
58}
59
60const asJson = (n: number): number | 'unlimited' => (n === Infinity ? 'unlimited' : n)
61
62type Engine = Parameters<Parameters<Parameters<Register>[0]>[2]>[0]
63
64// HEAD of the session's repo, or undefined with no git (or no $.process, which
65// is CLI only). Unknown counts as progress: the stall guard stands aside and the
66// budget still holds.
67async function headOf($: Engine): Promise<string | undefined> {
68 try {
69 const r = await $.process.run(['git', 'rev-parse', 'HEAD'], { timeoutMs: 5000 })
70 return r.exitCode === 0 ? r.stdout.trim() || undefined : undefined
71 } catch {
72 return undefined
73 }
74}
75
76// What /relay takes: off, on, unlimited or a number. "on" is the option's own
77// budget when that is on, else 3. Anything else is undefined: not understood.
78export function parseRelay(args: string, fallback: number): number | undefined {
79 const s = args.trim().toLowerCase()
80 if (s === 'on') return fallback > 0 ? fallback : 3
81 if (s === 'off') return 0
82 const n = budget(s)
83 return n === 0 ? undefined : n
84}
85
86export function relayStatus(limit: number, used: number): string {
87 if (limit === 0) return 'relay: off'
88 if (limit === Infinity) return `relay: on, unlimited (${used} used)`
89 return `relay: ${Math.max(0, limit - used)} of ${limit} left`
90}
91
92// <store>/relay, the store being CLEAR_RESUME_HOME or ~/.clear-resume as in
93// packages/store/store.mjs. Undefined when there is no home to put it under.
94export async function relayDir($: Engine): Promise<string | undefined> {
95 const own = await $.env.get('CLEAR_RESUME_HOME')
96 if (own) return `${own.replace(/[/\\]+$/, '')}/relay`
97 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
98 return home ? `${home.replace(/[/\\]+$/, '')}/.clear-resume/relay` : undefined
99}
100
101async function headlessRun($: Engine): Promise<boolean> {
102 return /^(1|true|on|yes)$/i.test((await $.env.get('CLEAR_RESUME_HEADLESS')) ?? '')
103}
104
105// One process's relay. The module lives in the process, so this outlives /clear.
106type Relay = {
107 configured: number
108 limit: number
109 // This process's name in the relay folder. A reload starts the count over, so a
110 // new file is right; the extension reads the newest one for its folder.
111 key: string
112 sessions: string[]
113 applied: number
114 used: number
115 pending: boolean
116 // The stall guard, as in the headless runner (scripts/lib/run.mjs): HEAD at the
117 // last relay, and how many continued sessions in a row ended without a commit.
118 lastHead: string | undefined
119 stalled: number
120 headless: boolean
121 // The `at` of the last hand-over request taken from <key>.handover.json, and
122 // whether the poll for it is running. The poll's timer outlives /clear.
123 asked: number
124 polling: boolean
125 // The session last warned that the nudge went unanswered, so it warns once.
126 warned: string | undefined
127}
128
129// Told after a nudged turn ends with no save through save.mjs. Seen 2026-10-07: a
130// user's own skill also named "handover" saved by its own script, the relay never
131// armed, and nothing said so.
132export const UNARMED_TEXT =
133 'clear-resume relay: no handover was saved with /clear-resume:handover after the context nudge, so the relay will not clear. ' +
134 'If the work goes on, run /clear-resume:handover (another handover skill does not count), or type /clear.'
135
136// The nudge (scripts/lib/nudge.mjs) marks a session it has asked to hand over at
137// <store>/.nudged/<slug of the session id>, the slug as in scripts/lib/store.mjs.
138async function nudged($: Engine, id: string): Promise<boolean> {
139 const dir = await relayDir($)
140 const slug = id.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50)
141 if (!dir || !slug) return false
142 try {
143 await $.fs.read(`${dir.replace(/[/\\]relay$/, '')}/.nudged/${slug}`)
144 return true
145 } catch {
146 return false
147 }
148}
149
150// A finished turn with the relay on and nothing saved: if the nudge has asked this
151// session for a handover, say once that the relay will not clear.
152async function warnUnarmed($: Engine, r: Relay, reason: string): Promise<void> {
153 if (r.limit === 0 || reason !== 'answer' || (await headlessRun($))) return
154 const id = await $.session.id()
155 if (!id || r.warned === id || !(await nudged($, id))) return
156 r.warned = id
157 $.ui.toast(UNARMED_TEXT, { timeoutMs: 20000 })
158}
159
160// The standing status entry: what is left while the relay is on, nothing while off.
161const shown = (r: Relay) => (r.limit === 0 || r.headless ? undefined : relayStatus(r.limit, r.used))
162
163// Write this window's state for the status bar. A failure never reaches the relay.
164async function publish($: Engine, r: Relay): Promise<void> {
165 try {
166 if (await headlessRun($)) return
167 const dir = await relayDir($)
168 if (!dir) return
169 const id = await $.session.id()
170 if (id && r.sessions.at(-1) !== id) r.sessions.push(id)
171 if (r.sessions.length > 100) r.sessions.splice(0, r.sessions.length - 100)
172 const file: RelayFile = {
173 v: 1,
174 key: r.key,
175 cwd: await $.session.cwd(),
176 sessions: r.sessions,
177 limit: asJson(r.limit),
178 configured: asJson(r.configured),
179 used: r.used,
180 stalled: r.stalled,
181 applied: r.applied,
182 updatedAt: Date.now(),
183 }
184 await $.fs.write(`${dir}/${r.key}.json`, JSON.stringify(file))
185 } catch {
186 // The status bar is a view; the relay works without it.
187 }
188}
189
190// A new budget for this window: the count and the stall guard start over.
191function setLimit(r: Relay, limit: number, at: number): void {
192 r.applied = at
193 r.limit = limit
194 r.used = 0
195 r.stalled = 0
196 r.lastHead = undefined
197 if (limit === 0) r.pending = false
198}
199
200// A budget set from the status bar, newer than the last one taken, replaces this
201// window's budget.
202async function takeOverride($: Engine, r: Relay): Promise<void> {
203 try {
204 const dir = await relayDir($)
205 if (!dir) return
206 const set = JSON.parse(String(await $.fs.read(`${dir}/${r.key}.set.json`)))
207 if (typeof set?.at !== 'number' || set.at <= r.applied) return
208 setLimit(r, budget(set.limit), set.at)
209 $.ui.status(shown(r))
210 } catch {
211 // No override file, or a bad one: keep the budget there is.
212 }
213}
214
215// A hand-over request from the status bar, newer than the last one taken: run the
216// handover skill. Queued until the session is idle, so a click mid-turn waits.
217async function takeHandover($: Engine, r: Relay): Promise<void> {
218 let at: unknown
219 try {
220 const dir = await relayDir($)
221 if (!dir) return
222 at = JSON.parse(String(await $.fs.read(`${dir}/${r.key}.handover.json`)))?.at
223 } catch {
224 // No request file, or a bad one: nothing asked.
225 return
226 }
227 if (typeof at !== 'number' || at <= r.asked) return
228 r.asked = at
229 $.ui.toast('clear-resume: writing a handover', { timeoutMs: 5000 })
230 try {
231 await $.command.run({ command: HANDOVER_COMMAND, args: '' })
232 } catch {
233 await $.prompt.submit({ text: HANDOVER_TEXT, asUser: true }).catch(err => {
234 $.ui.toast(`clear-resume: hand-over refused: ${String(err)}`, { timeoutMs: 15000 })
235 })
236 }
237}
238
239// Start the poll once per process, from whichever of session.start and the first
240// turn's end comes first. Not under the headless runner, which has no button.
241async function poll($: Engine, r: Relay): Promise<void> {
242 if (r.polling) return
243 r.polling = true
244 if (await headlessRun($)) return
245 let busy = false
246 $.clock.every(POLL_MS, () => {
247 if (busy) return
248 busy = true
249 void takeHandover($, r).finally(() => {
250 busy = false
251 })
252 })
253}
254
255// What the end of a main-thread turn does with a pending save.
256async function decide($: Engine, r: Relay, reason: string): Promise<void> {
257 if (!r.pending) return warnUnarmed($, r, reason)
258 // Turned off for this window: drop the save quietly.
259 if (r.limit === 0) {
260 r.pending = false
261 return
262 }
263 // The headless runner (run.mjs) ends the process instead; it has its own budget.
264 if (await headlessRun($)) {
265 r.pending = false
266 return
267 }
268 // An interrupted or failed turn is the user's to finish: do not clear under them.
269 if (reason !== 'answer') {
270 r.pending = false
271 return
272 }
273 if (r.used >= r.limit) {
274 r.pending = false
275 $.ui.toast(`clear-resume relay: budget of ${r.limit} used. Type /clear to continue from the handover.`, {
276 timeoutMs: 15000,
277 })
278 return
279 }
280 const head = await headOf($)
281 r.stalled = head !== undefined && head === r.lastHead ? r.stalled + 1 : 0
282 r.lastHead = head
283 if (r.stalled >= 2) {
284 r.pending = false
285 $.ui.toast(
286 'clear-resume relay: stopped. Two continued sessions in a row made no new commit. Type /clear to continue from the handover.',
287 { timeoutMs: 15000 },
288 )
289 return
290 }
291 r.used++
292 $.ui.status('clear-resume: clearing...')
293 // Not awaited: command.run rejects inside a hook the turn is waiting on,
294 // and the clear is queued until the session is idle anyway.
295 $.clock.after(300, () => {
296 $.command.run({ command: 'clear' }).catch(err => {
297 r.pending = false
298 $.ui.status(shown(r))
299 $.ui.toast(`clear-resume relay: /clear refused: ${String(err)}`, { timeoutMs: 15000 })
300 })
301 })
302}
303
304export const register: Register = (on, options) => {
305 // Off still registers: /relay and the status bar can turn it on for this window.
306 const configured = budget(options.relay)
307 const r: Relay = {
308 configured,
309 limit: configured,
310 key: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
311 sessions: [],
312 applied: 0,
313 used: 0,
314 pending: false,
315 lastHead: undefined,
316 stalled: 0,
317 headless: false,
318 asked: 0,
319 polling: false,
320 warned: undefined,
321 }
322
323 on('session.start', async ($, e, next) => {
324 const result = await next(e)
325 r.headless = await headlessRun($)
326 await $.command.register({
327 name: 'relay',
328 description: 'Clear and continue by itself in this window: off, on, unlimited or a number',
329 argumentHint: '[off|on|unlimited|<n>]',
330 immediate: true,
331 })
332 $.ui.status(shown(r))
333 await publish($, r)
334 await poll($, r)
335 return result
336 })
337
338 // Bare /relay reports; with an argument it sets this window's budget and starts
339 // the count and the stall guard over. Typed later than any status-bar choice not
340 // yet taken, so it wins over that one.
341 on('command.run', { command: 'relay' }, async ($, e) => {
342 if (e.args.trim() === '') return { text: relayStatus(r.limit, r.used) }
343 const set = parseRelay(e.args, configured)
344 if (set === undefined) return { text: `relay: "${e.args.trim()}" not understood. Use off, on, unlimited or a number.` }
345 setLimit(r, set, Date.now())
346 $.ui.status(shown(r))
347 await publish($, r)
348 return { text: relayStatus(r.limit, r.used) }
349 })
350
351 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
352 const ran = await next(e)
353 if (
354 e.agentId === undefined &&
355 SAVE_SCRIPT.test(e.command.replace(/\\/g, '/')) &&
356 ran.deny === undefined &&
357 ran.isError !== true &&
358 (ran.text ?? '').includes(SAVED)
359 ) {
360 r.pending = true
361 }
362 return ran
363 })
364
365 on('turn.complete', async ($, e, next) => {
366 const result = await next(e)
367 if (e.agentId !== undefined) return result
368 await takeOverride($, r)
369 await decide($, r, e.reason)
370 await publish($, r)
371 await poll($, r)
372 return result
373 })
374
375 on('session.end', async ($, e, next) => {
376 const result = await next(e)
377 if (e.reason !== 'clear' || !r.pending) return result
378 r.pending = false
379 $.ui.status('clear-resume: continuing...')
380 $.clock.after(1000, () => {
381 $.prompt
382 .submit({ text: RESUME_TEXT, asUser: true })
383 .then(() => {
384 $.ui.status(shown(r))
385 // The VS Code panel draws no status line; a toast is the count it can show.
386 $.ui.toast(`clear-resume ${relayStatus(r.limit, r.used)}`, { timeoutMs: 8000 })
387 return publish($, r)
388 })
389 .catch(err => {
390 $.ui.status(shown(r))
391 $.ui.toast(`clear-resume relay: submit refused: ${String(err)}`, { timeoutMs: 15000 })
392 })
393 })
394 return result
395 })
396}
397