/timemachine draws the session as a timeline of prompts, tool calls and turn ends from its own transcript. Press a point and give a new instruction: the mod…

Requirements. Mods are on by default in Claude Code 2.1.287+.
/timemachine draws the session as a timeline: every prompt, tool call and turn end, read from the session's own transcript. Press a point, type what you want done differently, and the mod writes a copy of the session cut at that point under a new session id and gives you the command that continues it. The session you are in is not touched, so you can try the other approach and still go back.
⏱ Time machine
9 points 3 prompts 5 calls
──────────────────────────────────────
1 ┏ ● prompt 1 add a retry to the upload client
2 ┃ ◆ Read src/upload.ts
3 ┃ ◆ Edit src/upload.ts <- armed (highlighted row)
4 ┃ ◆ Bash npm test
5 ┗ ■ turn end Tests pass, retry added with backoff.
╭────────────────────────────────────╮
│ Fork from #3 │
│ after: src/upload.ts │
│ Type the new instruction after │
│ /timemachine fork 3 and press Enter│
╰────────────────────────────────────╯
↑ older ↓ newer ⟳ reload ✕ clear
Colors mark the kind: blue prompts, violet turn ends, and a glyph per tool (green Bash, amber Edit/Write, teal Read/Grep/Glob, orange Web, pink Agent, lilac MCP). The armed row is highlighted and rows light up under the pointer.
> /timemachine fork 3 keep the retry but use the existing queue helper instead
Forked at point 3 (Edit src/upload.ts): 41 rows kept.
New session 7c1e…, resume command copied. In a new terminal:
cd '/home/me/app' && claude --resume 7c1e… 'keep the retry but use the existing queue helper instead'
| Command | Does |
|---|---|
/timemachine | opens the pane; pressing a point arms it: the fork bar shows the point's whole text, ⎇ Fork here forks at once, or type an instruction after the /timemachine fork <n> it puts in the prompt |
/timemachine list | the same points as text, numbered |
/timemachine fork <n> [instruction] | forks at point n; with an instruction the resume command carries it as the first message |
Points are of three kinds. ▸ prompt: the fork keeps everything before that prompt, so the instruction replaces it. · tool call: the fork keeps the conversation up to and including that call's result. ■ turn end: the fork keeps the whole turn. The timeline refreshes when a turn completes; the pane's reload button re-reads it on demand, and ⤢ expand adds a second line with more of each point's text. Row width follows the pane's own width.
The mod API has no call that forks or rewinds the live session, so the fork is a new session, not a branch inside the one you are in. What the mod does around that to make it one click:
.gitignore honored) with a throwaway git index: before each prompt, after each Edit/Write/Bash call and at each turn end. Your index, branch and stash are never touched; each snapshot is pinned by refs/time-machine/<session-id>/<point>./timemachine fork <n> [instruction]) thentime-machine/<id> at the snapshot of that point, under .claude/worktrees/,~/.claude/projects/<project>/<session-id>.jsonl (or under CLAUDE_CONFIG_DIR), follows the parentUuid chain from the last row (dropping rewound branches and subagent sidechains), keeps the rows up to the point, closes any tool_use left without its tool_result,claude --desktop --resume <new-id>, macOS), or in a new Terminal window when you gave an instruction (it travels as the resume command's message) or Desktop cannot take it, running in that worktree. Elsewhere, or if the window cannot open, the command is copied to the clipboard instead.Your checkout and the session you are in are not touched.
Limits:
claude --desktop, or Terminal; elsewhere you paste the command.$.fs.read rejects files over 4 MiB, so a very long transcript shows an error in the pane instead of a timeline.claude -p. /timemachine list and fork work there; the pane needs the terminal UI.snapshots (boolean, default true) and openTerminal (boolean, default true) are declared next to copyCommand in plugin.json.
Read from user settings, keyed by the plugin's full id:
{ "pluginConfigs": { "session-time-machine@skills-dir": { "options": { "copyCommand": false } } } }
| Option | Default | |
|---|---|---|
copyCommand | true | copy the resume command to the clipboard |
npx claude-code-templates@latest --mod productivity/session-time-machine
Hooks: session.start (registers /timemachine), command.run, turn.complete, ui.render on Pane. Calls: $.fs.read, $.fs.write, $.env.get (HOME, CLAUDE_CONFIG_DIR), $.session.cwd, $.session.id, $.prompt.fill, $.ui.copy. No network, no process spawn.
Tests: claude plugin test productivity/session-time-machine cover the transcript parsing, the cut points, the tool-pair closing, the resume command, and mount the pane on the terminal and desktop surfaces to press a point.
hooks/register.tsx 420 lines1/* @jsx h */
2/**
3 * session-time-machine — Claude Mod
4 *
5 * `/timemachine` opens a pane with the session's timeline: every prompt, tool
6 * call and turn end, read from the session's own transcript. Press a point
7 * (or run `/timemachine fork <n> <instruction>`) and the mod writes a copy of
8 * the transcript cut at that point under a new session id, with the
9 * `claude --resume <id> "<instruction>"` command that continues it, copied to
10 * the clipboard. The original session is not touched.
11 *
12 * The mod API has no call that forks the live session, so the fork is a new
13 * session you open yourself; see the README.
14 *
15 * Hooks: `session.start` registers the command, `command.run` opens the pane,
16 * lists or forks, `turn.complete` re-reads the transcript,
17 * `ui.render` on Pane draws it.
18 *
19 * Options:
20 * copyCommand: boolean copy the resume command to the clipboard (default true)
21 */
22import type { Register } from 'claude-code'
23import {
24 buildTimeline,
25 clip,
26 mainChain,
27 forkMessage,
28 planFork,
29 newSessionId,
30 parseArgs,
31 parseRows,
32 transcriptPath,
33 type Point,
34 type Row,
35} from './transcript.ts'
36import { MUTATING, loadSnapshots, openInDesktop, openInTerminal, restoreWorktree, snapshotFor, takeSnapshot, type Run } from './snapshots.ts'
37
38const PANE = 'time-machine'
39const C = {
40 accent: '#58a6ff',
41 prompt: '#5eb1ff',
42 turn: '#b392f0',
43 ink: '#e6edf3',
44 chip: '#2a313c',
45 rail: '#4b5563',
46 armed: '#1f3a5f',
47 hover: '#2d3b52',
48 bad: '#f85149',
49 tool: { Bash: '#7ee787', Edit: '#f2cc60', Read: '#6cb6c9', Web: '#ff9d5c', Agent: '#f778ba', Mcp: '#d2a8ff', Other: '#8b949e' },
50}
51
52const toolColor = (name = ''): string =>
53 /^(Edit|Write|MultiEdit|NotebookEdit)$/.test(name) ? C.tool.Edit
54 : name === 'Bash' ? C.tool.Bash
55 : /^(Read|Grep|Glob|LS)$/.test(name) ? C.tool.Read
56 : /^Web/.test(name) ? C.tool.Web
57 : /^(Agent|Task)$/.test(name) ? C.tool.Agent
58 : name.startsWith('mcp__') ? C.tool.Mcp
59 : C.tool.Other
60
61let chain: Row[] = []
62let points: Point[] = []
63let armed: number | undefined
64let offset = 0
65let isExpanded = false
66let forkResult: string | undefined
67let snaps = new Map<string, string>()
68let problem: string | undefined
69
70const ingest = (text: string) => {
71 problem = undefined
72 chain = mainChain(parseRows(text))
73 points = buildTimeline(chain)
74 offset = Math.max(0, points.length - 12)
75}
76
77const fail = (err: unknown) => {
78 problem = `cannot read the transcript (${err instanceof Error ? err.message : String(err)}); the read limit is 4 MiB`
79}
80
81export const register: Register = (on, options) => {
82 const isCopying = options.copyCommand !== false
83 const isSnapshotting = options.snapshots !== false
84 const isOpening = options.openTerminal !== false
85
86 /** What a helper needs from `$`, as plain values and closures: the engine refuses `$` itself passed to a function. */
87 type Ctx = {
88 cwd: string
89 sid: string
90 dir: string
91 run: Run
92 read: (path: string) => Promise<string>
93 write: (path: string, text: string) => Promise<void>
94 copy: (text: string) => Promise<boolean>
95 repaint: () => void
96 }
97
98 const reread = async (c: Ctx) => {
99 await c.read(transcriptPath(c.dir, c.cwd, c.sid)).then(ingest, fail)
100 snaps = await loadSnapshots(c.run, c.cwd, c.sid)
101 }
102 const snapshot = async (c: Ctx, key: string) => {
103 if (!isSnapshotting) return
104 const id = await takeSnapshot(c.run, c.cwd, c.sid, key)
105 if (id) snaps.set(key, id)
106 }
107
108 /** Forks at point `n`: restores the files into a git worktree when a snapshot exists, writes the cut session, opens it. */
109 const forkAt = async (c: Ctx, n: number, instruction: string): Promise<string> => {
110 const { cwd, dir, run } = c
111 const newId = newSessionId()
112 const commit = snapshotFor(points, n, snaps)
113 const tree = commit ? await restoreWorktree(run, cwd, commit, newId.slice(0, 8)) : undefined
114 const note = tree
115 ? `⏱ Forked at point ${n}. Files are restored in this worktree (branch ${tree.branch}). Paths from earlier in this session point at the original checkout: use the matching path under the current directory instead.`
116 : ''
117 // an instruction has to reach the model, which only the resume command's message does; without one Desktop can take it
118 const toDesktop = isOpening && !instruction.trim()
119 const plan = planFork(chain, points, n, toDesktop ? '' : [note, instruction].filter(Boolean).join('\n\n'), tree?.path ?? cwd, dir, newId, toDesktop ? note : '')
120 if ('error' in plan) {
121 if (tree) await run(['git', 'worktree', 'remove', '--force', tree.path], { cwd }).catch(() => undefined)
122 return `time machine: ${plan.error}`
123 }
124 await c.write(plan.file, plan.body)
125 armed = undefined
126 c.repaint()
127 const inDesktop = toDesktop && (await openInDesktop(run, tree?.path ?? cwd, plan.newId))
128 const inTerminal = !inDesktop && isOpening && (await openInTerminal(run, plan.command))
129 const opened = inDesktop || inTerminal
130 const copied = !opened && isCopying ? await c.copy(plan.command).catch(() => false) : false
131 return [
132 `Forked at point ${plan.n} (${plan.label}): ${plan.kept} rows kept.`,
133 tree ? `Files restored in worktree ${tree.path} (branch ${tree.branch}); your checkout is untouched.` : commit === undefined ? 'No file snapshot for this point (not a git project, or taken before the mod was loaded): conversation only.' : 'Could not create the worktree: conversation only.',
134 inDesktop ? 'Opened in Claude Desktop.' : inTerminal ? 'Opened in a new Terminal window.' : `New session ${plan.newId}${copied ? ', resume command copied' : ''}. In a new terminal:\n${plan.command}`,
135 ].join('\n')
136 }
137
138 on('session.start', async ($, e, next) => {
139 await $.command.register({
140 name: 'timemachine',
141 description: 'Timeline of this session; fork it from any point with a new instruction',
142 })
143 return next(e)
144 })
145
146 on('prompt.submit', async ($, e, next) => {
147 if (isSnapshotting && !e.text.trimStart().startsWith('/')) {
148 const c: Ctx = {
149 cwd: await $.session.cwd(),
150 sid: await $.session.id(),
151 dir: (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? '~'}/.claude`,
152 run: (argv, init) => $.process.run(argv, init),
153 read: path => $.fs.read(path),
154 write: (path, text) => $.fs.write(path, text),
155 copy: async text => (await $.ui.copy({ text })).isCopied === true,
156 repaint: () => $.ui.invalidate('ui.render'),
157 }
158 await reread(c)
159 await snapshot(c, `prompt-${points.filter(p => p.kind === 'prompt').length + 1}`)
160 }
161 return next(e)
162 })
163
164 on('tool.call', async ($, e, next) => {
165 const result = await next(e)
166 if (isSnapshotting && e.agentId === undefined && MUTATING.test(String(e.tool)) && e.tool_use_id) {
167 const c: Ctx = {
168 cwd: await $.session.cwd(),
169 sid: await $.session.id(),
170 dir: (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? '~'}/.claude`,
171 run: (argv, init) => $.process.run(argv, init),
172 read: path => $.fs.read(path),
173 write: (path, text) => $.fs.write(path, text),
174 copy: async text => (await $.ui.copy({ text })).isCopied === true,
175 repaint: () => $.ui.invalidate('ui.render'),
176 }
177 await snapshot(c, `tool-${e.tool_use_id}`)
178 }
179 return result
180 })
181
182 on('turn.complete', async ($, e, next) => {
183 if (e.agentId === undefined) {
184 const c: Ctx = {
185 cwd: await $.session.cwd(),
186 sid: await $.session.id(),
187 dir: (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? '~'}/.claude`,
188 run: (argv, init) => $.process.run(argv, init),
189 read: path => $.fs.read(path),
190 write: (path, text) => $.fs.write(path, text),
191 copy: async text => (await $.ui.copy({ text })).isCopied === true,
192 repaint: () => $.ui.invalidate('ui.render'),
193 }
194 await reread(c)
195 await snapshot(c, `turn-${points.filter(p => p.kind === 'prompt').length}`)
196 $.ui.status(points.length > 0 ? `⏱ ${points.length} points · /timemachine` : undefined)
197 $.ui.invalidate('ui.render')
198 }
199 return next(e)
200 })
201
202 on('command.run', { command: 'timemachine' }, async ($, e) => {
203 const args = parseArgs(e.args)
204 if (args.kind === 'error') return { text: args.text }
205 const c: Ctx = {
206 cwd: await $.session.cwd(),
207 sid: await $.session.id(),
208 dir: (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? '~'}/.claude`,
209 run: (argv, init) => $.process.run(argv, init),
210 read: path => $.fs.read(path),
211 write: (path, text) => $.fs.write(path, text),
212 copy: async text => (await $.ui.copy({ text })).isCopied === true,
213 repaint: () => $.ui.invalidate('ui.render'),
214 }
215 await reread(c)
216 if (problem) return { text: `time machine: ${problem}` }
217
218 if (args.kind === 'open') {
219 await $.ui.open({ id: PANE, title: 'Time machine', focus: true })
220 return { text: `Time machine: ${points.length} points. Press one to arm a fork, or /timemachine fork <n> <instruction>.` }
221 }
222 if (args.kind === 'list') {
223 return { text: points.map(p => `${String(p.n).padStart(3)} ${p.label}`).join('\n') || 'No points yet.' }
224 }
225 return { text: await forkAt(c, args.n, args.instruction) }
226 })
227
228 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
229 const { Box, Text, Button } = $.ui.resolve(e)
230 // header, count chips, rule, footer bar and the paging row take 9 of the pane's rows
231 const room = Math.max(3, Math.floor(((e.viewport?.rows ?? 24) - 9) / (isExpanded ? 2 : 1)))
232 const last = Math.max(0, points.length - room)
233 const start = Math.min(Math.max(offset, 0), last)
234 const shown = points.slice(start, start + room)
235 // the pane's own width: viewport.columns is the whole surface (95 reported for a 150-column terminal)
236 const cols = e.props.bodyColumns || (e.viewport?.columns ?? 60)
237 const wide = Math.max(16, cols - 12)
238 const turns = points.filter(p => p.kind === 'prompt').length
239 const tools = points.filter(p => p.kind === 'tool').length
240 const pick = points.find(p => p.n === armed)
241 const repaint = () => $.ui.invalidate('ui.render')
242 const files = pick ? (snapshotFor(points, pick.n, snaps) ? 'Files: restored from a git snapshot into a new worktree.' : 'Files: no snapshot for this point, so the fork is conversation only.') : ''
243 const preview = (
244 <Box key="foot" flexDirection="column" marginTop={1} borderStyle="round" borderColor={pick ? C.accent : C.rail} paddingX={1}>
245 {pick ? (
246 <Box key="armed" flexDirection="column">
247 <Text bold color={C.accent}>{`Fork from #${pick.n}`}</Text>
248 <Text dimColor>{`${pick.kind === 'prompt' ? 'The session is cut before this prompt' : pick.kind === 'turn' ? 'The session is cut after this turn' : `The session is cut after this ${pick.tool ?? 'tool'} call`}:`}</Text>
249 <Text color={C.ink} wrap="wrap">{clip(pick.full, 900)}</Text>
250 <Text dimColor wrap="wrap">{files}</Text>
251 <Box key="forkrow" flexDirection="row" marginTop={1}>
252 <Button
253 key="forknow"
254 variant="primary"
255 onPress={async () => {
256 forkResult = 'Forking…'
257 repaint()
258 forkResult = await forkAt({
259 cwd: await $.session.cwd(),
260 sid: await $.session.id(),
261 dir: (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? '~'}/.claude`,
262 run: (argv, init) => $.process.run(argv, init),
263 read: path => $.fs.read(path),
264 write: (path, text) => $.fs.write(path, text),
265 copy: async text => (await $.ui.copy({ text })).isCopied === true,
266 repaint,
267 }, pick.n, '')
268 repaint()
269 }}
270 >
271 ⎇ Fork here
272 </Button>
273 </Box>
274 <Text dimColor>{`Fork here opens the new session for you; or type /timemachine fork ${pick.n} <instruction> to start it with a message`}</Text>
275 </Box>
276 ) : (
277 <Text dimColor>Press any point to fork the session from there with a new instruction.</Text>
278 )}
279 </Box>
280 )
281 return (
282 <Box key="tm" flexDirection="column">
283 <Box key="head" flexDirection="row">
284 <Text bold color={C.accent}>
285 {'⏱ Time machine'}
286 </Text>
287 </Box>
288 <Box key="chips" flexDirection="row" marginTop={1}>
289 <Text color={C.ink} backgroundColor={C.chip}>{` ${points.length} points `}</Text>
290 <Text>{' '}</Text>
291 <Text color={C.prompt} backgroundColor={C.chip}>{` ${turns} prompts `}</Text>
292 <Text>{' '}</Text>
293 <Text color={C.tool.Bash} backgroundColor={C.chip}>{` ${tools} calls `}</Text>
294 </Box>
295 <Text color={C.rail}>{'─'.repeat(Math.max(10, cols - 2))}</Text>
296 {forkResult && (
297 <Box key="result" flexDirection="column" marginTop={1} borderStyle="round" borderColor={C.tool.Bash} paddingX={1}>
298 <Text wrap="wrap" color={C.ink}>{forkResult}</Text>
299 <Button
300 key="dismiss"
301 hover={{ color: C.bad }}
302 onPress={() => {
303 forkResult = undefined
304 repaint()
305 }}
306 >
307 ✕ dismiss
308 </Button>
309 </Box>
310 )}
311 {pick && preview}
312 {problem && <Text color={C.bad}>{problem}</Text>}
313 {points.length === 0 && !problem && <Text dimColor>Nothing recorded yet: send a prompt, then reload.</Text>}
314 {shown.map(p => {
315 const isArmed = armed === p.n
316 const tint = p.kind === 'prompt' ? C.prompt : p.kind === 'turn' ? C.turn : toolColor(p.tool)
317 const rail = p.kind === 'prompt' ? '┏' : p.kind === 'turn' ? '┗' : '┃'
318 const glyph = p.kind === 'prompt' ? '●' : p.kind === 'turn' ? '■' : '◆'
319 const kind = p.kind === 'prompt' ? `prompt ${p.turn}` : p.kind === 'turn' ? 'turn end' : (p.tool ?? 'tool')
320 return (
321 <Box key={`r${p.n}`} flexDirection="column" backgroundColor={isArmed ? C.armed : undefined}>
322 <Box flexDirection="row">
323 <Text dimColor>{String(p.n).padStart(3)} </Text>
324 <Text color={C.rail}>{rail}</Text>
325 <Text bold color={tint}>{` ${glyph} `}</Text>
326 <Button
327 key={`p${p.n}`}
328 plain
329 hover={{ backgroundColor: C.hover, bold: true }}
330 onPress={() => {
331 armed = p.n
332 repaint()
333 void $.prompt.fill({ text: `/timemachine fork ${p.n} ` }).catch(() => undefined)
334 }}
335 >
336 {clip(`${kind} ${p.detail}`, wide)}
337 </Button>
338 </Box>
339 {isExpanded && (
340 <Box key={`d${p.n}`} flexDirection="row">
341 <Text>{' '}</Text>
342 <Text color={C.rail}>{p.kind === 'tool' ? '┃ ' : ' '}</Text>
343 <Text dimColor wrap="wrap">{clip(p.full, wide * 3)}</Text>
344 </Box>
345 )}
346 </Box>
347 )
348 })}
349 {!pick && preview}
350 <Box key="nav" flexDirection="row" marginTop={1}>
351 <Button
352 key="older"
353 hover={{ color: C.accent }}
354 onPress={() => {
355 offset = Math.max(0, start - room)
356 repaint()
357 }}
358 >
359 ↑ older
360 </Button>
361 <Text>{' '}</Text>
362 <Button
363 key="newer"
364 hover={{ color: C.accent }}
365 onPress={() => {
366 offset = Math.min(last, start + room)
367 repaint()
368 }}
369 >
370 ↓ newer
371 </Button>
372 <Text>{' '}</Text>
373 <Button
374 key="expand"
375 hover={{ color: C.accent }}
376 onPress={() => {
377 isExpanded = !isExpanded
378 repaint()
379 }}
380 >
381 {isExpanded ? '⤡ collapse' : '⤢ expand'}
382 </Button>
383 <Text>{' '}</Text>
384 <Button
385 key="reload"
386 hover={{ color: C.accent }}
387 onPress={async () => {
388 await reread({
389 cwd: await $.session.cwd(),
390 sid: await $.session.id(),
391 dir: (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? '~'}/.claude`,
392 run: (argv, init) => $.process.run(argv, init),
393 read: path => $.fs.read(path),
394 write: (path, text) => $.fs.write(path, text),
395 copy: async text => (await $.ui.copy({ text })).isCopied === true,
396 repaint: () => $.ui.invalidate('ui.render'),
397 })
398 repaint()
399 }}
400 >
401 ⟳ reload
402 </Button>
403 {pick && (
404 <Button
405 key="clear"
406 hover={{ color: C.bad }}
407 onPress={() => {
408 armed = undefined
409 repaint()
410 }}
411 >
412 {' ✕ clear'}
413 </Button>
414 )}
415 </Box>
416 </Box>
417 )
418 })
419}
420hooks/transcript.ts 226 lines1/**
2 * Pure helpers for session-time-machine: no `$`, no I/O, so the tests run them
3 * on plain strings. A Claude Code transcript is a JSONL file of rows chained by
4 * `parentUuid`; the live conversation is the chain that ends at the last row
5 * written. A fork keeps a prefix of that chain, which is why a "point in time"
6 * here is just a count of chain rows to keep.
7 */
8
9export type Row = Record<string, any>
10
11export type PointKind = 'prompt' | 'tool' | 'turn'
12
13export type Point = {
14 /** 1-based, as `/timemachine fork <n>` names it */
15 n: number
16 kind: PointKind
17 /** 1-based number of the user prompt this point belongs to (0 before the first) */
18 turn: number
19 label: string
20 /** the tool's name on a tool point, what colors its glyph */
21 tool?: string
22 /** the tool_use id of a tool point: the key of its working-tree snapshot */
23 id?: string
24 /** the label without its kind: the prompt's text, the call's argument, the turn's last reply */
25 detail: string
26 /** the whole text behind the label (a prompt, a command, a reply), capped at 2000 characters */
27 full: string
28 /** how many rows of the chain a fork from here keeps (before closing tool pairs) */
29 keep: number
30}
31
32const CHAIN_TYPES = new Set(['user', 'assistant', 'system', 'attachment'])
33
34export function parseRows(text: string): Row[] {
35 const rows: Row[] = []
36 for (const line of text.split('\n')) {
37 if (!line.trim()) continue
38 try {
39 const row = JSON.parse(line)
40 if (row && typeof row === 'object') rows.push(row)
41 } catch {
42 // a half-written last line while a turn streams
43 }
44 }
45 return rows
46}
47
48/** The live conversation: the parentUuid chain back from the last row written, oldest first. */
49export function mainChain(rows: readonly Row[]): Row[] {
50 const byUuid = new Map<string, Row>()
51 let leaf: Row | undefined
52 for (const row of rows) {
53 if (typeof row.uuid !== 'string' || !CHAIN_TYPES.has(row.type) || row.isSidechain === true) continue
54 byUuid.set(row.uuid, row)
55 leaf = row
56 }
57 const chain: Row[] = []
58 const seen = new Set<string>()
59 for (let row = leaf; row && !seen.has(row.uuid); row = byUuid.get(row.parentUuid)) {
60 seen.add(row.uuid)
61 chain.push(row)
62 }
63 return chain.reverse()
64}
65
66const blocks = (row: Row): any[] => (Array.isArray(row.message?.content) ? row.message.content : [])
67
68/** The text a person typed, or undefined for a tool result, a meta row or an injected reminder. */
69export function promptText(row: Row): string | undefined {
70 if (row.type !== 'user' || row.isMeta === true) return undefined
71 const content = row.message?.content
72 let text: string
73 if (typeof content === 'string') text = content
74 else if (Array.isArray(content)) {
75 if (content.some((b: any) => b?.type === 'tool_result')) return undefined
76 text = content.filter((b: any) => b?.type === 'text').map((b: any) => String(b.text ?? '')).join('\n')
77 } else return undefined
78 text = text.trim()
79 if (!text || /^<(system-reminder|local-command|command-name|command-message|task-notification)/.test(text)) return undefined
80 return text
81}
82
83export const clip = (text: string, max: number): string => {
84 const one = text.replace(/[`\s]+/g, ' ').trim()
85 return one.length > max ? `${one.slice(0, Math.max(1, max - 1))}…` : one
86}
87
88function toolLabel(block: any): string {
89 const input = block.input ?? {}
90 const arg = input.command ?? input.file_path ?? input.pattern ?? input.path ?? input.url ?? input.description ?? input.prompt
91 return typeof arg === 'string' ? `${block.name} ${clip(arg, 60)}` : String(block.name)
92}
93
94function toolFull(block: any): string {
95 const input = block.input ?? {}
96 if (typeof input.command === 'string') return input.command
97 if (typeof input.file_path === 'string' && typeof input.new_string === 'string') return `${input.file_path}\n- ${input.old_string ?? ''}\n+ ${input.new_string}`
98 if (typeof input.file_path === 'string' && typeof input.content === 'string') return `${input.file_path}\n${input.content}`
99 try {
100 return JSON.stringify(input)
101 } catch {
102 return String(block.name)
103 }
104}
105
106/** Prompts, tool calls and turn ends of the chain, in order, each with the prefix a fork keeps. */
107export function buildTimeline(chain: readonly Row[]): Point[] {
108 const points: Point[] = []
109 const add = (kind: PointKind, turn: number, label: string, keep: number, detail = label, tool?: string, full = detail, id?: string) =>
110 points.push({ n: points.length + 1, kind, turn, label, keep, detail, tool, id, full: full.trim().slice(0, 2000) })
111
112 const resultAt = new Map<string, number>()
113 chain.forEach((row, i) => {
114 if (row.type !== 'user') return
115 for (const b of blocks(row)) if (b?.type === 'tool_result' && typeof b.tool_use_id === 'string') resultAt.set(b.tool_use_id, i)
116 })
117
118 let turn = 0
119 let lastText = ''
120 const endTurn = (end: number) => {
121 if (turn > 0) add('turn', turn, lastText ? `turn ${turn} ended: ${clip(lastText, 60)}` : `turn ${turn} ended`, end, clip(lastText, 90) || `turn ${turn} ended`, undefined, lastText || `turn ${turn} ended`)
122 }
123 chain.forEach((row, i) => {
124 const text = promptText(row)
125 if (text !== undefined) {
126 endTurn(i)
127 turn += 1
128 lastText = ''
129 add('prompt', turn, `before: ${clip(text, 70)}`, i, clip(text, 90), undefined, text)
130 return
131 }
132 if (row.type !== 'assistant') return
133 for (const b of blocks(row)) {
134 if (b?.type === 'text' && String(b.text ?? '').trim()) lastText = String(b.text)
135 else if (b?.type === 'tool_use') {
136 const at = resultAt.get(b.id)
137 if (at !== undefined) add('tool', turn, toolLabel(b), at + 1, toolLabel(b).slice(String(b.name).length).trim(), String(b.name), toolFull(b), String(b.id))
138 }
139 }
140 })
141 endTurn(chain.length)
142 return points
143}
144
145/**
146 * The prefix a fork keeps: grown until every tool_use in it that is answered
147 * later in the chain has its tool_result, so the API accepts it. A call the
148 * original never answered (an interrupted turn) stays as it was; Claude Code
149 * closes those itself on resume.
150 */
151export function closePrefix(chain: readonly Row[], keep: number): number {
152 const answeredAt = new Map<string, number>()
153 chain.forEach((row, i) => {
154 for (const b of blocks(row)) if (b?.type === 'tool_result') answeredAt.set(b.tool_use_id, i)
155 })
156 let n = Math.max(0, Math.min(keep, chain.length))
157 // each row pulled in may itself hold a call answered further on
158 for (let seen = 0; seen < n; seen++) {
159 for (const b of blocks(chain[seen]!)) {
160 if (b?.type !== 'tool_use') continue
161 const at = answeredAt.get(b.id)
162 if (at !== undefined) n = Math.max(n, at + 1)
163 }
164 }
165 return n
166}
167
168/** The JSONL of a fork: the first `keep` chain rows, under `newId`, with their uuids and parents intact. */
169export function forkTranscript(chain: readonly Row[], keep: number, newId: string, cwd?: string, note = ''): string {
170 const n = closePrefix(chain, keep)
171 const rows: unknown[] = chain.slice(0, n).map(row => ({ ...row, sessionId: newId, ...(cwd ? { cwd } : {}) }))
172 const last = rows[n - 1] as Row | undefined
173 // a closing user message: the model reads where its files are before the person says anything
174 if (note && last) rows.push({ parentUuid: last.uuid, isSidechain: false, userType: 'external', cwd, sessionId: newId, version: last.version, type: 'user', message: { role: 'user', content: note }, uuid: newSessionId(), timestamp: new Date().toISOString() })
175 return rows.map(r => JSON.stringify(r)).join('\n') + (n > 0 ? '\n' : '')
176}
177
178/** `/` and every other non-alphanumeric of the directory become `-`, as Claude Code names a project folder. */
179export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-')
180
181export const transcriptPath = (configDir: string, cwd: string, sessionId: string): string =>
182 `${configDir.replace(/\/+$/, '')}/projects/${projectSlug(cwd)}/${sessionId}.jsonl`
183
184export const shellQuote = (text: string): string => `'${text.replace(/'/g, `'\\''`)}'`
185
186export function resumeCommand(cwd: string, newId: string, instruction: string): string {
187 const base = `cd ${shellQuote(cwd)} && claude --resume ${newId}`
188 return instruction.trim() ? `${base} ${shellQuote(instruction)}` : base
189}
190
191/** A random v4 uuid; the module has no crypto, so Math.random does (an id, not a secret). */
192export function newSessionId(random: () => number = Math.random): string {
193 const hex = (n: number) => Array.from({ length: n }, () => Math.floor(random() * 16).toString(16)).join('')
194 return `${hex(8)}-${hex(4)}-4${hex(3)}-${'89ab'[Math.floor(random() * 4)]}${hex(3)}-${hex(12)}`
195}
196
197export type ForkArgs = { kind: 'open' } | { kind: 'list' } | { kind: 'fork'; n: number; instruction: string } | { kind: 'error'; text: string }
198
199export function parseArgs(args: string): ForkArgs {
200 const text = args.trim()
201 if (!text) return { kind: 'open' }
202 if (text === 'list') return { kind: 'list' }
203 const m = /^fork\s+(\d+)(?:\s+([\s\S]+))?$/.exec(text)
204 if (m) return { kind: 'fork', n: Number(m[1]), instruction: (m[2] ?? '').trim() }
205 return { kind: 'error', text: 'usage: /timemachine · /timemachine list · /timemachine fork <n> [new instruction]' }
206}
207
208export type ForkPlan = { error: string } | { newId: string; file: string; body: string; kept: number; command: string; label: string; n: number }
209
210/** Everything a fork needs short of writing the file: shared by the command and the pane's button. */
211export function planFork(chain: readonly Row[], points: readonly Point[], n: number, instruction: string, cwd: string, configDir: string, newId = newSessionId(), note = ''): ForkPlan {
212 const point = points.find(p => p.n === n)
213 if (!point) return { error: `no point ${n} (1-${points.length}); /timemachine list shows them` }
214 const body = forkTranscript(chain, point.keep, newId, cwd, note)
215 const kept = body ? body.trimEnd().split('\n').length - (note ? 1 : 0) : 0
216 if (kept === 0) return { error: 'nothing before that point to fork from' }
217 return { newId, file: transcriptPath(configDir, cwd, newId), body, kept, command: resumeCommand(cwd, newId, instruction), label: clip(point.label, 60), n }
218}
219
220export const forkMessage = (plan: Extract<ForkPlan, { newId: string }>, copied: boolean): string =>
221 [
222 `Forked at point ${plan.n} (${plan.label}): ${plan.kept} rows kept.`,
223 `New session ${plan.newId}${copied ? ', resume command copied' : ''}. In a new terminal:`,
224 plan.command,
225 ].join('\n')
226hooks/snapshots.ts 125 lines1/**
2 * Working-tree snapshots for session-time-machine.
3 *
4 * A snapshot is a git commit of the whole working tree (tracked, modified and
5 * untracked files, .gitignore honored) built with a throwaway index, so the
6 * person's index, branch and stash are never touched. Each one is pinned by a
7 * ref under `refs/time-machine/<session-id>/`, which is also how a later run
8 * finds them again: no state lives outside git.
9 */
10import type { Point } from './transcript.ts'
11
12export type Run = (
13 argv: readonly string[],
14 init?: { cwd?: string; env?: Record<string, string>; timeoutMs?: number },
15) => Promise<{ exitCode: number; stdout: string; stderr: string }>
16
17/** Tools that can change files; the others leave the tree as the last snapshot had it. */
18export const MUTATING = /^(Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell)$/
19
20const IDENT = { GIT_AUTHOR_NAME: 'time-machine', GIT_AUTHOR_EMAIL: 'time-machine@localhost', GIT_COMMITTER_NAME: 'time-machine', GIT_COMMITTER_EMAIL: 'time-machine@localhost' }
21const safe = (key: string): string => key.replace(/[^A-Za-z0-9_-]/g, '_')
22
23export const refPrefix = (sessionId: string): string => `refs/time-machine/${safe(sessionId)}/`
24
25/** The snapshot key of a point's own moment: before a prompt, after a tool call, after a turn. */
26export function ownKey(p: Pick<Point, 'kind' | 'turn' | 'id'>): string | undefined {
27 if (p.kind === 'prompt') return `prompt-${p.turn}`
28 if (p.kind === 'turn') return `turn-${p.turn}`
29 return p.id ? `tool-${safe(p.id)}` : undefined
30}
31
32/** The newest snapshot at or before a point: read-only tool calls take the last one that could have changed files. */
33export function snapshotFor(points: readonly Point[], n: number, snaps: ReadonlyMap<string, string>): string | undefined {
34 for (let i = points.findIndex(p => p.n === n); i >= 0; i--) {
35 const key = ownKey(points[i]!)
36 const hit = key ? snaps.get(key) : undefined
37 if (hit) return hit
38 }
39 return undefined
40}
41
42/** Commits the working tree under `key`; resolves to the commit, or undefined outside a git repo or on any git failure. */
43export async function takeSnapshot(run: Run, cwd: string, sessionId: string, key: string): Promise<string | undefined> {
44 try {
45 const top = await run(['git', 'rev-parse', '--show-toplevel'], { cwd })
46 const dir = await run(['git', 'rev-parse', '--absolute-git-dir'], { cwd })
47 if (top.exitCode !== 0 || dir.exitCode !== 0) return undefined
48 const root = top.stdout.trim()
49 const env = { ...IDENT, GIT_INDEX_FILE: `${dir.stdout.trim()}/time-machine-index` }
50 const opts = { cwd: root, env, timeoutMs: 120_000 }
51 const head = await run(['git', 'rev-parse', '-q', '--verify', 'HEAD'], { cwd: root })
52 const parent = head.exitCode === 0 ? head.stdout.trim() : undefined
53 if ((await run(parent ? ['git', 'read-tree', parent] : ['git', 'read-tree', '--empty'], opts)).exitCode !== 0) return undefined
54 if ((await run(['git', 'add', '-A'], opts)).exitCode !== 0) return undefined
55 const tree = await run(['git', 'write-tree'], opts)
56 if (tree.exitCode !== 0) return undefined
57 const commit = await run(['git', 'commit-tree', tree.stdout.trim(), ...(parent ? ['-p', parent] : []), '-m', `time-machine ${key}`], opts)
58 const id = commit.stdout.trim()
59 if (commit.exitCode !== 0 || !id) return undefined
60 const ref = await run(['git', 'update-ref', `${refPrefix(sessionId)}${safe(key)}`, id], { cwd: root })
61 return ref.exitCode === 0 ? id : undefined
62 } catch {
63 return undefined
64 }
65}
66
67/** Every snapshot of the session, keyed as `takeSnapshot` named them. */
68export async function loadSnapshots(run: Run, cwd: string, sessionId: string): Promise<Map<string, string>> {
69 const out = new Map<string, string>()
70 try {
71 const prefix = refPrefix(sessionId)
72 const res = await run(['git', 'for-each-ref', '--format=%(refname) %(objectname)', prefix], { cwd })
73 if (res.exitCode !== 0) return out
74 for (const line of res.stdout.split('\n')) {
75 const [ref, id] = line.trim().split(' ')
76 if (ref && id) out.set(ref.slice(prefix.length), id)
77 }
78 } catch {
79 // not a repo: no snapshots
80 }
81 return out
82}
83
84export type Worktree = { path: string; branch: string }
85
86/** A new worktree on its own branch at the snapshot, next to the project's other `.claude/worktrees`; undefined when git refuses. */
87export async function restoreWorktree(run: Run, cwd: string, commit: string, tag: string): Promise<Worktree | undefined> {
88 try {
89 const top = await run(['git', 'rev-parse', '--show-toplevel'], { cwd })
90 if (top.exitCode !== 0) return undefined
91 const path = `${top.stdout.trim()}/.claude/worktrees/time-machine-${tag}`
92 const branch = `time-machine/${tag}`
93 const add = await run(['git', 'worktree', 'add', '-b', branch, path, commit], { cwd: top.stdout.trim() })
94 return add.exitCode === 0 ? { path, branch } : undefined
95 } catch {
96 return undefined
97 }
98}
99
100const appleQuote = (text: string): string => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
101
102/** Opens `command` in a new Terminal window (macOS); resolves whether it did. */
103export async function openInTerminal(run: Run, command: string): Promise<boolean> {
104 try {
105 const os = await run(['uname', '-s'])
106 if (os.stdout.trim() !== 'Darwin') return false
107 const res = await run(['osascript', '-e', `tell application "Terminal" to do script ${appleQuote(command)}`, '-e', 'tell application "Terminal" to activate'])
108 return res.exitCode === 0
109 } catch {
110 return false
111 }
112}
113
114/** Opens the saved session in Claude Desktop (`claude --desktop --resume`, which wants a terminal, so `script` gives it one); resolves whether Desktop took it. */
115export async function openInDesktop(run: Run, cwd: string, sessionId: string): Promise<boolean> {
116 try {
117 const os = await run(['uname', '-s'])
118 if (os.stdout.trim() !== 'Darwin') return false
119 const res = await run(['script', '-q', '/dev/null', 'claude', '--desktop', '--resume', sessionId], { cwd, timeoutMs: 60_000 })
120 return res.exitCode === 0 && /Opening session/.test(res.stdout)
121 } catch {
122 return false
123 }
124}
125