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

Mods for Claude Code. Each one is a standalone plugin under plugins/.
| Mod | What it does |
|---|---|
| context-handoff | Watches 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-deck | A 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. |
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.
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.
git status and git diff --stat, and writes a handoff with:Then it handles your message as usual and adds what it did to the handoff.
/compact for you./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.
| Option | Default | What it sets |
|---|---|---|
thresholdK | 300 | Context size, in thousands of tokens, that arms a handoff. Keep it below the auto-compact window, or Claude Code's own compaction comes first. |
retentionDays | 2 | When 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 } } } }
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.
/context-handoff arms it again./compact; the handoff still comes back with your next message.claude plugin uninstall context-handoff@claude-mods
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.
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.
/subagents opens or closes it, and /subagents open only opens it. It also opens by itself when a new batch starts.
Grep · invalidateCache); its reported steps (2/5 · reading the tests); context size, output tokens and time.In a terminal session both draw as text.
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.
| Option | Default | What it sets |
|---|---|---|
autoOpen | on | Opens the panel by itself when a new batch of subagents starts; off opens it only with /subagents. |
stepReports | on | Asks 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" } } } }
claude plugin uninstall agent-deck@claude-mods
MIT
hooks/register.ts 351 lines1import { 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}
351types/index.d.ts 21 lines1/** 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