SLOPSHOPPER

context-handoff

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

newguardcommandtoaststatusprompt
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: context-handoff: on, phase idle ⎿ context-handoff: context 49%, band 60–70%, overshoot 75% ⎿ context-handoff: handoffs this session: 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-context-mods

Three Claude Code mods for keeping long sessions healthy and readable:

ModWhat it does
context-meterShows 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-handoffWhen 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-timestampPuts 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.

Install

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.

Update and uninstall

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

context-handoff in detail

  1. In the band. Once context passes 60%, Claude gets a hidden note telling it to hand off at the next natural boundary: a task finished, tests green, or before a new subtask, never mid-edit. At 70% the note says to do it now. The status line shows the current phase.
  2. Write. Claude calls the mod's 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.
  3. Rewind and compact. When the turn ends, the mod compacts the session. The summary only covers the transcript up to the Handoff call, so writing the note never ends up in context.
  4. Resume. The mod sends a prompt telling Claude to read the note, run its verify commands, and check git state against it before any edits, trusting the repo where they differ. Claude then keeps to the note's constraints and continues from its next action.
  5. Clean up. After Claude reads the note back, the mod deletes it. It only deletes a file it wrote itself, and only if that file is inside the session's handoff folder. If the note is never read, it is kept and a notice shows its path.

Safeguards:

  • Subagents are ignored.
  • If a turn ends at 75% or more with no handoff, Claude is asked once to write one.
  • If you interrupt the turn after the note is saved, the compaction is cancelled and the note is kept.

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.

Developing

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.

Source 2 files
hooks/register.ts 378 lines
1import 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}
378
types/index.d.ts 15 lines
1export 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