SLOPSHOPPER

handoff

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

newpanebandcommandtoastmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handoff
│ ┃ handoff ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ /work/app/.claude/handoff.local.md · │ handoff │ │ ┃ 09.10. 08:53 ⏺ Read(src/auth.ts) │ Handoff written: .claude/handoff.local.md. │ │ ┃ <!-- Mod Box handoff note, ⎿ Read 6 lines │ A new session in this folder offers │ │ ┃ 2025-10-09T08:53:20.020Z --> ⏺ Update(src/auth.ts) │ "Continue". │ │ ┃ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ OK ⏺ 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 │ ⎿ handoff: Handoff written: .claude/handoff.local.md. A new sessio │ │ ⟨Claude Code's own drawing⟩ 📋 Handoff written (09.10. 08:53): OK View × ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 📋 Handoff written (09.10. 08:53): OK View ×
Pane · handoff
/work/app/.claude/handoff.local.md · 09.10. 08:53 <!-- Mod Box handoff note, 2025-10-09T08:53:20.020Z --> OK
README

Handoff Note

/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.

Install

In a terminal Claude Code session (2.1.287 or newer):

/plugin install handoff --marketplace LupusMalusDeviant/claude-mods

Commands

CommandWhat it does
/handoffwrite the handoff note

Options

Set them in /config (or when installing).

OptionDefaultWhat it does
autooffon: written by itself shortly before the cache goes cold (when the context is large).
languageenLanguage of the texts this mod shows and writes.
ttlMinutes6060 on a subscription (Pro/Max), 5 for the API's default cache.

Token cost

one request over the warm cache per note

License

MIT, see LICENSE.

Source 5 files
hooks/entry.tsx 30 lines
1// 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}
30
mods/handoff/hooks/register.tsx 207 lines
1// 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}
207
mods/settings/hooks/band.ts 44 lines
1// 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}
44
mods/settings/hooks/usage.ts 89 lines
1// 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}
89
mods/handoff/types/contract.d.ts 29 lines
1/** 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