At every compaction, saves a handoff note (goal, open tasks, files, recent errors) to .claude/handoff/ and asks the summary to keep those facts; a later fresh…

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.
hooks/register.tsx 156 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import { ago, branchOf, buildNote, DIR, keepInstructions, LATEST, MAX_AGE_MS, noteName, openTaskCount } from './note'
5
6// At each compaction of the main conversation, a handoff note (goal, open
7// tasks, files touched, recent errors) goes to <root>/.claude/handoff/, and the
8// summarizer is asked to keep those facts. /handoff writes one on demand. A
9// fresh session with a recent note offers to resume from it (fills the prompt,
10// never sends). It never compacts on its own. No network, no processes, no
11// model calls. Old notes are not pruned: $.fs has no delete.
12//
13// Only a note this plugin wrote is offered: each write records latest.md's
14// SHA-256 and time in $.store, keyed by its path, and a latest.md that doesn't
15// match (one committed to a cloned repo, say) is never framed as the person's
16// own handoff.
17
18const offer = atom({ plugin: 'checkpoint', key: 'offer' } as const, null)
19const RESUME = 'Read .claude/handoff/latest.md and continue from it: re-check the open tasks and files it lists before acting.'
20
21/** What $.store keeps per latest.md this plugin wrote, under `written:<path>`. */
22type Written = { sha256: string; savedAt: number }
23
24const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
25
26async function rootOf($: EngineInterface): Promise<string> {
27 return (await $.session.root()).replace(/\\/g, '/').replace(/\/+$/, '')
28}
29
30async function sha256(text: string): Promise<string> {
31 const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
32 return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
33}
34
35/** When this plugin wrote `path` with exactly the text it holds now; undefined for any other file. */
36async function writtenAt($: EngineInterface, path: string): Promise<number | undefined> {
37 const record = (await $.store.get(`written:${path}`)) as Written | undefined
38 if (!record || typeof record.sha256 !== 'string' || typeof record.savedAt !== 'number' || !(await $.fs.exists(path))) return undefined
39 return (await sha256(String(await $.fs.read(path)))) === record.sha256 ? record.savedAt : undefined
40}
41
42async function branch($: EngineInterface, root: string): Promise<string | undefined> {
43 try {
44 const head = `${root}/.git/HEAD`
45 return (await $.fs.exists(head)) ? branchOf(String(await $.fs.read(head))) : undefined
46 } catch {
47 return undefined
48 }
49}
50
51/** Writes the note, latest.md and the folder's .gitignore; resolves the note's path relative to the root. */
52async function saveNote($: EngineInterface, messages: readonly SessionMessage[], trigger: string): Promise<string> {
53 const root = await rootOf($)
54 const now = await $.clock.now()
55 const note = buildNote(messages, { now, trigger, cwd: await $.session.cwd(), root, branch: await branch($, root) })
56 const dir = `${root}/${DIR}`
57 const name = noteName(now, trigger)
58 if (!(await $.fs.exists(`${dir}/.gitignore`))) await $.fs.write(`${dir}/.gitignore`, '*\n')
59 await $.fs.write(`${dir}/${name}`, note)
60 await $.fs.write(`${dir}/${LATEST}`, note)
61 const written: Written = { sha256: await sha256(note), savedAt: now }
62 await $.store.set(`written:${dir}/${LATEST}`, written)
63 return `${DIR}/${name}`
64}
65
66/** Fills the resume prompt, unless latest.md has changed since the offer and so is no longer this plugin's note. */
67async function resumeIfOurs($: EngineInterface) {
68 if ((await writtenAt($, `${await rootOf($)}/${DIR}/${LATEST}`)) === undefined) {
69 $.ui.toast(`checkpoint: ${DIR}/${LATEST} changed since it was offered, so it was not resumed`)
70 return
71 }
72 await $.prompt.fill({ text: RESUME, mode: 'replace' })
73}
74
75export const register: Register = on => {
76 on('session.start', async ($, e, next) => {
77 await $.command.register({ name: 'handoff', description: 'Save a checkpoint handoff note now (goal, open tasks, files, recent errors) to .claude/handoff/' }).catch(() => undefined)
78 try {
79 if ((await $.session.messages()).length === 0) {
80 const latest = `${await rootOf($)}/${DIR}/${LATEST}`
81 const savedAt = await writtenAt($, latest)
82 if (savedAt !== undefined && (await $.clock.now()) - savedAt < MAX_AGE_MS) {
83 const tasks = openTaskCount(String(await $.fs.read(latest)))
84 await update($, offer, () => ({ savedAt, tasks }))
85 }
86 }
87 } catch {
88 // no offer, nothing else lost
89 }
90 return next(e)
91 })
92
93 // Main loop only; a precompute installs nothing and may never be used.
94 on('session.compact', async ($, e, next) => {
95 if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
96 let instructions = e.instructions
97 try {
98 const path = await saveNote($, e.messages, e.trigger)
99 instructions = keepInstructions(e.instructions, path)
100 $.ui.toast(`checkpoint: handoff saved to ${path}`)
101 } catch (err) {
102 $.ui.toast(`checkpoint: could not save the handoff note (${errText(err)}); compacting anyway`)
103 }
104 return next(instructions === undefined ? e : { ...e, instructions })
105 }).catch(($, e, next) => next(e)) // never block a compaction
106
107 // /handoff, not /checkpoint: Claude Code ships /checkpoint, so that name is refused.
108 on('command.run', { command: 'handoff' }, async $ => {
109 try {
110 const path = await saveNote($, await $.session.messages(), 'checkpoint')
111 $.ui.toast(`checkpoint: handoff saved to ${path}`)
112 return { text: `Handoff note saved to ${path} (and ${DIR}/${LATEST}).` }
113 } catch (err) {
114 return { text: `checkpoint: could not save the handoff note (${errText(err)}).` }
115 }
116 })
117
118 // Once the person sends anything, the session is no longer fresh.
119 on('prompt.submit', async ($, e, next) => {
120 if (await read($, offer)) await update($, offer, () => null).catch(() => undefined)
121 return next(e)
122 }).catch(($, e, next) => next(e))
123
124 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
125 const below = await next(e)
126 const o = await read($, offer)
127 if (!o || e.props.hasSurvey) return below
128 const { Box, Button, Text } = $.ui.resolve(e)
129 const when = ago((await $.clock.now()) - o.savedAt)
130 const resume = () =>
131 void update($, offer, () => null)
132 .then(() => resumeIfOurs($))
133 .catch(err => $.ui.toast(`checkpoint: could not fill the prompt: ${errText(err)}`))
134 const mine = (
135 <Box flexDirection="column" width={e.props.bodyColumns}>
136 <Text wrap="truncate-end">
137 <Text color="#a78bfa" bold>
138 [CKPT]{' '}
139 </Text>
140 Handoff from {when} ({o.tasks} open task{o.tasks === 1 ? '' : 's'})
141 </Text>
142 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
143 <Button key="resume" label="Resume" variant="primary" onPress={resume} />
144 <Button key="dismiss" label="Dismiss" onPress={() => void update($, offer, () => null)} />
145 </Box>
146 </Box>
147 )
148 return (
149 <Box flexDirection="column">
150 {mine}
151 {below}
152 </Box>
153 )
154 })
155}
156hooks/note.ts 171 lines1import type { SessionMessage } from 'claude-code'
2
3// Pure text: the handoff note built from a transcript, its file name, and the
4// few facts read back out of it. No engine calls here.
5
6export const DIR = '.claude/handoff'
7export const LATEST = 'latest.md'
8export const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
9
10const GOAL_CHARS = 300
11const ERROR_CHARS = 200
12const RECENT_PROMPTS = 5
13const MAX_EDITED = 40
14const MAX_READ = 10
15const MAX_ERRORS = 5
16const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
17
18export type NoteMeta = {
19 /** Milliseconds since the epoch, when the note is written. */
20 now: number
21 /** What made it: a compaction trigger, or `checkpoint` for the command. */
22 trigger: string
23 cwd: string
24 /** The project root; paths under it are written relative to it. */
25 root?: string
26 branch?: string
27}
28
29const slash = (p: string) => p.replace(/\\/g, '/')
30
31export const clip = (text: string, max: number) => {
32 const t = text.replace(/\s+/g, ' ').trim()
33 return t.length > max ? t.slice(0, max - 1) + '…' : t
34}
35
36const pad = (n: number) => String(n).padStart(2, '0')
37
38/** `yyyy-mm-dd HH:MM` in local time. */
39export function stamp(ms: number): string {
40 const d = new Date(ms)
41 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
42}
43
44/** `<yyyy-mm-dd>-<HHmm>-<trigger>.md` */
45export function noteName(ms: number, trigger: string): string {
46 const [day, time] = stamp(ms).split(' ')
47 return `${day}-${time.replace(':', '')}-${trigger.replace(/[^A-Za-z0-9_-]/g, '')}.md`
48}
49
50/** A prompt the person typed: not a tool result, a reminder, a command record or a compaction summary. */
51function isTyped(m: SessionMessage): boolean {
52 if (m.role !== 'user' || (m.toolResults?.length ?? 0) > 0) return false
53 const t = m.text.trim()
54 return t !== '' && !t.startsWith('<') && !t.startsWith('[Request interrupted') && !/^This session is being continued from a previous conversation/.test(t)
55}
56
57const pathOf = (input: Record<string, unknown>): string | undefined => {
58 const p = input.file_path ?? input.notebook_path
59 return typeof p === 'string' && p.trim() ? p.trim() : undefined
60}
61
62function relative(p: string, root?: string): string {
63 const s = slash(p)
64 if (!root) return s
65 const r = slash(root).replace(/\/+$/, '')
66 return s.toLowerCase().startsWith(r.toLowerCase() + '/') ? s.slice(r.length + 1) : s
67}
68
69type Todo = { content: string; status: string }
70
71/** The todo list the last TodoWrite call set, or undefined when there was none. */
72function lastTodos(messages: readonly SessionMessage[]): Todo[] | undefined {
73 for (let i = messages.length - 1; i >= 0; i--) {
74 const uses = messages[i].toolUses ?? []
75 for (let j = uses.length - 1; j >= 0; j--) {
76 if (uses[j].tool !== 'TodoWrite') continue
77 const todos = (uses[j].input as { todos?: unknown }).todos
78 if (!Array.isArray(todos)) continue
79 return todos
80 .filter(t => t && typeof t.content === 'string')
81 .map(t => ({ content: String(t.content), status: typeof t.status === 'string' ? t.status : 'pending' }))
82 }
83 }
84 return undefined
85}
86
87/** The handoff note, markdown. */
88export function buildNote(messages: readonly SessionMessage[], meta: NoteMeta): string {
89 const typed = messages.filter(isTyped).map(m => m.text)
90 const uses = messages.flatMap(m => m.toolUses ?? [])
91
92 const edited: string[] = []
93 const read: string[] = []
94 for (let i = uses.length - 1; i >= 0; i--) {
95 const u = uses[i]
96 const p = pathOf(u.input ?? {})
97 if (!p) continue
98 const rel = relative(p, meta.root)
99 if (EDITS.has(u.tool) && edited.length < MAX_EDITED && !edited.includes(rel)) edited.push(rel)
100 else if (u.tool === 'Read' && read.length < MAX_READ && !read.includes(rel)) read.push(rel)
101 }
102
103 const errors = uses
104 .filter(u => u.isError)
105 .slice(-MAX_ERRORS)
106 .map(u => {
107 const first = (u.text ?? '').split(/\r?\n/).find(l => l.trim()) ?? '(no error text)'
108 return `- ${u.tool}: ${clip(first, ERROR_CHARS)}`
109 })
110
111 const todos = lastTodos(messages)
112 const open = (todos ?? []).filter(t => t.status !== 'completed')
113
114 const out: string[] = ['# Checkpoint handoff', '']
115 out.push(`- Time: ${stamp(meta.now)}`, `- Trigger: ${meta.trigger}`, `- Cwd: ${slash(meta.cwd)}`)
116 if (meta.branch) out.push(`- Branch: ${meta.branch}`)
117 out.push('', '## Goal', '')
118 if (typed.length === 0) out.push('(no typed prompts in this part of the session)')
119 else {
120 out.push('First prompt:', `> ${clip(typed[0], GOAL_CHARS)}`, '', 'Latest prompts (oldest first):')
121 typed.slice(-RECENT_PROMPTS).forEach((t, i) => out.push(`${i + 1}. ${clip(t, GOAL_CHARS)}`))
122 }
123 out.push('', '## Open tasks', '')
124 if (todos === undefined) out.push('(no todo list in this part of the session)')
125 else if (open.length === 0) out.push('(all todos completed)')
126 else open.forEach(t => out.push(`- [ ] ${t.status === 'in_progress' ? '(in progress) ' : ''}${clip(t.content, GOAL_CHARS)}`))
127 out.push('', '## Files touched', '')
128 if (edited.length === 0 && read.length === 0) out.push('(none)')
129 if (edited.length > 0) out.push('Edited (most recent first):', ...edited.map(p => `- ${p}`))
130 if (read.length > 0) out.push(...(edited.length > 0 ? [''] : []), 'Read recently:', ...read.map(p => `- ${p}`))
131 out.push('', '## Recent errors', '')
132 out.push(...(errors.length > 0 ? errors : ['(none)']))
133 return out.join('\n') + '\n'
134}
135
136/** How many `- [ ]` items the note's Open tasks section lists. */
137export function openTaskCount(note: string): number {
138 const at = note.indexOf('## Open tasks')
139 if (at === -1) return 0
140 const rest = note.slice(at + '## Open tasks'.length)
141 const end = rest.search(/\n## /)
142 return (end === -1 ? rest : rest.slice(0, end)).split(/\r?\n/).filter(l => l.startsWith('- [ ]')).length
143}
144
145/** The summarizer's instructions: what it was told, plus the keep-these-facts block. */
146export function keepInstructions(existing: string | undefined, notePath: string): string {
147 const block =
148 'Preserve verbatim in the summary: open tasks, the file paths being worked on, the last errors and decisions. ' +
149 `A full handoff note was saved at ${notePath} (in the project root).`
150 return existing && existing.trim() ? `${existing.trim()}\n\n${block}` : block
151}
152
153/** The branch a `.git/HEAD` names, or a short commit when detached. */
154export function branchOf(head: string): string | undefined {
155 const t = head.trim()
156 const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(t)
157 if (ref) return ref[1]
158 return /^[0-9a-f]{40}$/i.test(t) ? `detached at ${t.slice(0, 7)}` : undefined
159}
160
161/** `5 minutes ago`, `3 hours ago`, `2 days ago`. */
162export function ago(ms: number): string {
163 const m = Math.max(0, Math.round(ms / 60000))
164 if (m < 1) return 'just now'
165 if (m < 60) return `${m} minute${m === 1 ? '' : 's'} ago`
166 const h = Math.round(m / 60)
167 if (h < 24) return `${h} hour${h === 1 ? '' : 's'} ago`
168 const d = Math.round(h / 24)
169 return `${d} day${d === 1 ? '' : 's'} ago`
170}
171types/index.d.ts 8 lines1export type Offer = { savedAt: number; tasks: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 checkpoint: { offer: Offer | null }
6 }
7}
8