In the 60-70% context band, Claude writes a handoff, the handoff is rewound away, the session compacts, and Claude resumes from the file.

Three Claude Code mods for keeping long sessions healthy and readable:
| Mod | What it does |
|---|---|
| context-meter | Shows context-window usage in the prompt footer (207k / 1.0M (21%)), followed by five-hour and weekly limit usage when your plan reports it (· 5h 34% · 7d 12%), and shows a notice once when context usage passes 80%. |
| context-handoff | When context is 60–70% full, Claude picks a natural stopping point and writes itself a handoff note. The mod then cuts the note-writing out of the transcript (a rewind), runs /compact, and has Claude read the note and carry on. The note is deleted once Claude has read it back. |
| prompt-timestamp | Puts the local date and time at the front of every prompt you type ([10-02 14:03 EDT] fix the bug), so the transcript and Claude both see when it was sent. |
All three are function-hook mods (plugins written as TypeScript hook modules). That API is early access, so it can change between Claude Code releases. context-meter and context-handoff were built and tested on Claude Code 2.1.289; prompt-timestamp needs 2.1.287 or later.
Inside Claude Code:
/plugin marketplace add Vibe-Commit/claude-context-mods
/plugin install context-meter@claude-context-mods
/plugin install context-handoff@claude-context-mods
/plugin install prompt-timestamp@claude-context-mods
Or from a shell:
claude plugin marketplace add Vibe-Commit/claude-context-mods
claude plugin install context-meter@claude-context-mods
claude plugin install context-handoff@claude-context-mods
claude plugin install prompt-timestamp@claude-context-mods
Restart Claude Code afterwards (or run /reload-plugins). Install any or all of them; they work independently.
claude plugin marketplace update claude-context-mods
claude plugin update context-handoff@claude-context-mods # restart to apply
claude plugin uninstall context-handoff@claude-context-mods
Handoff tool with a markdown note. The note has fixed sections: goal, the user's constraints, status (verified or not, with proof), the next action, failed approaches, decisions and their reasons, open issues, git and environment state, and commands to verify. It is saved to ~/.claude/handoffs/<session-id>/handoff-N.md. Claude is then held to ending its turn.Handoff call, so writing the note never ends up in context.Safeguards:
Command: /handoff [status|on|off|now]. now asks for a handoff right away, which is the quickest way to try the mod.
Thresholds (60 / 70 / 75 by default) can be changed per user. You can use /plugin configure context-handoff@claude-context-mods inside Claude Code, or a shell:
echo '{"bandLow":"55","bandHigh":"65","overshoot":"75"}' \
| claude plugin configure context-handoff@claude-context-mods --values-stdin
When you install, Claude Code reports "3 userConfig options not yet set". That's expected: unset options use the defaults.
Each mod has tests:
claude plugin validate plugins/context-handoff
claude plugin test plugins/context-handoff
To work on a mod with hot reload, load it from your checkout with claude --plugin-dir ./plugins/context-handoff, or list the folder in CLAUDE_CODE_PLUGIN_DIRS. Uninstall the marketplace copy first so it doesn't load twice. Bump version in the mod's plugin.json and in .claude-plugin/marketplace.json together. claude plugin validate . checks that they agree.
hooks/register.ts 378 lines1import type { EngineInterface, Register, SessionMessage } from 'claude-code'
2
3import type { HandoffPhase } from '../types'
4
5// Lifecycle: idle → nudged (in the band) → pending (Handoff written, turn
6// ending) → compacting (handoff rewound off the transcript, then summarised)
7// → resuming (Claude reads the file) → idle (file deleted).
8
9const TOOL = 'Handoff'
10const TOOL_ID = 'mcp__context-handoff__Handoff'
11const MIN_CONTENT = 200
12const COMPACT_RETRY_MS = 500
13const COMPACT_ATTEMPTS = 20
14
15const phaseRef = { plugin: 'context-handoff', key: 'phase' } as const
16const pathRef = { plugin: 'context-handoff', key: 'handoffPath' } as const
17const firmRef = { plugin: 'context-handoff', key: 'isFirmSent' } as const
18const overshootRef = { plugin: 'context-handoff', key: 'isOvershootSent' } as const
19const disabledRef = { plugin: 'context-handoff', key: 'isDisabled' } as const
20const countRef = { plugin: 'context-handoff', key: 'handoffCount' } as const
21const resumeTurnRef = { plugin: 'context-handoff', key: 'resumeTurnId' } as const
22const lateRef = { plugin: 'context-handoff', key: 'lateMessages' } as const
23
24const TOOL_DESCRIPTION = [
25 'Saves a note you resume from after the session compacts.',
26 'Call it only when a `[context-handoff]` note asks you to, at a natural boundary, never mid-edit.',
27 'In a git repo, first run `git status -sb` and `git log -1 --oneline`, then call it alone in its own message.',
28 '`content` is markdown for a future you with no memory of this session: state, not narrative. Use extremely concise language and minimize text, except in Pending specs, where exact wording matters more than brevity.',
29 'Reference what is on disk by path or commit; copy what is not. Anything the user said in conversation that is not in a file is not on disk, so copy it. Never include secret values.',
30 'Use these sections in order, writing "None" for an empty one:',
31 "## Goal (the user's current request in their words, and what done looks like);",
32 '## Constraints (every user rule still in force, verbatim);',
33 '## Status (each item: verified with a command and result from after its last change, unverified, or not started);',
34 '## Pending specs (verbatim) (for every item not yet done that the user or a plan specified: copy the exact text, including conditions, fail-closed shapes, required tests or witness mutants, and where it wires in, e.g. build sessions vs rewrite attempts, live-only vs testable in dry runs; never reduce an item to a label; write any detail that was never decided as an open question under that item);',
35 '## Next action (one step: the file or command, and the expected result);',
36 '## Failed approaches (what failed and why, with the exact error);',
37 '## Decisions (each choice, what was ruled out, and why);',
38 '## Open issues (bugs and failing tests with exact errors, questions for the user);',
39 '## Environment (branch, HEAD, uncommitted changes, files changed this session, running processes);',
40 '## Verify (read-only commands, each with its expected result).',
41].join(' ')
42
43const softNote = (pct: number) =>
44 `[context-handoff] Context is at ${pct}%. At the next natural boundary ` +
45 `(a task finished, tests green, before starting a new subtask) call the ` +
46 `${TOOL} tool with your handoff note, then end your turn. Do not do it mid-edit.`
47
48const firmNote = (pct: number) =>
49 `[context-handoff] Context is at ${pct}%. Finish the step in hand, then call ` +
50 `the ${TOOL} tool now and end your turn.`
51
52const overshootPrompt = (pct: number) =>
53 `Context is at ${pct}%, past the handoff band. Call the ${TOOL} tool now with ` +
54 `your handoff note, then end your turn.`
55
56const nowPrompt =
57 `The user asked for a context handoff. Finish the step in hand, then call the ${TOOL} ` +
58 `tool now with your handoff note and end your turn.`
59
60const RESUME_PREFIX = 'Resuming after a context handoff.'
61
62const resumePrompt = (path: string, late: string) =>
63 `${RESUME_PREFIX} Read the note at ${path} with the Read tool. ` +
64 `Before any edits, run its Verify commands and check git against its Environment section; ` +
65 `where they differ, trust the repo and never revert or discard work to match the note. ` +
66 `Follow its Constraints and treat its Pending specs as binding; if a pending item is only a label with no conditions, tell the user before implementing it and do not guess. Then continue from its Next action without waiting for confirmation.` +
67 (late === '' ? '' :
68 `\n\nThese messages arrived after the note was written, so it does not cover them. ` +
69 `Handle them first if they change the plan:\n${late}`)
70
71type Band = { low: number; high: number; overshoot: number }
72
73const num = (v: unknown, fallback: number) =>
74 typeof v === 'number' && Number.isFinite(v) ? v : fallback
75
76export const register: Register = (on, options) => {
77 const band: Band = {
78 low: num(options.bandLow, 60),
79 high: num(options.bandHigh, 70),
80 overshoot: num(options.overshoot, 75),
81 }
82
83 on('session.start', async ($, e, next) => {
84 await $.tool.register({
85 name: TOOL,
86 description: TOOL_DESCRIPTION,
87 inputSchema: {
88 type: 'object',
89 properties: {
90 content: { type: 'string', description: 'The whole handoff note, in markdown.' },
91 },
92 required: ['content'],
93 },
94 })
95 await $.command.register({
96 name: 'handoff',
97 description: 'Context handoff: status, on, off, or now (hand off at the next boundary)',
98 argumentHint: '[status|on|off|now]',
99 })
100 return next(e)
101 })
102
103 on('command.run', { command: 'handoff' }, async ($, e) => {
104 const arg = e.args.trim()
105 if (arg === 'off') {
106 await $.state.set(disabledRef, true)
107 $.ui.status(undefined)
108 return { text: 'Context handoff is off for this session.' }
109 }
110 if (arg === 'on') {
111 await $.state.set(disabledRef, false)
112 return { text: 'Context handoff is on.' }
113 }
114 if (arg === 'now') {
115 if ((await phaseOf($)) !== 'idle' && (await phaseOf($)) !== 'nudged') {
116 return { text: `A handoff is already under way (${await phaseOf($)}).` }
117 }
118 await $.state.set(phaseRef, 'nudged')
119 await $.state.set(firmRef, true)
120 // A command's `context` is only recorded, it starts no turn, so submit
121 // a prompt to make Claude act now.
122 $.clock.after(0, () => void $.prompt.submit({ text: nowPrompt }))
123 return { text: 'Asked Claude to hand off.' }
124 }
125 const pct = (await $.session.usage()).context.percent
126 const isDisabled = (await $.state.get(disabledRef)).value === true
127 const path = (await $.state.get(pathRef)).value
128 const count = (await $.state.get(countRef)).value ?? 0
129 return {
130 text: [
131 `context-handoff: ${isDisabled ? 'off' : 'on'}, phase ${await phaseOf($)}`,
132 `context ${pct ?? '?'}%, band ${band.low}–${band.high}%, overshoot ${band.overshoot}%`,
133 `handoffs this session: ${count}${path ? `, current file ${path}` : ''}`,
134 ].join('\n'),
135 }
136 })
137
138 // The Handoff tool: write the note, then hold the turn to its end.
139 on('tool.call', { tool: TOOL_ID }, async ($, e) => {
140 if (e.agentId !== undefined) return { deny: 'Only the main conversation hands off.' }
141 const phase = await phaseOf($)
142 if (phase !== 'nudged') {
143 return { deny: phase === 'idle'
144 ? 'Context is not in the handoff band yet; keep working.'
145 : `A handoff is already under way (${phase}).` }
146 }
147 const content = (e as { content?: unknown }).content
148 if (typeof content !== 'string' || content.trim().length < MIN_CONTENT) {
149 return { deny: `content must be the whole handoff note in markdown (at least ${MIN_CONTENT} characters).` }
150 }
151 const count = (await $.state.get(countRef)).value ?? 0
152 const path = `${await handoffDir($)}/handoff-${count + 1}.md`
153 await $.fs.write(path, content)
154 await $.state.set(pathRef, path)
155 await $.state.set(phaseRef, 'pending')
156 $.ui.status('handoff: saved · compacting when this turn ends')
157 return {
158 result: `Handoff saved to ${path}. End your turn now: one line, no further tool calls. ` +
159 'The session will compact and resume from this file.',
160 }
161 })
162
163 // Every other tool: hold the turn once the handoff is written, delete the
164 // file once it has been read back, and tell Claude when it is in the band.
165 on('tool.call', async ($, e, next) => {
166 if (e.agentId !== undefined || e.tool === TOOL_ID) return next(e)
167 const phase = await phaseOf($)
168 if (phase === 'pending') {
169 return { deny: 'The handoff is saved; end your turn now with no further tool calls.' }
170 }
171 const ran = await next(e)
172 if (ran.deny !== undefined) return ran
173 if (phase === 'resuming' && e.tool === 'Read' && ran.isError !== true) {
174 await cleanup($, e.file_path)
175 }
176 const note = await bandNote($, band)
177 return note === undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
178 })
179
180 // A turn with no tool calls still hears about the band, with the prompt.
181 on('prompt.submit', async ($, e, next) => {
182 if (e.origin.kind === 'plugin') return next(e)
183 const note = await bandNote($, band)
184 return note === undefined ? next(e) : next({ ...e, context: [...(e.context ?? []), note] })
185 })
186
187 // Marks which turn is the resume turn, so another turn ending first (a
188 // message from another session, say) is not taken for it.
189 on('turn.start', async ($, e, next) => {
190 if ((await phaseOf($)) === 'resuming' && e.text.startsWith(RESUME_PREFIX)) {
191 await $.state.set(resumeTurnRef, e.turnId)
192 }
193 return next(e)
194 })
195
196 on('turn.complete', async ($, e, next) => {
197 const answered = await next(e)
198 if (e.agentId !== undefined) return answered
199 const phase = await phaseOf($)
200
201 if (phase === 'pending') {
202 if (e.isAborted) {
203 const path = (await $.state.get(pathRef)).value
204 await $.state.set(phaseRef, 'nudged')
205 await $.state.set(pathRef, '')
206 $.ui.toast(`Handoff cancelled by the interrupt; the note stays at ${path}.`)
207 return answered
208 }
209 await $.state.set(phaseRef, 'compacting')
210 $.clock.after(0, () => void compactAndResume($, 1))
211 return answered
212 }
213
214 if (phase === 'resuming') {
215 // Only the resume turn's end counts; any other turn ends and waits.
216 if ((await $.state.get(resumeTurnRef)).value !== e.turnId) return answered
217 // The resume turn ended without a successful Read of the note: keep it.
218 const path = (await $.state.get(pathRef)).value
219 await finish($, false)
220 if (path) $.ui.toast(`Resumed, but the handoff was not read back; kept at ${path}.`)
221 return answered
222 }
223
224 if ((phase === 'idle' || phase === 'nudged') && e.reason === 'answer') {
225 const pct = await percentOf($)
226 const isDisabled = (await $.state.get(disabledRef)).value === true
227 const isSent = (await $.state.get(overshootRef)).value === true
228 if (!isDisabled && !isSent && pct !== undefined && pct >= band.overshoot) {
229 await $.state.set(overshootRef, true)
230 await $.state.set(phaseRef, 'nudged')
231 void $.prompt.submit({ text: overshootPrompt(pct) })
232 }
233 }
234 return answered
235 })
236
237 // The rewind: what the summary runs over ends before the Handoff call, so
238 // writing the note (and the turn's last words) never reach the summary.
239 on('session.compact', async ($, e, next) => {
240 if (e.agentId !== undefined || e.trigger !== 'plugin') return next(e)
241 if ((await phaseOf($)) !== 'compacting' || !Array.isArray(e.messages)) return next(e)
242 const at = lastHandoffCall(e.messages)
243 if (at <= 0) {
244 $.ui.log('context-handoff: no Handoff call in the transcript; compacting it whole', { to: 'debug' })
245 return next(e)
246 }
247 await $.state.set(lateRef, lateMessages(e.messages, at))
248 return next({ ...e, messages: e.messages.slice(0, at) })
249 })
250}
251
252async function compactAndResume($: EngineInterface, attempt: number): Promise<void> {
253 const path = (await $.state.get(pathRef)).value
254 if (!path) return finish($, false)
255 $.ui.status('handoff: compacting')
256 let compacted
257 try {
258 compacted = await $.session.compact({
259 instructions: 'Summarize the work so far. A handoff note written by the assistant ' +
260 'will be read right after this summary and takes precedence on next steps. ' +
261 'Preserve verbatim any binding specs, exact conditions and per-item requirements ' +
262 'the user gave; never shorten them to labels.',
263 })
264 } catch (err) {
265 // Refused while a turn still runs: try again shortly.
266 if (attempt < COMPACT_ATTEMPTS) {
267 $.clock.after(COMPACT_RETRY_MS, () => void compactAndResume($, attempt + 1))
268 return
269 }
270 $.ui.toast(`Handoff: compaction failed (${String(err)}); the note stays at ${path}.`)
271 return finish($, false)
272 }
273 if (compacted.skip !== undefined) {
274 $.ui.toast(`Handoff: compaction skipped (${compacted.skip}); the note stays at ${path}.`)
275 return finish($, false)
276 }
277 await $.state.set(phaseRef, 'resuming')
278 $.ui.status('handoff: resuming')
279 const late = (await $.state.get(lateRef)).value ?? ''
280 await $.prompt.submit({ text: resumePrompt(path, late) })
281}
282
283// Deletes the note only when the Read was of exactly the file this plugin
284// wrote, and that file lies under this session's handoff folder.
285async function cleanup($: EngineInterface, readPath: string): Promise<void> {
286 const path = (await $.state.get(pathRef)).value
287 if (!path) return
288 const dir = await handoffDir($)
289 const [file, read, root] = await Promise.all([
290 $.fs.stat(path, { resolve: true }).catch(() => undefined),
291 $.fs.stat(readPath, { resolve: true }).catch(() => undefined),
292 $.fs.stat(dir, { resolve: true }).catch(() => undefined),
293 ])
294 const real = file?.realPath
295 if (real === undefined || read?.realPath !== real) return
296 if (file?.kind !== 'file' || root?.realPath === undefined || !real.startsWith(`${root.realPath}/`)) {
297 $.ui.log(`context-handoff: refused to delete ${real}: not a file under ${dir}`)
298 return
299 }
300 const removed = await $.process.run(['rm', '--', real])
301 if (removed.exitCode !== 0) {
302 $.ui.log(`context-handoff: could not delete ${real}: ${removed.stderr.trim()}`)
303 return
304 }
305 // Leaves the folder alone when another handoff of this session is in it.
306 await $.process.run(['rmdir', '--', root.realPath]).catch(() => undefined)
307 await finish($, true)
308}
309
310async function finish($: EngineInterface, isDone: boolean): Promise<void> {
311 if (isDone) {
312 const count = (await $.state.get(countRef)).value ?? 0
313 await $.state.set(countRef, count + 1)
314 }
315 await $.state.set(phaseRef, 'idle')
316 await $.state.set(pathRef, '')
317 await $.state.set(firmRef, false)
318 await $.state.set(overshootRef, false)
319 await $.state.set(lateRef, '')
320 await $.state.set(resumeTurnRef, '')
321 $.ui.status(undefined)
322}
323
324// The note for Claude when the context is in the band, at most once per
325// level per window; undefined otherwise.
326async function bandNote($: EngineInterface, band: Band): Promise<string | undefined> {
327 if ((await $.state.get(disabledRef)).value === true) return undefined
328 const pct = await percentOf($)
329 if (pct === undefined) return undefined
330 const phase = await phaseOf($)
331 if (phase === 'idle' && pct >= band.low) {
332 await $.state.set(phaseRef, 'nudged')
333 $.ui.status(`handoff: ${pct}% · waiting for a good point`)
334 return softNote(pct)
335 }
336 if (phase === 'nudged') {
337 $.ui.status(`handoff: ${pct}% · waiting for a good point`)
338 if (pct >= band.high && (await $.state.get(firmRef)).value !== true) {
339 await $.state.set(firmRef, true)
340 return firmNote(pct)
341 }
342 }
343 return undefined
344}
345
346async function phaseOf($: EngineInterface): Promise<HandoffPhase> {
347 return (await $.state.get(phaseRef)).value ?? 'idle'
348}
349
350async function percentOf($: EngineInterface): Promise<number | undefined> {
351 return (await $.session.usage()).context.percent
352}
353
354async function handoffDir($: EngineInterface): Promise<string> {
355 const home = await $.env.get('HOME')
356 if (!home) throw new Error('HOME is not set')
357 return `${home}/.claude/handoffs/${await $.session.id()}`
358}
359
360// User text sent after the Handoff call (the rewind drops it from the summary
361// and the note predates it), whether typed or relayed from another session.
362// Tool results are not `text`, and the plugin's own notes are skipped.
363function lateMessages(messages: readonly SessionMessage[], at: number): string {
364 return messages
365 .slice(at + 1)
366 .filter(m => m.role === 'user' && m.text.trim() !== '' && !m.text.startsWith('[context-handoff]'))
367 .map(m => `> ${m.text.trim().replace(/\n/g, '\n> ')}`)
368 .join('\n\n')
369}
370
371function lastHandoffCall(messages: readonly SessionMessage[]): number {
372 for (let i = messages.length - 1; i >= 0; i--) {
373 const m = messages[i]
374 if (m?.role === 'assistant' && m.toolUses.some(u => u.tool === TOOL_ID)) return i
375 }
376 return -1
377}
378types/index.d.ts 15 lines1export type HandoffPhase = 'idle' | 'nudged' | 'pending' | 'compacting' | 'resuming'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'context-handoff': {
6 phase: HandoffPhase
7 handoffPath: string
8 isFirmSent: boolean
9 isOvershootSent: boolean
10 isDisabled: boolean
11 handoffCount: number
12 }
13 }
14}
15