/handoff writes a handoff note to .claude/handoff.local.md; a new session offers to continue from it.

/handoff writes a handoff note to .claude/handoff.local.md; a new session offers to continue from it.
Part of the Mod Box, which bundles it with other mods and one window to switch and configure them all. This is the same mod on its own.
In a terminal Claude Code session (2.1.287 or newer):
/plugin install handoff --marketplace LupusMalusDeviant/claude-mods
| Command | What it does |
|---|---|
/handoff | write the handoff note |
Set them in /config (or when installing).
| Option | Default | What it does |
|---|---|---|
auto | off | on: written by itself shortly before the cache goes cold (when the context is large). |
language | en | Language of the texts this mod shows and writes. |
ttlMinutes | 60 | 60 on a subscription (Pro/Max), 5 for the API's default cache. |
one request over the warm cache per note
MIT, see LICENSE.
hooks/entry.tsx 30 lines1// Entry of the standalone Handoff Note. The mod hands its display to a band (in the Mod Box the
2// band manager draws it); here this hook, registered first and so the outermost, draws it below
3// whatever else is above the prompt.
4import type { Register } from 'claude-code'
5
6import { register as inner } from '../mods/handoff/hooks/register'
7import { openBand, takeBand } from '../mods/settings/hooks/band'
8
9export const register: Register = (on, options) => {
10 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
11 openBand(e)
12 const rest = await next(e)
13 const parts = takeBand(e)
14 if (e.props.hasSurvey || parts.length === 0) return rest
15 const { Box, Text } = $.ui.resolve(e)
16 return (
17 <Box flexDirection="column">
18 {rest}
19 {parts.map(p => (
20 <Box key={`band-${p.id}`} flexDirection="row" paddingX={1}>
21 <Box flexShrink={1}>{p.line as any}</Box>
22 {(p.actions as any[]).flatMap((a, i) => [<Text key={`gap-${i}`}>{' '}</Text>, a])}
23 </Box>
24 ))}
25 </Box>
26 )
27 })
28 ;(inner as any)(on, options)
29}
30mods/handoff/hooks/register.tsx 207 lines1// Handoff note: so a new session doesn't have to re-read the whole old history at high cost.
2// /handoff asks the model, over its own history (through the warm cache, so cheaply),
3// for a handoff and writes it to <project>/.claude/handoff.local.md.
4// A new session finds the note and offers "Continue" (puts the request into the prompt).
5// Automatic (option): shortly before the cache goes cold while the context is large.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { Handoff } from '../types'
10import { addUsage, USAGE_STORE } from '../../settings/hooks/usage'
11import type { UsageBook } from '../../settings/hooks/usage'
12import { offer as offerBand } from '../../settings/hooks/band'
13import type { BandPart } from '../../settings/hooks/band'
14
15const note = atom({ plugin: 'handoff', key: 'note' } as const, null as Handoff | null)
16const text = atom({ plugin: 'handoff', key: 'text' } as const, '')
17const busy = atom({ plugin: 'handoff', key: 'busy' } as const, false)
18
19const FILE = '.claude/handoff.local.md'
20
21// Display language (options.language: de | en)
22let lang: 'de' | 'en' = 'en'
23const t = (de: string, en: string) => (lang === 'en' ? en : de)
24const PANE = 'handoff'
25const MAX_AGE_MS = 14 * 24 * 60 * 60_000
26const AUTO_BEFORE_COLD_MS = 6 * 60_000
27const AUTO_MIN_TOKENS = 40_000
28
29// Instructions to Claude are English; the note is for the next Claude session, so it is English too.
30export function handoffPrompt(_lang: string): string {
31 return (
32 'Write a handoff note for a new Claude Code session that continues this work without the prior transcript. ' +
33 'Markdown, concise, with these sections: "## Goal", "## Done" (with file paths), "## Current state", ' +
34 '"## Open / next steps" (concrete, in order), "## Good to know" (decisions, pitfalls, commands, user preferences). ' +
35 'No preamble, start with the first heading. Do not invent: mark what is unclear as open.'
36 )
37}
38
39export const register: Register = (on, options) => {
40 // Band manager: hand this mod’s display to the Mod Box (it draws, orders and places it).
41 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
42 const rest = await next(e)
43 if (!e.props.hasSurvey) {
44 const part = await band($, e).catch(() => null)
45 if (part) offerBand(e, part)
46 }
47 return rest
48 })
49
50 lang = options.language === 'de' ? 'de' : 'en'
51 const isAuto = options.auto === 'on'
52 const ttlMs = (Number(options.ttlMinutes) || 60) * 60_000
53 let lastAt = 0
54 let autoDoneFor = 0
55
56 on('session.start', {}, async ($, e, next) => {
57 const r = await next(e)
58 await $.command.register({ name: 'handoff', description: t('Übergabe-Notiz für die nächste Sitzung schreiben (.claude/handoff.local.md)', 'Write a handoff note for the next session (.claude/handoff.local.md)') })
59 await offer($)
60 if (isAuto) {
61 $.clock.every(60_000, () => {
62 void (async () => {
63 const now = await $.clock.now()
64 if (!lastAt || autoDoneFor === lastAt || (await read($, busy))) return
65 const left = lastAt + ttlMs - now
66 if (left > AUTO_BEFORE_COLD_MS || left < 30_000) return
67 const { context } = await $.session.usage()
68 if ((context.tokens ?? 0) < AUTO_MIN_TOKENS) return
69 autoDoneFor = lastAt
70 await write($, lang, true)
71 })().catch(() => {})
72 })
73 }
74 return r
75 })
76
77 on('turn.step', {}, async function* ($, e, next) {
78 if (!e.agentId) lastAt = await $.clock.now()
79 yield* next(e)
80 })
81
82 on('command.run', { command: 'handoff' }, async $ => {
83 const r = await write($, lang, false)
84 return { text: r }
85 })
86
87 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
88 const { Box, Text, Markdown } = $.ui.resolve(e) as any
89 const n = await read($, note)
90 const t = await read($, text)
91 return (
92 <Box flexDirection="column" paddingX={1}>
93 <Text dimColor>{n ? `${n.path} · ${fmtDate(n.at)}` : FILE}</Text>
94 {Markdown ? <Markdown key="ub-md" text={t || '(leer)'} /> : <Text>{t || '(leer)'}</Text>}
95 </Box>
96 )
97 })
98}
99
100async function offer($: EngineInterface) {
101 const cwd = await $.session.cwd()
102 const path = `${cwd}/${FILE}`
103 if (!(await $.fs.exists(path))) return
104 const st = await $.fs.stat(path).catch(() => null)
105 const now = await $.clock.now()
106 if (!st || now - st.mtimeMs > MAX_AGE_MS) return
107 // Offer it only to a fresh session, not in the middle of a running one.
108 const msgs = await $.session.messages().catch(() => [])
109 if (msgs.some(m => m.role === 'assistant')) return
110 const body = String(await $.fs.read(path))
111 await update($, text, () => body)
112 await update($, note, () => ({ path, at: st.mtimeMs, preview: preview(body), kind: 'gefunden', isDismissed: false }))
113}
114
115// Mod Box: token usage of this mod's own model calls, for the window (session in $.state,
116// total in $.store). $ stays in this file; usage.ts only does the arithmetic.
117const USAGE = { plugin: 'handoff', key: 'usage' } as const
118async function recordUsage($: EngineInterface, model: string, u: Parameters<typeof addUsage>[3] | undefined) {
119 if (!u) return
120 await update($, USAGE, b => addUsage(b, 'handoff', model, u)).catch(() => {})
121 const total = await $.store.get(USAGE_STORE).catch(() => null)
122 await $.store.set(USAGE_STORE, addUsage(total as UsageBook | null, 'handoff', model, u)).catch(() => {})
123}
124
125async function write($: EngineInterface, lang: string, isAuto: boolean): Promise<string> {
126 if (await read($, busy)) return t('Übergabe wird schon geschrieben.', 'The handoff is already being written.')
127 await update($, busy, () => true)
128 try {
129 const r = await $.model.fork({ prompt: handoffPrompt(lang) })
130 await recordUsage($, await $.session.model().catch(() => ''), 'usage' in r ? r.usage : undefined)
131 if (!r.isAnswered) {
132 const why = r.reason === 'nothing-to-fork' ? t('In dieser Sitzung gibt es noch nichts zu übergeben.', 'Nothing to hand off in this session yet.') : t(`Das Modell hat nicht geantwortet (${r.reason}).`, `The model did not answer (${r.reason}).`)
133 if (!isAuto) $.ui.toast(why)
134 return why
135 }
136 const cwd = await $.session.cwd()
137 const now = await $.clock.now()
138 const path = `${cwd}/${FILE}`
139 const body = `<!-- Mod Box handoff note, ${new Date(now).toISOString()}${isAuto ? ', written automatically before the cache expired' : ''} -->\n\n${r.text.trim()}\n`
140 await $.fs.write(path, body)
141 await update($, text, () => body)
142 await update($, note, () => ({ path, at: now, preview: preview(body), kind: 'geschrieben', isDismissed: false }))
143 const cached = r.usage.cache_read_input_tokens
144 const msg = t(
145 `Übergabe geschrieben: ${FILE}${cached ? ` (${Math.round(cached / 1000)}k Token aus dem Cache)` : ''}. Eine neue Sitzung in diesem Ordner bietet "Weitermachen" an.`,
146 `Handoff written: ${FILE}${cached ? ` (${Math.round(cached / 1000)}k tokens from the cache)` : ''}. A new session in this folder offers "Continue".`,
147 )
148 $.ui.toast(msg)
149 return msg
150 } finally {
151 await update($, busy, () => false)
152 }
153}
154
155export function preview(body: string): string {
156 const line = body
157 .split('\n')
158 .map(l => l.trim())
159 .find(l => l && !l.startsWith('<!--') && !l.startsWith('#'))
160 return (line ?? '').replace(/^[-*]\s*/, '').slice(0, 100)
161}
162
163function fmtDate(ms: number): string {
164 const d = new Date(ms)
165 const p = (n: number) => String(n).padStart(2, '0')
166 return `${p(d.getDate())}.${p(d.getMonth() + 1)}. ${p(d.getHours())}:${p(d.getMinutes())}`
167}
168
169// For the Mod Box band manager (instead of an AbovePrompt hook of its own).
170export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
171 const n = await read($, note)
172 if (!n || n.isDismissed || e.props?.isWorking) return null
173 const { Text, Button } = $.ui.resolve(e)
174 const when = fmtDate(n.at)
175 const head = n.kind === 'gefunden' ? t(`Übergabe vom ${when} gefunden: `, `Handoff from ${when} found: `) : t(`Übergabe geschrieben (${when}): `, `Handoff written (${when}): `)
176 const actions: unknown[] = []
177 if (n.kind === 'gefunden') {
178 actions.push(
179 <Button
180 key="ub-weiter"
181 label={t('Weitermachen', 'Continue')}
182 plain
183 onPress={async () => {
184 await $.prompt.fill({ text: `Read ${FILE} (the last session's handoff) and continue where it stopped. First sum up in two sentences where we stand.${lang === 'en' ? '' : ' Answer in German.'}`, mode: 'replace' })
185 await update($, note, x => (x ? { ...x, isDismissed: true } : x))
186 }}
187 />,
188 )
189 }
190 actions.push(<Button key="ub-ansehen" label={t('Ansehen', 'View')} plain onPress={() => void $.ui.open({ id: PANE, title: t('Übergabe-Notiz', 'Handoff note'), focus: true, closeOnEscape: true })} />)
191 actions.push(<Button key="ub-weg" label="×" plain onPress={() => void update($, note, x => (x ? { ...x, isDismissed: true } : x))} />)
192 return {
193 id: 'handoff',
194 urgent: n.kind === 'gefunden',
195 short: <Text color="cyan">{t('📋 Übergabe', '📋 handoff')}</Text>,
196 line: (
197 <Text>
198 <Text color="cyan" bold>{'📋 '}</Text>
199 <Text>{head}</Text>
200 <Text dimColor>{n.preview}</Text>
201 </Text>
202 ),
203 actions,
204 status: `📋 ${head}${n.preview}`,
205 }
206}
207mods/settings/hooks/band.ts 44 lines1// The band manager's mailbox. A band mod computes its display in its own AbovePrompt hook (with its
2// own $; the validator does not let $ cross an import) and drops it here with offer(). The settings
3// hook, registered first and so the outermost, runs after all of them, takes the parts and draws
4// them ordered, compact or one line each, or sends them to the status line.
5export type BandPart = {
6 /** The mod's switch key, e.g. "cacheMeter". */
7 id: string
8 /** Wants attention (CI red, sign offer …): always its own line with its buttons. */
9 urgent: boolean
10 /** One short element for the compact line. */
11 short: unknown
12 /** The full element for its own line. */
13 line: unknown
14 /** Buttons that belong to the part. */
15 actions: unknown[]
16 /** Plain text for the status line. */
17 status: string
18}
19
20const parts = new Map<string, BandPart[]>()
21
22function keyOf(e: { requestId?: string }): string {
23 return e.requestId ?? 'band'
24}
25
26/** Called by the manager before the inner hooks run. */
27export function openBand(e: { requestId?: string }) {
28 parts.set(keyOf(e), [])
29}
30
31/** Called by a band mod with its display for this render. */
32export function offer(e: { requestId?: string }, part: BandPart) {
33 const list = parts.get(keyOf(e))
34 if (list) list.push(part)
35 else parts.set(keyOf(e), [part])
36}
37
38/** Called by the manager after the inner hooks ran. */
39export function takeBand(e: { requestId?: string }): BandPart[] {
40 const list = parts.get(keyOf(e)) ?? []
41 parts.delete(keyOf(e))
42 return list
43}
44mods/settings/hooks/usage.ts 89 lines1// Token usage of the mods that call a model themselves (Next Steps, Heads-up, Catch Me Up,
2// Handoff Note, keep-warm in the Cache Meter). Pure data and arithmetic: each of those mods records
3// its calls with its own $ (session in $.state "mb_usage", total in $.store "usage-total"),
4// the window only reads. Costs are an estimate from list prices.
5
6export type ModUsage = {
7 calls: number
8 input: number
9 output: number
10 cacheRead: number
11 cacheWrite: number
12 /** Estimated US dollars of the calls whose model had a price. */
13 usd: number
14 /** Calls whose model had no price in the table. */
15 unpriced: number
16}
17
18export type UsageBook = Record<string, ModUsage>
19
20export const USAGE_STORE = 'usage-total'
21
22type Usage = {
23 input_tokens: number
24 output_tokens: number
25 cache_read_input_tokens?: number | null
26 cache_creation_input_tokens?: number | null
27}
28
29// $ per million tokens: [cache read, cache write (1 h), output]; base input is half the 1 h write.
30// List prices as Cache Tax keeps them (September 2026). Longer family names first.
31const PRICES: Array<[string, number, number, number]> = [
32 ['fable-5-1', 0.25, 20, 50],
33 ['fable-5', 1, 20, 50],
34 ['opus-5', 0.5, 10, 25],
35 ['opus-4', 0.5, 10, 25],
36 ['opus', 0.5, 10, 25],
37 ['sonnet-5', 0.2, 4, 10],
38 ['sonnet', 0.3, 6, 15],
39 ['haiku', 0.1, 2, 5],
40]
41
42export function priceOf(model: string): [number, number, number] | null {
43 const m = model.toLowerCase().replace(/[\s.]+/g, '-')
44 for (const [family, read, write, output] of PRICES) if (m.includes(family)) return [read, write, output]
45 return null
46}
47
48export function costOf(model: string, u: Usage): number | null {
49 const p = priceOf(model)
50 if (!p) return null
51 const [read, write, output] = p
52 return ((u.cache_read_input_tokens ?? 0) * read + (u.cache_creation_input_tokens ?? 0) * write + u.input_tokens * (write / 2) + u.output_tokens * output) / 1e6
53}
54
55export function addUsage(book: UsageBook | null | undefined, id: string, model: string, u: Usage): UsageBook {
56 const prev = book?.[id] ?? { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, usd: 0, unpriced: 0 }
57 const usd = costOf(model, u)
58 return {
59 ...(book ?? {}),
60 [id]: {
61 calls: prev.calls + 1,
62 input: prev.input + u.input_tokens,
63 output: prev.output + u.output_tokens,
64 cacheRead: prev.cacheRead + (u.cache_read_input_tokens ?? 0),
65 cacheWrite: prev.cacheWrite + (u.cache_creation_input_tokens ?? 0),
66 usd: prev.usd + (usd ?? 0),
67 unpriced: prev.unpriced + (usd == null ? 1 : 0),
68 },
69 }
70}
71
72export function tokensOf(m: ModUsage): number {
73 return m.input + m.output + m.cacheRead + m.cacheWrite
74}
75
76export function sumBook(book: UsageBook | null | undefined): ModUsage {
77 const out = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, usd: 0, unpriced: 0 }
78 for (const m of Object.values(book ?? {})) {
79 out.calls += m.calls
80 out.input += m.input
81 out.output += m.output
82 out.cacheRead += m.cacheRead
83 out.cacheWrite += m.cacheWrite
84 out.usd += m.usd
85 out.unpriced += m.unpriced
86 }
87 return out
88}
89mods/handoff/types/contract.d.ts 29 lines1/** Model calls the mod made itself: counted per session, priced at list price. */
2export type ModUsage = {
3 calls: number
4 input: number
5 output: number
6 cacheRead: number
7 cacheWrite: number
8 usd: number
9 unpriced: number
10}
11
12export type Handoff = {
13 /** Path of the found or written note. */
14 path: string
15 /** When it was written (ms). */
16 at: number
17 /** The first line with content, as a preview. */
18 preview: string
19 /** gefunden: found at start; geschrieben: written in this session. */
20 kind: 'gefunden' | 'geschrieben'
21 isDismissed: boolean
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 handoff: { usage: Record<string, ModUsage>; note: Handoff | null; text: string; busy: boolean }
27 }
28}
29