SLOPSHOPPER

context-handoff

Watches the context window; past a threshold the agent writes a handoff with your next message, then the session compacts and gets it back.

newcommandtoastpromptprocesstimer
★ 4v0.4.3MITupdated 2026-10-23igorfduarte/claude-mods/plugins/context-handoff
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 ╭────────────────────────────────────────────╮ │ context-handoff │ ⏺ Read(src/auth.ts) │ Handoff armed: the handoff is written with │ ⎿ Read 6 lines │ your next message, then the session │ ⏺ Update(src/auth.ts) │ compacts │ ⎿ 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 › /context-handoff ⎿ context-handoff: Handoff armed: send your next message; the agent writes the handoff first, then the session compacts. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Mods for Claude Code. Each one is a standalone plugin under plugins/.

ModWhat it does
context-handoffWatches the context window. Past a threshold, the agent writes a thorough handoff, the session compacts, and the handoff comes back with your next message, so long sessions keep their context.
agent-deckA live side panel of every subagent in the session (what it is doing, its steps, context and time) and a progress bar above the prompt for the subagents running now.

Install

At the prompt of a Claude Code terminal session, with the mod's name from the table:

/plugin install context-handoff --marketplace igorfduarte/claude-mods
/plugin install agent-deck --marketplace igorfduarte/claude-mods

Answer y to add the marketplace, pick a scope (user is the default), and set the options or keep the defaults.

From a shell:

claude plugin install agent-deck --marketplace igorfduarte/claude-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with igorfduarte/claude-mods, then Add plugin.

Start a new session afterwards; sessions already open do not pick it up.

Mods are built on function hooks, an early-access Claude Code API: tested on Claude Code 2.1.308, and it may change between releases.

context-handoff

Long sessions fill the context window, and compaction alone keeps only a short summary. A common workaround is asking the agent, right before compacting, for a handoff to paste back afterwards. This mod does that for you.

How it works

  1. After each turn of the main session, the mod checks the context size. Past the threshold (300k tokens by default) it arms a handoff and shows a notice.
  2. With your next message, the mod writes a conversation inventory taken from the transcript, not from the model's memory: your messages verbatim, every file touched and with which tools, shell commands, searches, subagents launched and tool errors.
  3. In the same turn, before handling your message, the session's own agent goes through the whole conversation, uses the inventory as a checklist, carries over the previous handoff if there is one, records git status and git diff --stat, and writes a handoff with:
  4. goal and current state
  5. your instructions and preferences, quoted where the wording matters
  6. decisions, and approaches rejected and why
  7. each relevant file: role, what changed and why, state
  8. tools, commands and environment
  9. work log and next steps
  10. open questions, risks and errors
  11. files to re-read after compaction

Then it handles your message as usual and adds what it did to the handoff.

  1. After that turn the mod compacts the session. Where Claude Code does not let a mod compact directly (the desktop app and other SDK hosts), it runs /compact for you.
  2. Your following message carries the handoff and the inventory to the agent, unseen in the chat, and the agent re-reads the files it listed before answering.

/context-handoff arms a cycle on demand, without waiting for the threshold.

The handoff is written by the session's own model on purpose: the conversation is already in its prompt cache, so reading it back costs little, and only that agent can re-read files and run git. A separate model would read the whole conversation again uncached.

Options

OptionDefaultWhat it sets
thresholdK300Context size, in thousands of tokens, that arms a handoff. Keep it below the auto-compact window, or Claude Code's own compaction comes first.
retentionDays2When a session starts, handoff folders of other sessions untouched for this many days are deleted. 0 keeps them forever.

Set them when installing, with /plugin configure context-handoff@claude-mods in a terminal session, or in ~/.claude/settings.json:

{ "pluginConfigs": { "context-handoff@claude-mods": { "options": { "thresholdK": 350, "retentionDays": 7 } } } }

Files

Each session gets a folder ~/.claude/handoffs/<session-id>/ with handoff-N.md and inventory-N.md per cycle. They stay on your machine. They hold your messages verbatim and what the agent wrote about your work, so treat them like the transcript itself.

Limits

  • The mod acts at the end of a turn. If Claude Code's auto-compaction fires first, in the middle of a very long turn, the mod does not intercept it: keep the threshold well below the auto-compact window.
  • The handoff rides on your next message rather than a turn of its own, because the desktop app does not run turns started by a mod. The cycle needs one more message from you to start.
  • If a cycle is interrupted or the handoff is not written, the mod does not retry by itself until the session compacts; /context-handoff arms it again.
  • If the session cannot compact by itself, a notice asks you to run /compact; the handoff still comes back with your next message.

Uninstall

claude plugin uninstall context-handoff@claude-mods

agent-deck

A live view of the subagents a session runs, in the background or not.

Inspired by savvy-progress by JohnnyVizz: its agents panel and progress bar, made for any subagent rather than one workflow.

Progress bar

Above the prompt, for the current batch (the subagents started while others still run): one segment per subagent. A segment fills as the subagent reports its steps, pulses while it works without reporting, and turns red if it failed. Beside it: how many are done and running, the percent and the elapsed time. ×N opens or closes the panel, and ✕ hides the bar until the next batch. Once every subagent has ended the bar reads All done, and it steps aside with your next message.

Agents panel

/subagents opens or closes it, and /subagents open only opens it. It also opens by itself when a new batch starts.

  • A header with the subagents running out of the total, output tokens and time.
  • One row per subagent: a pixel robot that walks while it works, smiles when done and turns red-eyed on an error; the task's description; its type, model and effort, and whether it runs in the background; what it does now, read off its latest tool calls (Grep · invalidateCache); its reported steps (2/5 · reading the tests); context size, output tokens and time.
  • Finished subagents collapse under their own heading and can be cleared.

In a terminal session both draw as text.

Step reports

To fill each subagent's bar, the mod registers a step tool and appends one paragraph to every subagent's brief, asking it to report its plan and its progress through that tool. That changes every subagent's prompt slightly and costs a few cheap tool calls. With stepReports off, briefs are left as they are and each bar sweeps while its subagent works.

Options

OptionDefaultWhat it sets
autoOpenonOpens the panel by itself when a new batch of subagents starts; off opens it only with /subagents.
stepReportsonAsks each subagent to report its steps, as above.

Set them when installing, with /plugin configure agent-deck@claude-mods in a terminal session, or in ~/.claude/settings.json:

{ "pluginConfigs": { "agent-deck@claude-mods": { "options": { "autoOpen": "off" } } } }

Limits

  • No cost estimate: the panel counts tokens, not dollars.
  • Context is shown in tokens (the latest request's prompt and output), not as a share of the window.
  • A subagent stopped from outside, whose end no event reports, is marked ended once Claude Code's own agent list no longer has it.

Uninstall

claude plugin uninstall agent-deck@claude-mods

License

MIT

Source 2 files
hooks/register.ts 351 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4const phase = atom({ plugin: 'context-handoff', key: 'phase' } as const, 'idle')
5const cycle = atom({ plugin: 'context-handoff', key: 'cycle' } as const, 0)
6const requestedAt = atom({ plugin: 'context-handoff', key: 'requestedAt' } as const, 0)
7const pending = atom({ plugin: 'context-handoff', key: 'pending' } as const, null)
8const written = atom({ plugin: 'context-handoff', key: 'written' } as const, null)
9const isHeld = atom({ plugin: 'context-handoff', key: 'isHeld' } as const, false)
10
11// Prompts no person typed never carry the handoff; the desktop app's own origin varies by host, so the rest do.
12const NOT_PERSON = new Set([
13  'plugin',
14  'task-notification',
15  'scheduled-trigger',
16  'peer',
17  'peer-send-message',
18  'coordinator',
19  'observer',
20  'observer-activity',
21  'auto-continuation',
22])
23const COMPACT_TRIES = 30
24// The engine reads a context block past 100,000 characters as a head and a path, and 200,000 in all.
25const MAX_BLOCK_CHARS = 95_000
26const FILE_KEYS = ['file_path', 'notebook_path']
27
28type Paths = { handoff: string; inventory: string }
29
30const kTokens = (n: number) => `${Math.round(n / 1000)}k`
31
32const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}… [+${text.length - max} chars]` : text)
33
34const handoffsRoot = async ($: EngineInterface) => {
35  const home = ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
36  return `${home}/.claude/handoffs`
37}
38
39const sessionDir = async ($: EngineInterface) => `${await handoffsRoot($)}/${await $.session.id()}`
40
41const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
42const DAY_MS = 86_400_000
43
44// Removes the folders of other sessions whose newest file is older than the retention. A folder is kept while its
45// session may still need it: the next cycle carries the previous handoff over, and a resumed session re-reads it.
46const pruneOld = async ($: EngineInterface, days: number) => {
47  if (days <= 0) return
48  const root = await handoffsRoot($)
49  if (!(await $.fs.exists(root))) return
50  const current = await $.session.id()
51  const cutoff = (await $.clock.now()) - days * DAY_MS
52  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
53  for (const entry of await $.fs.list(root)) {
54    if (entry.kind !== 'dir' || entry.isLink || entry.name === current || !SESSION_ID.test(entry.name)) continue
55    const dir = `${root}/${entry.name}`
56    const newest = Math.max(0, ...(await $.fs.list(dir)).map(f => f.mtimeMs))
57    if (newest >= cutoff) continue
58    const argv = isWindows ? ['cmd', '/c', 'rmdir', '/s', '/q', dir.replace(/\//g, '\\')] : ['rm', '-rf', dir]
59    await $.process.run(argv).catch(() => undefined)
60  }
61}
62
63const pathsOf = async ($: EngineInterface, n: number): Promise<Paths> => {
64  const dir = await sessionDir($)
65  return { handoff: `${dir}/handoff-${n}.md`, inventory: `${dir}/inventory-${n}.md` }
66}
67
68// A mechanical record of the conversation, so the handoff can be checked against what actually happened.
69const buildInventory = (messages: readonly SessionMessage[], header: string[]) => {
70  const prompts: string[] = []
71  const files = new Map<string, Map<string, number>>()
72  const tools = new Map<string, number>()
73  const commands: string[] = []
74  const searches: string[] = []
75  const agents: string[] = []
76  const errors: string[] = []
77
78  for (const m of messages) {
79    if (m.role === 'user') {
80      // The engine's own notes ride in the user's rows; a slash command is kept as its name alone.
81      const text = m.text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
82      const command = /^<command-name>\s*(\S+?)\s*<\/command-name>/.exec(text)
83      if (command) prompts.push(`(ran ${command[1]})`)
84      else if (text) prompts.push(text)
85      continue
86    }
87    for (const use of m.toolUses) {
88      const input = use.input
89      tools.set(use.tool, (tools.get(use.tool) ?? 0) + 1)
90      for (const key of FILE_KEYS) {
91        const path = input[key]
92        if (typeof path !== 'string') continue
93        const ops = files.get(path) ?? new Map<string, number>()
94        ops.set(use.tool, (ops.get(use.tool) ?? 0) + 1)
95        files.set(path, ops)
96      }
97      if (typeof input.command === 'string') commands.push(`${use.isError ? '✗' : '✓'} [${use.tool}] ${clip(input.command, 400)}`)
98      if (typeof input.pattern === 'string') searches.push(`[${use.tool}] ${clip(input.pattern, 200)}${typeof input.path === 'string' ? ` in ${input.path}` : ''}`)
99      if (use.tool === 'Agent') {
100        agents.push(`${String(input.subagent_type ?? 'general-purpose')}: ${String(input.description ?? '')}${use.agentId ? ` (agent ${use.agentId})` : ''}`)
101      }
102      if (use.isError) errors.push(`${use.tool}: ${clip((use.text ?? '').replace(/\s+/g, ' '), 300)}`)
103    }
104  }
105
106  const list = (items: string[], last: number) =>
107    items.length === 0 ? ['(none)'] : [...(items.length > last ? [`(${items.length - last} earlier entries left out)`] : []), ...items.slice(-last).map(i => `- ${i}`)]
108
109  return [
110    '# Conversation inventory',
111    '',
112    ...header,
113    '',
114    `## The user's messages, verbatim (${prompts.length})`,
115    '',
116    ...(prompts.length ? prompts.map((p, i) => `### ${i + 1}\n\n${clip(p, 6000)}\n`) : ['(none)']),
117    '',
118    `## Files touched (${files.size})`,
119    '',
120    ...list([...files].map(([path, ops]) => `${path}: ${[...ops].map(([tool, n]) => `${tool}×${n}`).join(', ')}`), 500),
121    '',
122    '## Tools used',
123    '',
124    ...list([...tools].sort((a, b) => b[1] - a[1]).map(([tool, n]) => `${tool}: ${n}`), 200),
125    '',
126    `## Shell commands (${commands.length}, ✗ = errored)`,
127    '',
128    ...list(commands, 300),
129    '',
130    `## Searches (${searches.length})`,
131    '',
132    ...list(searches, 100),
133    '',
134    `## Subagents launched (${agents.length})`,
135    '',
136    ...list(agents, 100),
137    '',
138    `## Tool errors (${errors.length})`,
139    '',
140    ...list(errors, 50),
141    '',
142  ].join('\n')
143}
144
145const handoffPrompt = (paths: Paths, previous: Paths | null, tokens: number, threshold: number) =>
146  [
147    `[context-handoff] The context window is at ${kTokens(tokens)} tokens (threshold ${kTokens(threshold)}). The session will be compacted right after this turn, and the handoff you write now is how you will keep the context. Completeness matters more than brevity or tokens.`,
148    '',
149    "Before handling the user's message above, do this, in order:",
150    '1. Go through the WHOLE conversation from its first message, not just the recent turns, including any compaction summary and earlier handoff in it.',
151    `2. Read the inventory at \`${paths.inventory}\` with the Read tool: the user's messages verbatim, every file touched, every command, search, subagent and tool error. Use it as a checklist so nothing that still matters is left out.`,
152    ...(previous ? [`3. Read the previous handoff at \`${previous.handoff}\` and carry over everything in it that is still relevant.`] : []),
153    `${previous ? 4 : 3}. If the working directory is a git repository, run \`git status\` and \`git diff --stat\` and record the uncommitted state and the branch.`,
154    `${previous ? 5 : 4}. Write the handoff at \`${paths.handoff}\` with the Write tool, with these sections:`,
155    '   - Goal and current state: what the user wants overall and exactly where the work stands.',
156    "   - The user's instructions and preferences: quote them verbatim where the wording matters.",
157    '   - Decisions and their reasons, and approaches tried or rejected and why.',
158    '   - Files: for each relevant file, its path, its role, what changed and why, and its state (done, partial, untested).',
159    '   - Tools, commands and environment: build/test/run commands that work, MCP servers and tools used, skills invoked, subagents and what they returned, environment quirks. Name where credentials live, never their values.',
160    '   - Work log: done, in progress, and the next steps in order, each concrete enough to act on.',
161    '   - Open questions, risks, known bugs, failing tests, and error messages verbatim.',
162    '   - Files to re-read after compaction: the few most important ones, each with why.',
163    '   - Anything else needed to continue seamlessly.',
164    '',
165    'Write it in the language of this conversation.',
166    '',
167    "Only then handle the user's message above as usual. Before ending this turn, add to the handoff's work log what you did for that message. The session is compacted right after this turn.",
168  ].join('\n')
169
170const compactInstructions = (paths: Paths) =>
171  `A detailed handoff (${paths.handoff}) and a conversation inventory (${paths.inventory}) were written and will be handed back after compaction. Name both files in the summary and stress the current task, its next step, and the user's latest instructions.`
172
173const restoreBlocks = (paths: Paths, handoff: string, inventory: string | null) => [
174  [
175    'This session was just compacted by the context-handoff mod. Before compaction you wrote the handoff below to keep the important context.',
176    `Before answering the message above, re-read the files the handoff lists under "Files to re-read after compaction"; the full handoff is at ${paths.handoff} and the inventory at ${paths.inventory}.`,
177    '',
178    clip(handoff, MAX_BLOCK_CHARS),
179  ].join('\n'),
180  ...(inventory ? [`The conversation inventory written before compaction (${paths.inventory}):\n\n${clip(inventory, MAX_BLOCK_CHARS)}`] : []),
181]
182
183// A turn of the mod's own does not start in every host (the desktop app runs none), so the handoff rides on the
184// person's next message: armed at the threshold, asked for in that message's turn, compacted after it.
185const arm = async ($: EngineInterface, why: string) => {
186  await update($, phase, () => 'armed')
187  $.ui.toast(`${why}: the handoff is written with your next message, then the session compacts`)
188}
189
190// Writes the inventory and returns the instructions to attach to the person's prompt.
191const prepareHandoff = async ($: EngineInterface, tokens: number, threshold: number) => {
192  await update($, phase, () => 'writing')
193  const n = await update($, cycle, c => c + 1)
194  const paths = await pathsOf($, n)
195  const previous = n > 1 ? await pathsOf($, n - 1) : null
196  const hasPrevious = previous !== null && (await $.fs.exists(previous.handoff))
197
198  const header = [
199    `- Session: ${await $.session.id()}`,
200    `- Working directory: ${await $.session.cwd()}`,
201    `- Model: ${await $.session.model()}`,
202    `- Context: ${kTokens(tokens)} tokens`,
203    `- Written: ${new Date(await $.clock.now()).toISOString()}`,
204    ...(hasPrevious && previous ? [`- Previous handoff: ${previous.handoff}`, `- Previous inventory: ${previous.inventory}`] : []),
205  ]
206  await $.fs.write(paths.inventory, buildInventory(await $.session.messages(), header))
207
208  const at = await $.clock.now()
209  await update($, requestedAt, () => at)
210  return handoffPrompt(paths, hasPrevious ? previous : null, tokens, threshold)
211}
212
213const giveUp = async ($: EngineInterface, why: string) => {
214  await update($, phase, () => 'idle')
215  await update($, isHeld, () => true)
216  $.ui.toast(`context-handoff: ${why}. No automatic retry until the session compacts; /context-handoff arms it again.`)
217}
218
219// The handoff is written but the session did not compact by itself: a /compact by the person still hands it back.
220const askForCompact = async ($: EngineInterface, why: string) => {
221  await update($, phase, () => 'idle')
222  $.ui.log(`context-handoff: automatic compaction failed: ${why}`, { to: 'debug' })
223  $.ui.toast(`context-handoff: the handoff is written but the session could not compact by itself (${why}). Run /compact; the handoff comes back with your next message.`)
224}
225
226// The mod's own session.compact hook does not run for a /compact it raised (the re-entry rule), so it hands the
227// handoff back here too.
228const compactByCommand = async ($: EngineInterface, n: number, paths: Paths) => {
229  try {
230    await $.command.run({ command: 'compact', args: compactInstructions(paths) })
231    if ((await read($, written)) !== n) return
232    await update($, written, () => null)
233    await update($, pending, () => n)
234    await update($, phase, () => 'idle')
235    $.ui.toast('Session compacted: the handoff goes with your next message')
236  } catch (error) {
237    await askForCompact($, error instanceof Error ? error.message : String(error))
238  }
239}
240
241// Compacting rejects while a turn runs; the handoff turn is still closing when its turn.complete fires.
242const compactWhenIdle = ($: EngineInterface, n: number, paths: Paths, attempt: number) => {
243  $.clock.after(1000, () => {
244    void $.session.compact({ instructions: compactInstructions(paths) }).then(
245      async result => {
246        if (result.skip !== undefined) return askForCompact($, `skipped: ${result.skip}`)
247        await update($, written, () => null)
248        await update($, pending, () => n)
249        await update($, phase, () => 'idle')
250        $.ui.toast('Session compacted: the handoff goes with your next message')
251      },
252      (error: unknown) => {
253        const why = error instanceof Error ? error.message : String(error)
254        $.ui.log(`context-handoff: compact attempt ${attempt} rejected: ${why}`, { to: 'debug' })
255        // An SDK host (the desktop app, -p) compacts only inside a turn: run /compact as a command there instead.
256        if (/not available in a headless/i.test(why)) return compactByCommand($, n, paths)
257        return attempt < COMPACT_TRIES ? compactWhenIdle($, n, paths, attempt + 1) : askForCompact($, why)
258      },
259    )
260  })
261}
262
263export const register: Register = (on, options) => {
264  const threshold = Math.max(50, Number(options.thresholdK) || 300) * 1000
265  const retentionDays = Math.max(0, Number(options.retentionDays ?? 2))
266
267  on('session.start', async ($, e, next) => {
268    void pruneOld($, retentionDays).catch(() => undefined)
269    await $.command.register({
270      name: 'context-handoff',
271      description: 'Arm a handoff: written with your next message, then the session compacts and gets it back',
272    })
273    return next(e)
274  })
275
276  on('command.run', { command: 'context-handoff' }, async $ => {
277    const now = await read($, phase)
278    if (now === 'armed') return { text: 'Already armed: the handoff is written with your next message.' }
279    if (now !== 'idle') return { text: 'A handoff is already under way.' }
280    await update($, isHeld, () => false)
281    await arm($, 'Handoff armed')
282    return { text: 'Handoff armed: send your next message; the agent writes the handoff first, then the session compacts.' }
283  })
284
285  on('turn.complete', async ($, e, next) => {
286    const result = await next(e)
287    if (e.agentId !== undefined) return result
288
289    const now = await read($, phase)
290    if (now === 'writing') {
291      if (e.reason !== 'answer') {
292        await giveUp($, 'the handoff turn did not finish')
293        return result
294      }
295      const n = await read($, cycle)
296      const paths = await pathsOf($, n)
297      const since = await read($, requestedAt)
298      const stat = await $.fs.stat(paths.handoff).catch(() => undefined)
299      if (!stat || stat.mtimeMs < since - 2000) {
300        await giveUp($, `no handoff was written at ${paths.handoff}`)
301        return result
302      }
303      await update($, written, () => n)
304      await update($, phase, () => 'compacting')
305      compactWhenIdle($, n, paths, 1)
306      return result
307    }
308
309    if (now !== 'idle' || e.reason !== 'answer' || (await read($, isHeld))) return result
310    const { context } = await $.session.usage()
311    if ((context.tokens ?? 0) >= threshold) await arm($, `Context at ${kTokens(context.tokens ?? 0)}`)
312    return result
313  })
314
315  // Any compaction of the main conversation re-arms the watch, /compact typed by the person included, and hands
316  // back a handoff that was written and still waits for one.
317  on('session.compact', async ($, e, next) => {
318    const result = await next(e)
319    if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
320      await update($, isHeld, () => false)
321      const n = await read($, written)
322      if (n !== null) {
323        await update($, written, () => null)
324        await update($, pending, () => n)
325        await update($, phase, () => 'idle')
326      }
327    }
328    return result
329  })
330
331  on('prompt.submit', async ($, e, next) => {
332    const isPerson = !e.command && !e.isMeta && e.turnId === undefined && !NOT_PERSON.has(e.origin.kind)
333    if (!isPerson) return next(e)
334
335    if ((await read($, phase)) === 'armed') {
336      const { context } = await $.session.usage()
337      const ask = await prepareHandoff($, context.tokens ?? 0, threshold)
338      return next({ ...e, context: [...(e.context ?? []), ask] })
339    }
340
341    const n = await read($, pending)
342    if (n === null) return next(e)
343    await update($, pending, () => null)
344    const paths = await pathsOf($, n)
345    const handoff = await $.fs.read(paths.handoff).catch(() => null)
346    if (!handoff) return next(e)
347    const inventory = await $.fs.read(paths.inventory).catch(() => null)
348    return next({ ...e, context: [...(e.context ?? []), ...restoreBlocks(paths, handoff, inventory)] })
349  })
350}
351
types/index.d.ts 21 lines
1/** Where the handoff cycle stands: waiting, armed for the next message, the agent writing the file, or compacting. */
2export type Phase = 'idle' | 'armed' | 'writing' | 'compacting'
3
4declare module 'claude-code' {
5  interface PluginState {
6    'context-handoff': {
7      phase: Phase
8      /** The handoff cycles this session has started; names each cycle's files. */
9      cycle: number
10      /** When the current handoff was asked for, in ms; the file must be newer than this. */
11      requestedAt: number
12      /** The cycle whose files go with the next prompt, once the session compacted. */
13      pending: number | null
14      /** The cycle whose handoff is written and waits for a compaction, the mod's own or a /compact. */
15      written: number | null
16      /** True after a failed or interrupted cycle: no automatic retry until a compaction. */
17      isHeld: boolean
18    }
19  }
20}
21