/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 | |
/timemachine on \ | off | disables the whole mod (so meus-mods can control it too) — while off, no working-tree snapshot is taken and the transcript is not re-read |
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 beyond git and (macOS only) Terminal/Desktop.
Tests: claude plugin test 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.
Vendored from davila7/claude-code-templates (cli-tool/components/mods/productivity/session-time-machine), MIT licensed. Added the isEnabled toggle above so it fits the on/off convention of every other mod in this repo.
hooks/register.tsx 457 lines1/**
2 * session-time-machine — Claude Mod
3 *
4 * `/timemachine` opens a pane with the session's timeline: every prompt, tool
5 * call and turn end, read from the session's own transcript. Press a point
6 * (or run `/timemachine fork <n> <instruction>`) and the mod writes a copy of
7 * the transcript cut at that point under a new session id, with the
8 * `claude --resume <id> "<instruction>"` command that continues it, copied to
9 * the clipboard. The original session is not touched.
10 *
11 * The mod API has no call that forks the live session, so the fork is a new
12 * session you open yourself; see the README.
13 *
14 * Hooks: `session.start` registers the command, `command.run` opens the pane,
15 * lists or forks, `turn.complete` re-reads the transcript,
16 * `ui.render` on Pane draws it.
17 *
18 * Options:
19 * copyCommand: boolean copy the resume command to the clipboard (default true)
20 *
21 * Vendored from davila7/claude-code-templates (cli-tool/components/mods/productivity/session-time-machine),
22 * MIT licensed. Added: an isEnabled toggle (/timemachine on|off) so meus-mods can control it. While
23 * disabled, no working-tree snapshot is taken and the transcript is not re-read, so the on/off switch
24 * actually stops the mod's background git work, not just the pane.
25 */
26import { atom, read, update } from 'claude-code'
27import type { EngineInterface, Register } from 'claude-code'
28import {
29 buildTimeline,
30 clip,
31 mainChain,
32 planFork,
33 newSessionId,
34 parseArgs,
35 parseRows,
36 transcriptPath,
37 type Point,
38 type Row,
39} from './transcript.ts'
40import { MUTATING, loadSnapshots, openInDesktop, openInTerminal, restoreWorktree, snapshotFor, takeSnapshot, type Run } from './snapshots.ts'
41
42const PANE = 'time-machine'
43const C = {
44 accent: '#58a6ff',
45 prompt: '#5eb1ff',
46 turn: '#b392f0',
47 ink: '#e6edf3',
48 chip: '#2a313c',
49 rail: '#4b5563',
50 armed: '#1f3a5f',
51 hover: '#2d3b52',
52 bad: '#f85149',
53 tool: { Bash: '#7ee787', Edit: '#f2cc60', Read: '#6cb6c9', Web: '#ff9d5c', Agent: '#f778ba', Mcp: '#d2a8ff', Other: '#8b949e' },
54}
55
56const toolColor = (name = ''): string =>
57 /^(Edit|Write|MultiEdit|NotebookEdit)$/.test(name) ? C.tool.Edit
58 : name === 'Bash' ? C.tool.Bash
59 : /^(Read|Grep|Glob|LS)$/.test(name) ? C.tool.Read
60 : /^Web/.test(name) ? C.tool.Web
61 : /^(Agent|Task)$/.test(name) ? C.tool.Agent
62 : name.startsWith('mcp__') ? C.tool.Mcp
63 : C.tool.Other
64
65const isEnabledRef = atom({ plugin: 'session-time-machine', key: 'isEnabled' } as const, true)
66
67let chain: Row[] = []
68let points: Point[] = []
69let armed: number | undefined
70let offset = 0
71let isExpanded = false
72let forkResult: string | undefined
73let snaps = new Map<string, string>()
74let problem: string | undefined
75// kept in sync with isEnabledRef at session.start and on every /timemachine on|off,
76// so the frequent prompt.submit/tool.call hooks below can check it without an async $.state read
77let isEnabled = true
78
79const ingest = (text: string) => {
80 problem = undefined
81 chain = mainChain(parseRows(text))
82 points = buildTimeline(chain)
83 offset = Math.max(0, points.length - 12)
84}
85
86const fail = (err: unknown) => {
87 problem = `cannot read the transcript (${err instanceof Error ? err.message : String(err)}); the read limit is 4 MiB`
88}
89
90// The engine does not hand HOME to mods on Windows; without USERPROFILE the old
91// `~` fallback was never expanded and every transcript read hit ENOENT.
92async function configDir($: EngineInterface): Promise<string> {
93 const explicit = await $.env.get('CLAUDE_CONFIG_DIR')
94 if (explicit) return explicit
95 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '~'
96 return `${home.replace(/\\/g, '/')}/.claude`
97}
98
99export const register: Register = (on, options) => {
100 const isCopying = options.copyCommand !== false
101 const isSnapshotting = options.snapshots !== false
102 const isOpening = options.openTerminal !== false
103
104 /** What a helper needs from `$`, as plain values and closures: the engine refuses `$` itself passed to a function. */
105 type Ctx = {
106 cwd: string
107 sid: string
108 dir: string
109 run: Run
110 read: (path: string) => Promise<string>
111 write: (path: string, text: string) => Promise<void>
112 copy: (text: string) => Promise<boolean>
113 repaint: () => void
114 }
115
116 const reread = async (c: Ctx) => {
117 await c.read(transcriptPath(c.dir, c.cwd, c.sid)).then(ingest, fail)
118 snaps = await loadSnapshots(c.run, c.cwd, c.sid)
119 }
120 const snapshot = async (c: Ctx, key: string) => {
121 if (!isSnapshotting) return
122 const id = await takeSnapshot(c.run, c.cwd, c.sid, key)
123 if (id) snaps.set(key, id)
124 }
125
126 /** Forks at point `n`: restores the files into a git worktree when a snapshot exists, writes the cut session, opens it. */
127 const forkAt = async (c: Ctx, n: number, instruction: string): Promise<string> => {
128 const { cwd, dir, run } = c
129 const newId = newSessionId()
130 const commit = snapshotFor(points, n, snaps)
131 const tree = commit ? await restoreWorktree(run, cwd, commit, newId.slice(0, 8)) : undefined
132 const note = tree
133 ? `⏱ 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.`
134 : ''
135 // an instruction has to reach the model, which only the resume command's message does; without one Desktop can take it
136 const toDesktop = isOpening && !instruction.trim()
137 const plan = planFork(chain, points, n, toDesktop ? '' : [note, instruction].filter(Boolean).join('\n\n'), tree?.path ?? cwd, dir, newId, toDesktop ? note : '')
138 if ('error' in plan) {
139 if (tree) await run(['git', 'worktree', 'remove', '--force', tree.path], { cwd }).catch(() => undefined)
140 return `time machine: ${plan.error}`
141 }
142 await c.write(plan.file, plan.body)
143 armed = undefined
144 c.repaint()
145 const inDesktop = toDesktop && (await openInDesktop(run, tree?.path ?? cwd, plan.newId))
146 const inTerminal = !inDesktop && isOpening && (await openInTerminal(run, plan.command))
147 const opened = inDesktop || inTerminal
148 const copied = !opened && isCopying ? await c.copy(plan.command).catch(() => false) : false
149 return [
150 `Forked at point ${plan.n} (${plan.label}): ${plan.kept} rows kept.`,
151 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.',
152 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}`,
153 ].join('\n')
154 }
155
156 on('session.start', async ($, e, next) => {
157 const saved = await $.store.get('isEnabled').catch(() => undefined)
158 await update($, isEnabledRef, () => (typeof saved === 'boolean' ? saved : isEnabledRef.initial))
159 isEnabled = await read($, isEnabledRef)
160 await $.command.register({
161 name: 'timemachine',
162 description: 'Timeline of this session; fork it from any point with a new instruction (on|off toggles the mod)',
163 argumentHint: '[list|fork <n> [instruction]|on|off]',
164 })
165 return next(e)
166 })
167
168 on('prompt.submit', async ($, e, next) => {
169 if (isEnabled && isSnapshotting && !e.text.trimStart().startsWith('/')) {
170 const c: Ctx = {
171 cwd: await $.session.cwd(),
172 sid: await $.session.id(),
173 dir: await configDir($),
174 run: (argv, init) => $.process.run(argv, init),
175 read: path => $.fs.read(path),
176 write: (path, text) => $.fs.write(path, text),
177 copy: async text => (await $.ui.copy({ text })).isCopied === true,
178 repaint: () => $.ui.invalidate('ui.render'),
179 }
180 await reread(c)
181 await snapshot(c, `prompt-${points.filter(p => p.kind === 'prompt').length + 1}`)
182 }
183 return next(e)
184 })
185
186 on('tool.call', async ($, e, next) => {
187 const result = await next(e)
188 if (isEnabled && isSnapshotting && e.agentId === undefined && MUTATING.test(String(e.tool)) && e.tool_use_id) {
189 const c: Ctx = {
190 cwd: await $.session.cwd(),
191 sid: await $.session.id(),
192 dir: await configDir($),
193 run: (argv, init) => $.process.run(argv, init),
194 read: path => $.fs.read(path),
195 write: (path, text) => $.fs.write(path, text),
196 copy: async text => (await $.ui.copy({ text })).isCopied === true,
197 repaint: () => $.ui.invalidate('ui.render'),
198 }
199 await snapshot(c, `tool-${e.tool_use_id}`)
200 }
201 return result
202 })
203
204 on('turn.complete', async ($, e, next) => {
205 if (isEnabled && e.agentId === undefined) {
206 const c: Ctx = {
207 cwd: await $.session.cwd(),
208 sid: await $.session.id(),
209 dir: await configDir($),
210 run: (argv, init) => $.process.run(argv, init),
211 read: path => $.fs.read(path),
212 write: (path, text) => $.fs.write(path, text),
213 copy: async text => (await $.ui.copy({ text })).isCopied === true,
214 repaint: () => $.ui.invalidate('ui.render'),
215 }
216 await reread(c)
217 await snapshot(c, `turn-${points.filter(p => p.kind === 'prompt').length}`)
218 $.ui.status(points.length > 0 ? `⏱ ${points.length} points · /timemachine` : undefined)
219 $.ui.invalidate('ui.render')
220 }
221 return next(e)
222 })
223
224 on('command.run', { command: 'timemachine' }, async ($, e) => {
225 const raw = e.args.trim().toLowerCase()
226 if (raw === 'on' || raw === 'off') {
227 isEnabled = raw === 'on'
228 await update($, isEnabledRef, () => isEnabled)
229 await $.store.set('isEnabled', isEnabled) // $.state is per session; the store survives a restart
230 if (!isEnabled) {
231 await $.ui.close({ id: PANE }).catch(() => undefined)
232 $.ui.status(undefined)
233 }
234 return { text: `session-time-machine ${isEnabled ? 'ativado' : 'desativado'}.` }
235 }
236 if (!isEnabled) {
237 return { text: 'session-time-machine esta desativado. Uso: /timemachine on' }
238 }
239
240 const args = parseArgs(e.args)
241 if (args.kind === 'error') return { text: args.text }
242 const c: Ctx = {
243 cwd: await $.session.cwd(),
244 sid: await $.session.id(),
245 dir: await configDir($),
246 run: (argv, init) => $.process.run(argv, init),
247 read: path => $.fs.read(path),
248 write: (path, text) => $.fs.write(path, text),
249 copy: async text => (await $.ui.copy({ text })).isCopied === true,
250 repaint: () => $.ui.invalidate('ui.render'),
251 }
252 await reread(c)
253 if (problem) return { text: `time machine: ${problem}` }
254
255 if (args.kind === 'open') {
256 await $.ui.open({ id: PANE, title: 'Time machine', focus: true })
257 return { text: `Time machine: ${points.length} points. Press one to arm a fork, or /timemachine fork <n> <instruction>.` }
258 }
259 if (args.kind === 'list') {
260 return { text: points.map(p => `${String(p.n).padStart(3)} ${p.label}`).join('\n') || 'No points yet.' }
261 }
262 return { text: await forkAt(c, args.n, args.instruction) }
263 })
264
265 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
266 const { Box, Text, Button } = $.ui.resolve(e)
267 // header, count chips, rule, footer bar and the paging row take 9 of the pane's rows
268 const room = Math.max(3, Math.floor(((e.viewport?.rows ?? 24) - 9) / (isExpanded ? 2 : 1)))
269 const last = Math.max(0, points.length - room)
270 const start = Math.min(Math.max(offset, 0), last)
271 const shown = points.slice(start, start + room)
272 // the pane's own width: viewport.columns is the whole surface (95 reported for a 150-column terminal)
273 const cols = e.props.bodyColumns || (e.viewport?.columns ?? 60)
274 const wide = Math.max(16, cols - 12)
275 const turns = points.filter(p => p.kind === 'prompt').length
276 const tools = points.filter(p => p.kind === 'tool').length
277 const pick = points.find(p => p.n === armed)
278 const repaint = () => $.ui.invalidate('ui.render')
279 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.') : ''
280 const preview = (
281 <Box key="foot" flexDirection="column" marginTop={1} borderStyle="round" borderColor={pick ? C.accent : C.rail} paddingX={1}>
282 {pick ? (
283 <Box key="armed" flexDirection="column">
284 <Text bold color={C.accent}>{`Fork from #${pick.n}`}</Text>
285 <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>
286 <Text color={C.ink} wrap="wrap">{clip(pick.full, 900)}</Text>
287 <Text dimColor wrap="wrap">{files}</Text>
288 <Box key="forkrow" flexDirection="row" marginTop={1}>
289 <Button
290 key="forknow"
291 variant="primary"
292 onPress={async () => {
293 forkResult = 'Forking…'
294 repaint()
295 forkResult = await forkAt({
296 cwd: await $.session.cwd(),
297 sid: await $.session.id(),
298 dir: await configDir($),
299 run: (argv, init) => $.process.run(argv, init),
300 read: path => $.fs.read(path),
301 write: (path, text) => $.fs.write(path, text),
302 copy: async text => (await $.ui.copy({ text })).isCopied === true,
303 repaint,
304 }, pick.n, '')
305 repaint()
306 }}
307 >
308 ⎇ Fork here
309 </Button>
310 </Box>
311 <Text dimColor>{`Fork here opens the new session for you; or type /timemachine fork ${pick.n} <instruction> to start it with a message`}</Text>
312 </Box>
313 ) : (
314 <Text dimColor>Press any point to fork the session from there with a new instruction.</Text>
315 )}
316 </Box>
317 )
318 return (
319 <Box key="tm" flexDirection="column">
320 <Box key="head" flexDirection="row">
321 <Text bold color={C.accent}>
322 {'⏱ Time machine'}
323 </Text>
324 </Box>
325 <Box key="chips" flexDirection="row" marginTop={1}>
326 <Text color={C.ink} backgroundColor={C.chip}>{` ${points.length} points `}</Text>
327 <Text>{' '}</Text>
328 <Text color={C.prompt} backgroundColor={C.chip}>{` ${turns} prompts `}</Text>
329 <Text>{' '}</Text>
330 <Text color={C.tool.Bash} backgroundColor={C.chip}>{` ${tools} calls `}</Text>
331 </Box>
332 <Text color={C.rail}>{'─'.repeat(Math.max(10, cols - 2))}</Text>
333 {forkResult && (
334 <Box key="result" flexDirection="column" marginTop={1} borderStyle="round" borderColor={C.tool.Bash} paddingX={1}>
335 <Text wrap="wrap" color={C.ink}>{forkResult}</Text>
336 <Button
337 key="dismiss"
338 hover={{ color: C.bad }}
339 onPress={() => {
340 forkResult = undefined
341 repaint()
342 }}
343 >
344 ✕ dismiss
345 </Button>
346 </Box>
347 )}
348 {pick && preview}
349 {problem && <Text color={C.bad}>{problem}</Text>}
350 {points.length === 0 && !problem && <Text dimColor>Nothing recorded yet: send a prompt, then reload.</Text>}
351 {shown.map(p => {
352 const isArmed = armed === p.n
353 const tint = p.kind === 'prompt' ? C.prompt : p.kind === 'turn' ? C.turn : toolColor(p.tool)
354 const rail = p.kind === 'prompt' ? '┏' : p.kind === 'turn' ? '┗' : '┃'
355 const glyph = p.kind === 'prompt' ? '●' : p.kind === 'turn' ? '■' : '◆'
356 const kind = p.kind === 'prompt' ? `prompt ${p.turn}` : p.kind === 'turn' ? 'turn end' : (p.tool ?? 'tool')
357 return (
358 <Box key={`r${p.n}`} flexDirection="column" backgroundColor={isArmed ? C.armed : undefined}>
359 <Box flexDirection="row">
360 <Text dimColor>{String(p.n).padStart(3)} </Text>
361 <Text color={C.rail}>{rail}</Text>
362 <Text bold color={tint}>{` ${glyph} `}</Text>
363 <Button
364 key={`p${p.n}`}
365 plain
366 hover={{ backgroundColor: C.hover, bold: true }}
367 onPress={() => {
368 armed = p.n
369 repaint()
370 void $.prompt.fill({ text: `/timemachine fork ${p.n} ` }).catch(() => undefined)
371 }}
372 >
373 {clip(`${kind} ${p.detail}`, wide)}
374 </Button>
375 </Box>
376 {isExpanded && (
377 <Box key={`d${p.n}`} flexDirection="row">
378 <Text>{' '}</Text>
379 <Text color={C.rail}>{p.kind === 'tool' ? '┃ ' : ' '}</Text>
380 <Text dimColor wrap="wrap">{clip(p.full, wide * 3)}</Text>
381 </Box>
382 )}
383 </Box>
384 )
385 })}
386 {!pick && preview}
387 <Box key="nav" flexDirection="row" marginTop={1}>
388 <Button
389 key="older"
390 hover={{ color: C.accent }}
391 onPress={() => {
392 offset = Math.max(0, start - room)
393 repaint()
394 }}
395 >
396 ↑ older
397 </Button>
398 <Text>{' '}</Text>
399 <Button
400 key="newer"
401 hover={{ color: C.accent }}
402 onPress={() => {
403 offset = Math.min(last, start + room)
404 repaint()
405 }}
406 >
407 ↓ newer
408 </Button>
409 <Text>{' '}</Text>
410 <Button
411 key="expand"
412 hover={{ color: C.accent }}
413 onPress={() => {
414 isExpanded = !isExpanded
415 repaint()
416 }}
417 >
418 {isExpanded ? '⤡ collapse' : '⤢ expand'}
419 </Button>
420 <Text>{' '}</Text>
421 <Button
422 key="reload"
423 hover={{ color: C.accent }}
424 onPress={async () => {
425 await reread({
426 cwd: await $.session.cwd(),
427 sid: await $.session.id(),
428 dir: await configDir($),
429 run: (argv, init) => $.process.run(argv, init),
430 read: path => $.fs.read(path),
431 write: (path, text) => $.fs.write(path, text),
432 copy: async text => (await $.ui.copy({ text })).isCopied === true,
433 repaint: () => $.ui.invalidate('ui.render'),
434 })
435 repaint()
436 }}
437 >
438 ⟳ reload
439 </Button>
440 {pick && (
441 <Button
442 key="clear"
443 hover={{ color: C.bad }}
444 onPress={() => {
445 armed = undefined
446 repaint()
447 }}
448 >
449 {' ✕ clear'}
450 </Button>
451 )}
452 </Box>
453 </Box>
454 )
455 })
456}
457hooks/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}
125types/index.d.ts 12 lines1export type SessionTimeMachineState = {
2 isEnabled: boolean
3}
4
5declare module 'claude-code' {
6 interface PluginState {
7 'session-time-machine': {
8 isEnabled: boolean
9 }
10 }
11}
12