Per-prompt snapshots of your whole project, so you can preview and restore to any earlier prompt, Bash changes included.

hooks/register.tsx 661 lines1// Waypoint: a snapshot of the whole project before every prompt and after
2// its turn, kept in a shadow git repository outside the project. /waypoints
3// opens a timeline: pick a prompt, preview what it changed, and restore all
4// files to just before or just after it. Changes made by Bash, subagents and
5// your own editor are caught too, because it snapshots the folder itself.
6//
7// Before any restore it takes a safety snapshot, so a restore can be undone.
8// It cannot undo what lives outside the project folder: databases, installed
9// packages, pushed commits, deployments.
10
11import { atom, read, update } from 'claude-code'
12import type { Elements, EngineInterface, Register } from 'claude-code'
13
14import type { WaypointCheckpoint, WaypointDetail, WaypointRepo } from '../types'
15import { DEFAULT_EXCLUDES, gitArgv, parseNumstat, parsePatch, repoDir, shortHash } from './git'
16import type { Stats } from './git'
17
18const PANE = 'waypoint'
19const KEEP = 300
20const PREVIEW_FILES = 8
21const PREVIEW_CHARS = 9000
22
23const repo = atom({ plugin: 'waypoint', key: 'repo' } as const, null)
24const offReason = atom({ plugin: 'waypoint', key: 'offReason' } as const, null)
25const checkpoints = atom({ plugin: 'waypoint', key: 'checkpoints' } as const, [])
26const selected = atom({ plugin: 'waypoint', key: 'selected' } as const, null)
27const cursor = atom({ plugin: 'waypoint', key: 'cursor' } as const, null)
28const action = atom({ plugin: 'waypoint', key: 'action' } as const, null)
29const detail = atom({ plugin: 'waypoint', key: 'detail' } as const, null)
30const isWorking = atom({ plugin: 'waypoint', key: 'isWorking' } as const, false)
31const note = atom({ plugin: 'waypoint', key: 'note' } as const, null)
32const message = atom({ plugin: 'waypoint', key: 'message' } as const, null)
33
34export const register: Register = on => {
35 on('session.start', async ($, e, next) => {
36 const result = await next(e)
37 for (const spec of COMMANDS) {
38 try {
39 await $.command.register({ ...spec, immediate: true })
40 } catch (error) {
41 $.ui.log(`waypoint: could not register /${spec.name} (${String(error)})`, { to: 'debug' })
42 }
43 }
44 await start($, e.cwd)
45 return result
46 })
47
48 on('turn.start', async ($, e, next) => {
49 const r = await read($, repo)
50 await update($, isWorking, () => true)
51 if (r === null) return next(e)
52 try {
53 const at = await $.clock.now()
54 const n = nextNumber(await read($, checkpoints))
55 const label = e.text.trim() === '' ? '(continued without a new prompt)' : e.text.trim()
56 const before = await snapshot($, r, `before #${n}: ${oneLine(label, 200)}`)
57 await save($, r, list => [...list, { n, kind: 'prompt', label, at, before, turnId: e.turnId }])
58 } catch (error) {
59 $.ui.log(`waypoint: snapshot before the prompt failed (${String(error)})`)
60 }
61 return next(e)
62 })
63
64 on('turn.complete', async ($, e, next) => {
65 const result = await next(e)
66 if (e.agentId !== undefined) return result
67 await update($, isWorking, () => false)
68 const r = await read($, repo)
69 if (r === null) return result
70 const list = await read($, checkpoints)
71 const cp = [...list].reverse().find(c => c.turnId === e.turnId)
72 if (cp === undefined) return result
73 try {
74 const after = await snapshot($, r, `after #${cp.n}: ${oneLine(cp.label, 200)}`)
75 const s = await stats($, r, cp.before, after)
76 await save($, r, all => all.map(c => (c.n === cp.n ? { ...c, after, ...s } : c)))
77 } catch (error) {
78 $.ui.log(`waypoint: snapshot after the turn failed (${String(error)})`)
79 }
80 return result
81 })
82
83 // After a restore, tell Claude once, with the next prompt, that the files moved under it.
84 on('prompt.submit', async ($, e, next) => {
85 const text = await read($, note)
86 if (text === null) return next(e)
87 await update($, note, () => null)
88 return next({ ...e, context: [...(e.context ?? []), text] })
89 })
90
91 // /waypoints [args], plus one command per action so they show up as you type /waypoints.
92 on('command.run', { command: 'waypoints' }, ($, e) => runWaypoints($, e.args.trim()))
93 on('command.run', { command: 'waypoints-open' }, ($, e) => runWaypoints($, e.args.trim() || ''))
94 on('command.run', { command: 'waypoints-restore' }, ($, e) =>
95 e.args.trim() === '' ? { text: 'Which one? For example: /waypoints-restore 11 or /waypoints-restore 11 before' } : runWaypoints($, `restore ${e.args.trim()}`),
96 )
97 on('command.run', { command: 'waypoints-save' }, ($, e) => runWaypoints($, `save ${e.args.trim()}`))
98
99 // The highlight moved onto a row (arrows, Tab, autoFocus): remember it and scroll it to the middle.
100 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
101 const result = await next(e)
102 const key = e.element
103 if (result.deny === undefined && key !== undefined && key in ACTIONS) {
104 await update($, action, () => ACTIONS[key]!)
105 }
106 if (result.deny === undefined && key !== undefined && key.startsWith('cp-')) {
107 await update($, cursor, () => Number(key.slice(3)))
108 try {
109 await $.ui.scroll({ in: PANE, to: { key }, block: 'center' })
110 } catch {
111 // nothing to scroll: the list fits
112 }
113 }
114 return result
115 })
116
117 // Where the arrows would scroll the list instead of moving the highlight, move the highlight.
118 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
119 if (e.origin.kind !== 'person' || e.pointer !== undefined || Math.abs(e.by) !== 1) return next(e)
120
121 // Detail view: up/down walks the a/b/c options.
122 const sel = await read($, selected)
123 if (sel !== null) {
124 const list = await read($, checkpoints)
125 const cp = list.find(c => c.n === sel)
126 if (cp === undefined) return next(e)
127 const ids: string[] = []
128 if (cp.after !== undefined) ids.push('after')
129 if (cp.kind === 'prompt') ids.push('before')
130 ids.push('back')
131 const keyFor: Record<string, string> = { after: 'restore-after', before: 'restore-before', back: 'back' }
132 const cur = (await read($, action)) ?? ids[0]!
133 const at = Math.max(0, ids.indexOf(cur))
134 const to = Math.max(0, Math.min(ids.length - 1, at + Math.sign(e.by)))
135 const nextId = ids[to]!
136 await update($, action, () => nextId)
137 try {
138 await $.ui.focus({ requestId: PANE, key: keyFor[nextId]! })
139 } catch {}
140 return next(e)
141 }
142
143 const rows = [...(await read($, checkpoints))].reverse()
144 if (rows.length === 0) return next(e)
145 const current = (await read($, cursor)) ?? rows[0]!.n
146 const at = Math.max(0, rows.findIndex(c => c.n === current))
147 const to = Math.max(0, Math.min(rows.length - 1, at + Math.sign(e.by)))
148 const n = rows[to]!.n
149 await update($, cursor, () => n)
150 try {
151 await $.ui.focus({ requestId: PANE, key: `cp-${n}` })
152 } catch {
153 // the pane does not hold the keyboard
154 }
155 // Keep the highlighted row in the middle of the window.
156 const top = ((await read($, message)) !== null ? 1 : 0) + ((await read($, isWorking)) ? 1 : 0) + 2
157 const offset = Math.max(0, Math.min(Math.max(0, e.contentRows - e.bodyRows), top + to - Math.floor(e.bodyRows / 2)))
158 return next({ ...e, offset })
159 })
160
161 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
162 const els = $.ui.resolve(e)
163 const { Box, Text } = els
164 const off = await read($, offReason)
165 if (off !== null) return <Text color="yellow">Waypoint is off: {off}</Text>
166 const list = await read($, checkpoints)
167 const n = await read($, selected)
168 const msg = await read($, message)
169 const working = await read($, isWorking)
170 const width = e.props.bodyColumns
171
172 const top = (
173 <Box flexDirection="column">
174 {msg !== null && <Text color="green">{msg}</Text>}
175 {working && <Text color="yellow">Claude is working: restores wait until the turn ends.</Text>}
176 </Box>
177 )
178
179 if (n !== null) {
180 const cp = list.find(c => c.n === n)
181 if (cp !== undefined) {
182 return (
183 <Box flexDirection="column" width={width}>
184 {top}
185 {detailView($, els, cp, await read($, detail), list, await read($, action))}
186 </Box>
187 )
188 }
189 }
190 const at = await read($, cursor)
191 return (
192 <Box flexDirection="column" width={width}>
193 {top}
194 {listView($, els, list, width, at)}
195 </Box>
196 )
197 })
198}
199
200// ---------- the shadow repository ----------
201
202type GitRun = { ok: boolean; out: string; err: string }
203
204async function git($: EngineInterface, r: WaypointRepo, args: string[], timeoutMs = 120_000): Promise<GitRun> {
205 const run = await $.process.run(gitArgv(r, args), {
206 cwd: r.work,
207 timeoutMs,
208 env: { GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
209 })
210 return { ok: run.exitCode === 0, out: run.stdout, err: run.stderr.trim() }
211}
212
213async function hasGit($: EngineInterface): Promise<boolean> {
214 try {
215 return (await $.process.run(['git', '--version'], { timeoutMs: 10_000 })).exitCode === 0
216 } catch {
217 return false
218 }
219}
220
221async function repoFor($: EngineInterface, work: string): Promise<WaypointRepo> {
222 const custom = await $.env.get('WAYPOINT_HOME')
223 const home = custom ?? `${(await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? work}/.claude/waypoint`
224 return { dir: repoDir(home, work, await shortHash(work)), work }
225}
226
227async function ensureRepo($: EngineInterface, r: WaypointRepo): Promise<GitRun> {
228 if (await $.fs.exists(`${r.dir}/HEAD`)) return { ok: true, out: '', err: '' }
229 const init = await $.process.run(['git', 'init', '--bare', '-q', r.dir], { timeoutMs: 30_000 })
230 if (init.exitCode !== 0) return { ok: false, out: '', err: init.stderr.trim() }
231 for (const [key, value] of [
232 ['core.autocrlf', 'false'],
233 ['core.longpaths', 'true'],
234 ['user.name', 'Waypoint'],
235 ['user.email', 'waypoint@localhost'],
236 ['commit.gpgsign', 'false'],
237 ] as const) {
238 await git($, r, ['config', key, value], 15_000)
239 }
240 await $.fs.write(
241 `${r.dir}/info/exclude`,
242 ['# Waypoint never snapshots these (your .gitignore files apply too). Edit freely.', ...DEFAULT_EXCLUDES, ''].join('\n'),
243 )
244 return { ok: true, out: '', err: '' }
245}
246
247/** Records the whole work tree as one commit and returns its id. */
248async function snapshot($: EngineInterface, r: WaypointRepo, msg: string): Promise<string> {
249 const add = await git($, r, ['add', '-A', '--ignore-errors', '.'])
250 if (!add.ok && !/warning|ignored/i.test(add.err)) throw new Error(`git add failed: ${add.err}`)
251 const commit = await git($, r, ['commit', '-q', '--allow-empty', '--no-verify', '--no-gpg-sign', '-m', msg.slice(0, 500)])
252 if (!commit.ok) throw new Error(`git commit failed: ${commit.err}`)
253 const head = await git($, r, ['rev-parse', 'HEAD'], 15_000)
254 if (!head.ok) throw new Error(`git rev-parse failed: ${head.err}`)
255 return head.out.trim()
256}
257
258/** Puts every snapshotted file back as it was in `commit`, and removes files the commit did not have. */
259async function restore($: EngineInterface, r: WaypointRepo, commit: string): Promise<GitRun> {
260 return git($, r, ['read-tree', '-u', '--reset', commit])
261}
262
263async function stats($: EngineInterface, r: WaypointRepo, from: string, to: string): Promise<Stats> {
264 const run = await git($, r, ['diff', '--numstat', '--no-renames', from, to], 60_000)
265 return parseNumstat(run.ok ? run.out : '')
266}
267
268async function patch($: EngineInterface, r: WaypointRepo, from: string, to: string): Promise<string> {
269 const run = await git($, r, ['diff', '-U3', '--no-color', '--no-ext-diff', '--no-renames', from, to], 60_000)
270 return run.ok ? run.out : ''
271}
272
273// ---------- the pane ----------
274
275// Dialog mode (focus + closeOnEscape + holdToasts): the pane takes the keyboard and the
276// arrows walk its rows instead of scrolling the window.
277async function openPane($: EngineInterface) {
278 await $.ui.open({ id: PANE, title: 'Waypoint', focus: true, closeOnEscape: true, holdToasts: true, rows: 20 })
279}
280
281// ---------- commands ----------
282
283const COMMANDS = [
284 { name: 'waypoints', description: 'Waypoint: open the timeline of your prompts (or /waypoints 11, /waypoints restore 11 before)', argumentHint: '[<number> | restore <number> [before] | save <label>]' },
285 { name: 'waypoints-open', description: 'Waypoint: show what one prompt changed', argumentHint: '<number>' },
286 { name: 'waypoints-restore', description: 'Waypoint: restore all files to after a prompt (add "before" for before it)', argumentHint: '<number> [before]' },
287 { name: 'waypoints-save', description: 'Waypoint: save a checkpoint of your files now', argumentHint: '<label>' },
288]
289
290async function runWaypoints($: EngineInterface, args: string): Promise<{ text: string }> {
291 const off = await read($, offReason)
292 if (off !== null) return { text: `Waypoint is off: ${off}` }
293 const r = await read($, repo)
294 if (r !== null && /^save\b/i.test(args)) {
295 if (await read($, isWorking)) return { text: 'Wait for Claude to finish this turn, then save.' }
296 const label = args.replace(/^save\s*/i, '') || 'saved by hand'
297 const n = nextNumber(await read($, checkpoints))
298 const commit = await snapshot($, r, `manual #${n}: ${label}`)
299 const at = await $.clock.now()
300 await save($, r, list => [...list, { n, kind: 'manual', label, at, before: commit, after: commit }])
301 return { text: `Saved waypoint #${n}: ${label}` }
302 }
303 // /waypoints restore 11 [before]: restore from the prompt, no pane keys needed.
304 const restoreArgs = /^restore\s+#?(\d+)(?:\s+(before|after))?\s*$/i.exec(args)
305 if (restoreArgs) {
306 const n = Number(restoreArgs[1])
307 const list = await read($, checkpoints)
308 const cp = list.find(c => c.n === n)
309 if (cp === undefined) return { text: `There is no checkpoint #${n}. Type /waypoints to see the list.` }
310 const wantBefore = (restoreArgs[2] ?? '').toLowerCase() === 'before'
311 if (wantBefore && cp.kind !== 'prompt') return { text: `#${n} is not a prompt, so it has no "before": use /waypoints restore ${n}` }
312 const commit = wantBefore ? cp.before : cp.after
313 if (commit === undefined) {
314 return { text: `#${n} never finished, so there is no "after" snapshot: use /waypoints restore ${n} before` }
315 }
316 const where = cp.kind === 'prompt' ? `${wantBefore ? 'before' : 'after'} #${n}` : `#${n}`
317 await update($, message, () => null)
318 const done = await confirmRestore($, commit, where, cp, list)
319 return { text: (await read($, message)) ?? (done ? `Restored to ${where}.` : 'Restore cancelled.') }
320 }
321
322 // /waypoints 11: open straight on checkpoint #11.
323 const pick = /^#?(\d+)$/.exec(args)
324 await update($, selected, () => null)
325 await update($, detail, () => null)
326 const newest = (await read($, checkpoints)).reduce<number | null>((max, c) => (max === null || c.n > max ? c.n : max), null)
327 await update($, cursor, () => newest)
328 await openPane($)
329 if (pick) {
330 const n = Number(pick[1])
331 if (!(await read($, checkpoints)).some(c => c.n === n)) return { text: `There is no checkpoint #${n}.` }
332 await select($, n)
333 return { text: `Waypoint: checkpoint #${n}. Restore with /waypoints-restore ${n} (or add "before").` }
334 }
335 const count = (await read($, checkpoints)).length
336 return { text: count === 0 ? 'No waypoints yet: one is taken before every prompt.' : `Waypoint: ${count} checkpoints.` }
337}
338
339// ---------- startup and saving ----------
340
341async function start($: EngineInterface, work: string) {
342 if (!(await hasGit($))) {
343 await update($, offReason, () => 'git was not found on PATH. Install git and start a new session.')
344 return
345 }
346 const r = await repoFor($, work)
347 const made = await ensureRepo($, r)
348 if (!made.ok) {
349 await update($, offReason, () => `could not create the snapshot store at ${r.dir} (${made.err})`)
350 return
351 }
352 await update($, offReason, () => null)
353 await update($, repo, () => r)
354 const stored = await $.store.get(storeKey(r))
355 const list = Array.isArray(stored) ? (stored as WaypointCheckpoint[]) : []
356 await update($, checkpoints, () => list)
357 // Keep the shadow repository compact without slowing the first prompt down.
358 $.clock.after(5_000, () => {
359 void $.process.run(['git', `--git-dir=${r.dir}`, 'gc', '--auto', '--quiet'], { timeoutMs: 120_000 }).catch(() => {})
360 })
361}
362
363async function save($: EngineInterface, r: WaypointRepo, change: (list: WaypointCheckpoint[]) => WaypointCheckpoint[]) {
364 const list = await update($, checkpoints, current => change([...current]).slice(-KEEP))
365 await $.store.set(storeKey(r), list)
366}
367
368function storeKey(r: WaypointRepo): string {
369 return `checkpoints:${r.dir.replace(/\\/g, '/').split('/').pop()}`
370}
371
372function nextNumber(list: readonly WaypointCheckpoint[]): number {
373 return list.reduce((max, c) => Math.max(max, c.n), 0) + 1
374}
375
376// ---------- the timeline ----------
377
378type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Code'>
379
380function listView($: EngineInterface, { Box, Text, Button }: Els, list: readonly WaypointCheckpoint[], width: number, at: number | null) {
381 if (list.length === 0) {
382 return (
383 <Box flexDirection="column">
384 <Text bold>Waypoint</Text>
385 <Text dimColor>No checkpoints yet. One is taken before every prompt you send.</Text>
386 </Box>
387 )
388 }
389 const rows = [...list].reverse()
390 return (
391 <Box flexDirection="column">
392 <Text bold>
393 Waypoint · {list.length} checkpoints <Text dimColor>(newest first)</Text>
394 </Text>
395 <Text dimColor wrap="truncate-end">
396 ↑↓ to move, Enter to open, Esc to close. No keys? Press Ctrl+X then Tab, or type /waypoints-open {rows[0]!.n}
397 </Text>
398 {rows.map(cp => {
399 const isHere = cp.n === (at ?? rows[0]!.n)
400 return (
401 <Button
402 key={`cp-${cp.n}`}
403 plain
404 {...(isHere ? { autoFocus: true as const } : { dimColor: cp.kind === 'safety' })}
405 label={`${isHere ? '❯' : ' '} ${rowLabel(cp, width - 2)}`}
406 onPress={() => select($, cp.n)}
407 />
408 )
409 })}
410 </Box>
411 )
412}
413
414/** Button keys of a checkpoint's options, and the option each one is. */
415const ACTIONS: Record<string, string> = { 'restore-after': 'after', 'restore-before': 'before', back: 'back' }
416
417function detailView(
418 $: EngineInterface,
419 { Box, Text, Button, Code }: Els,
420 cp: WaypointCheckpoint,
421 d: WaypointDetail | null,
422 list: readonly WaypointCheckpoint[],
423 focused: string | null,
424) {
425 const kind = cp.kind === 'safety' ? 'Safety checkpoint' : cp.kind === 'manual' ? 'Saved by hand' : 'Prompt'
426 const after = cp.after
427 const isPrompt = cp.kind === 'prompt'
428
429 // The options, in the order shown: a, b, c. A safety or hand-saved checkpoint has one snapshot, so no "before".
430 type Option = { key: string; id: string; hotkey: string; label: string; help: string; run: () => unknown }
431 const options: Option[] = []
432 if (after !== undefined) {
433 options.push({
434 key: 'restore-after',
435 id: 'after',
436 hotkey: 'a',
437 label: isPrompt ? `Restore to AFTER #${cp.n}` : `Restore #${cp.n}`,
438 help: isPrompt
439 ? `Every file goes back to how it was right after prompt #${cp.n} finished. Keeps what #${cp.n} did, undoes everything after it.`
440 : `Every file goes back to how it was when checkpoint #${cp.n} was taken.`,
441 run: () => confirmRestore($, after, isPrompt ? `after #${cp.n}` : `#${cp.n}`, cp, list),
442 })
443 }
444 if (isPrompt) {
445 options.push({
446 key: 'restore-before',
447 id: 'before',
448 hotkey: 'b',
449 label: `Restore to BEFORE #${cp.n}`,
450 help: `Every file goes back to how it was just before you sent prompt #${cp.n}. Undoes #${cp.n} and everything after it.`,
451 run: () => confirmRestore($, cp.before, `before #${cp.n}`, cp, list),
452 })
453 }
454 options.push({
455 key: 'back',
456 id: 'back',
457 hotkey: 'c',
458 label: 'Back to the list',
459 help: 'Goes back to the list of checkpoints. Nothing is changed.',
460 run: () => select($, null),
461 })
462 const here = options.find(o => o.id === focused) ?? options[0]!
463
464 return (
465 <Box flexDirection="column">
466 <Text bold>
467 {kind} #{cp.n} · {clock(cp.at)} {day(cp.at)}
468 </Text>
469 <Text wrap="truncate-end">{oneLine(cp.label, 300)}</Text>
470
471 <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor="cyan" paddingX={1}>
472 <Text bold>What do you want to do?</Text>
473 {options.map((o, i) => (
474 <Button
475 key={o.key}
476 plain
477 hotkey={o.hotkey}
478 {...(i === 0 ? { autoFocus: true as const } : {})}
479 {...(o.id === here.id ? {} : { dimColor: true })}
480 label={`${o.id === here.id ? '❯' : ' '} ${o.label}`}
481 onPress={o.run}
482 />
483 ))}
484 <Text color="cyan" wrap="wrap">
485 {'→ '}
486 {here.help}
487 </Text>
488 {here.id !== 'back' && (
489 <Text dimColor wrap="wrap">
490 You are asked to confirm, and your current files are saved first as a ⟲ safety checkpoint, so this can be undone.
491 </Text>
492 )}
493 <Text dimColor wrap="truncate-end">
494 ↑↓ or a / b / c, Enter to choose. No keys? Type /waypoints-restore {cp.n}
495 {isPrompt ? ` (or /waypoints-restore ${cp.n} before)` : ''}
496 </Text>
497 </Box>
498
499 {isPrompt && (
500 <Box marginTop={1}>
501 <Text bold>
502 What prompt #{cp.n} changed <Text dimColor>({rowStats(cp) || 'still running, or it never finished'})</Text>
503 </Text>
504 </Box>
505 )}
506 {isPrompt && d === null && <Text dimColor>Loading the changes…</Text>}
507 {isPrompt && d !== null && d.files.length === 0 && <Text dimColor>This prompt changed no files.</Text>}
508 {isPrompt &&
509 d !== null &&
510 d.files.map(f => (
511 <Box key={`file-${f.path}`} flexDirection="column" marginTop={1}>
512 <Text bold>
513 {f.path} <Text color="green">+{f.added}</Text> <Text color="red">−{f.removed}</Text>
514 </Text>
515 {f.isBinary ? (
516 <Text dimColor>binary file changed</Text>
517 ) : f.hunks === '' ? (
518 <Text dimColor>no line changes (renamed, emptied or mode change)</Text>
519 ) : (
520 <Code source={f.hunks} format="diff" path={f.path} wrap="truncate-end" />
521 )}
522 </Box>
523 ))}
524 {isPrompt && d !== null && d.more > 0 && <Text dimColor>…and {d.more} more files not shown.</Text>}
525 </Box>
526 )
527}
528
529async function select($: EngineInterface, n: number | null) {
530 await update($, selected, () => n)
531 await update($, action, () => null)
532 await update($, detail, () => null)
533 await update($, message, () => null)
534 if (n === null) return
535 const r = await read($, repo)
536 const list = await read($, checkpoints)
537 const cp = list.find(c => c.n === n)
538 if (r === null || cp === undefined || cp.kind !== 'prompt') return
539 // What the prompt changed: from before it to after it (or to the newest snapshot if it never finished).
540 const to = cp.after ?? (await snapshotHead($, r)) ?? cp.before
541 const files = parsePatch(await patch($, r, cp.before, to))
542 const shown: typeof files = []
543 let size = 0
544 for (const f of files) {
545 if (shown.length >= PREVIEW_FILES) break
546 const hunks = f.hunks.length > PREVIEW_CHARS - size ? cut(f.hunks, Math.max(0, PREVIEW_CHARS - size)) : f.hunks
547 size += hunks.length
548 shown.push({ ...f, hunks })
549 }
550 const d: WaypointDetail = { n, files: shown, more: files.length - shown.length }
551 if ((await read($, selected)) === n) await update($, detail, () => d)
552}
553
554async function snapshotHead($: EngineInterface, r: WaypointRepo): Promise<string | undefined> {
555 const run = await git($, r, ['rev-parse', 'HEAD'], 15_000)
556 return run.ok ? run.out.trim() : undefined
557}
558
559async function confirmRestore(
560 $: EngineInterface,
561 commit: string,
562 where: string,
563 cp: WaypointCheckpoint,
564 list: readonly WaypointCheckpoint[],
565): Promise<boolean> {
566 if (await read($, isWorking)) {
567 await update($, message, () => 'Claude is still working. Restore once the turn has ended.')
568 return false
569 }
570 let answer = 'Cancel'
571 try {
572 answer = await $.ui.ask(`Restore every project file to ${where}? Your current files are saved first as a safety checkpoint.`, {
573 header: 'Waypoint',
574 options: ['Restore', 'Cancel'],
575 })
576 } catch {
577 return false // dismissed
578 }
579 if (answer !== 'Restore') return false
580 return doRestore($, commit, where, cp, list)
581}
582
583export async function doRestore(
584 $: EngineInterface,
585 commit: string,
586 where: string,
587 cp: WaypointCheckpoint,
588 list: readonly WaypointCheckpoint[],
589): Promise<boolean> {
590 const r = await read($, repo)
591 if (r === null) return false
592 try {
593 const n = nextNumber(list)
594 const safety = await snapshot($, r, `safety #${n}: before restoring to ${where}`)
595 const at = await $.clock.now()
596 await save($, r, all => [...all, { n, kind: 'safety', label: `before restoring to ${where}`, at, before: safety, after: safety }])
597 const done = await restore($, r, commit)
598 if (!done.ok) throw new Error(done.err || 'git read-tree failed')
599 const what = cp.kind === 'prompt' ? ` ("${oneLine(cp.label, 80)}")` : ''
600 await update($, note, () =>
601 `Waypoint restored the project's files to ${where}${what}. Files changed after that point are back to how they were then, ` +
602 'so earlier edits in this conversation may no longer be on disk: re-read a file before editing it.',
603 )
604 await update($, selected, () => null)
605 await update($, detail, () => null)
606 await update($, message, () => `Restored to ${where}. To undo, restore safety checkpoint ⟲${n}.`)
607 $.ui.toast(`Waypoint: restored to ${where}`)
608 return true
609 } catch (error) {
610 await update($, message, () => `Restore failed: ${String(error)}`)
611 return false
612 }
613}
614
615// ---------- helpers ----------
616
617function rowLabel(cp: WaypointCheckpoint, width: number): string {
618 const mark = cp.kind === 'safety' ? '⟲' : cp.kind === 'manual' ? '★' : '#'
619 const head = `${mark}${cp.n} ${clock(cp.at)} `
620 const changed = rowStats(cp)
621 const room = Math.max(12, width - head.length - changed.length - 8)
622 return `${head}${oneLine(cp.label, room).padEnd(room)} ${changed}`
623}
624
625function rowStats(cp: WaypointCheckpoint): string {
626 if (cp.kind !== 'prompt') return ''
627 if (cp.files === undefined) return cp.after === undefined ? '…' : ''
628 if (cp.files === 0) return 'no changes'
629 return `${cp.files} ${cp.files === 1 ? 'file' : 'files'} +${cp.added ?? 0} −${cp.removed ?? 0}`
630}
631
632function cut(hunks: string, max: number): string {
633 if (max <= 0) return ''
634 const parts = hunks.split(/\n(?=@@ )/)
635 const kept: string[] = []
636 let size = 0
637 for (const p of parts) {
638 if (size + p.length + 1 > max) break
639 kept.push(p)
640 size += p.length + 1
641 }
642 return kept.join('\n')
643}
644
645function oneLine(text: string, max: number): string {
646 const flat = text.replace(/\s+/g, ' ').trim()
647 return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
648}
649
650const pad = (n: number) => String(n).padStart(2, '0')
651
652function clock(ms: number): string {
653 const d = new Date(ms)
654 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
655}
656
657function day(ms: number): string {
658 const d = new Date(ms)
659 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
660}
661hooks/git.ts 92 lines1// Pure helpers for the shadow repository: the git argv, the default
2// excludes, and parsers for git's output. Everything that calls $ lives in
3// register.tsx, since $ is only followed into functions of the same file.
4
5import type { WaypointFileDiff, WaypointRepo } from '../types'
6
7/** Folders that are big, rebuilt from scratch, and never worth a snapshot. */
8export const DEFAULT_EXCLUDES = [
9 'node_modules/',
10 '.venv/',
11 'venv/',
12 'env/',
13 '__pycache__/',
14 '.pytest_cache/',
15 '.mypy_cache/',
16 '.tox/',
17 'dist/',
18 'build/',
19 'out/',
20 '.next/',
21 '.nuxt/',
22 '.svelte-kit/',
23 '.turbo/',
24 '.parcel-cache/',
25 '.gradle/',
26 'target/',
27 '.dart_tool/',
28 'Pods/',
29 'coverage/',
30 '.cache/',
31 '.DS_Store',
32 'Thumbs.db',
33 '*.log',
34 // Session Journal's log: a restore must never roll it back.
35 '.claude/journal/',
36]
37
38/** The argv for one git command against the shadow repository. */
39export function gitArgv(repo: WaypointRepo, args: readonly string[]): string[] {
40 return ['git', '-c', 'core.safecrlf=false', '-c', 'core.quotepath=false', `--git-dir=${repo.dir}`, `--work-tree=${repo.work}`, ...args]
41}
42
43/** The shadow repository's folder for a project: <home>/<project name>-<hash of its path>. */
44export function repoDir(home: string, work: string, hash: string): string {
45 const name = (work.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'project').replace(/[^\w.-]/g, '_')
46 return `${home.replace(/[\\/]+$/, '')}/${name}-${hash}`
47}
48
49export type Stats = { files: number; added: number; removed: number }
50
51export function parseNumstat(text: string): Stats {
52 let files = 0
53 let added = 0
54 let removed = 0
55 for (const line of text.split('\n')) {
56 const m = /^(\d+|-)\t(\d+|-)\t/.exec(line)
57 if (!m) continue
58 files += 1
59 added += m[1] === '-' ? 0 : Number(m[1])
60 removed += m[2] === '-' ? 0 : Number(m[2])
61 }
62 return { files, added, removed }
63}
64
65/** Splits `git diff` output into one entry per file, hunks only (headers dropped). */
66export function parsePatch(text: string): WaypointFileDiff[] {
67 const files: WaypointFileDiff[] = []
68 for (const chunk of text.split(/^diff --git /m).slice(1)) {
69 const lines = chunk.split('\n')
70 const header = lines[0] ?? ''
71 const plus = lines.find(l => l.startsWith('+++ '))
72 const minus = lines.find(l => l.startsWith('--- '))
73 const fromPlus = plus && plus !== '+++ /dev/null' ? plus.slice(4).replace(/^b\//, '') : undefined
74 const fromMinus = minus && minus !== '--- /dev/null' ? minus.slice(4).replace(/^a\//, '') : undefined
75 const fromHeader = /^a\/(.*) b\/(.*)$/.exec(header)?.[2]
76 const path = fromPlus ?? fromMinus ?? fromHeader ?? header
77 const isBinary = lines.some(l => l.startsWith('Binary files '))
78 const start = lines.findIndex(l => l.startsWith('@@ '))
79 const body = start < 0 ? [] : lines.slice(start).filter(l => l !== '\\ No newline at end of file')
80 while (body.length > 0 && body[body.length - 1] === '') body.pop()
81 const added = body.filter(l => l.startsWith('+')).length
82 const removed = body.filter(l => l.startsWith('-')).length
83 files.push({ path, hunks: body.join('\n'), added, removed, isBinary })
84 }
85 return files
86}
87
88export async function shortHash(text: string): Promise<string> {
89 const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text.toLowerCase())))
90 return Array.from(bytes.slice(0, 5), b => b.toString(16).padStart(2, '0')).join('')
91}
92types/index.d.ts 52 lines1export type WaypointCheckpoint = {
2 /** Number shown to the person: #1, #2, ... per project. */
3 n: number
4 /** prompt: taken before a prompt ran; safety: taken before a restore; manual: /waypoints save. */
5 kind: 'prompt' | 'safety' | 'manual'
6 /** The prompt's text (prompt), or what the checkpoint is for. */
7 label: string
8 /** When it was taken, ms since the epoch. */
9 at: number
10 /** Snapshot commit of the files before the prompt ran (or at the moment taken). */
11 before: string
12 /** Snapshot commit after the prompt's turn ended; absent while it runs or if it never finished. */
13 after?: string
14 turnId?: string
15 files?: number
16 added?: number
17 removed?: number
18}
19
20export type WaypointFileDiff = { path: string; hunks: string; added: number; removed: number; isBinary: boolean }
21
22export type WaypointDetail = {
23 n: number
24 files: WaypointFileDiff[]
25 /** Files left out of the preview to keep it readable. */
26 more: number
27}
28
29export type WaypointRepo = { dir: string; work: string }
30
31declare module 'claude-code' {
32 interface PluginState {
33 waypoint: {
34 repo: WaypointRepo | null
35 /** Why Waypoint is off, when it is (no git, no project folder). */
36 offReason: string | null
37 checkpoints: WaypointCheckpoint[]
38 selected: number | null
39 /** The highlighted row in the list (a checkpoint number); the list keeps it in the middle. */
40 cursor: number | null
41 /** The highlighted option in a checkpoint's view: 'after', 'before' or 'back'. */
42 action: string | null
43 detail: WaypointDetail | null
44 isWorking: boolean
45 /** Told to Claude with the next prompt after a restore. */
46 note: string | null
47 /** A line for the pane: what just happened. */
48 message: string | null
49 }
50 }
51}
52