SLOPSHOPPER

session-time-machine

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

newpaneguardcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-time-machine
│ ┃ time-machine ✕ › fix the failing auth test and add an audit log call │ ┃ ⏱ Time machine │ ┃ ⏺ Read(src/auth.ts) │ ┃ 0 points 0 prompts 0 calls ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ────────── ⎿ Added 2 lines, removed 1 line │ ┃ cannot read the transcript (ENOENT: no such ⏺ Bash(bun test) │ ┃ file ⎿ 3 pass, 1 fail │ ┃ /Users/dev/.claude/projects/-work-app/previe │ ┃ w-session.jsonl); the read limit is 4 MiB ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Press any point to fork the session from │ │ ┃ │ there with a new instruction. │ › /timemachine │ ┃ ╰──────────────────────────────────────────╯ ⎿ session-time-machine: time machine: cannot read the transcript ( │ ┃ │ ┃ [ ↑ older ] [ ↓ newer ] [ ⤢ expand ] [ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · time-machine
⏱ Time machine 0 points 0 prompts 0 calls ────────────────────────────────────────────────────── cannot read the transcript (ENOENT: no such file /Users/dev/.claude/projects/-work-app/preview-session.jsonl) ; the read limit is 4 MiB ╭──────────────────────────────────────────────────────────╮ │ Press any point to fork the session from there with a │ │ new instruction. │ ╰──────────────────────────────────────────────────────────╯ [ ↑ older ] [ ↓ newer ] [ ⤢ expand ] [ ⟳ reload ]
README

session-time-machine

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'

Usage

CommandDoes
/timemachineopens 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 listthe 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 \offdisables 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.

How it forks, and what it cannot do

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:

  1. Snapshots. While the mod is loaded (and enabled) it commits the whole working tree (tracked, modified and untracked files, .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>.
  2. Fork here (or /timemachine fork <n> [instruction]) then
  3. creates a git worktree on its own branch time-machine/<id> at the snapshot of that point, under .claude/worktrees/,
  4. reads ~/.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,
  5. writes them under a new session id for the worktree, with a first message that tells the model the files live in the worktree,
  6. opens it in Claude Desktop (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:

  • macOS only. The fork opens through claude --desktop, or Terminal; elsewhere you paste the command.
  • Snapshots only exist from when the mod was loaded and enabled. A point without one forks the conversation only, and the pane says so before you press.
  • Git projects only. Outside a git repository there is no file snapshot.
  • Bash side effects outside the repo (installed packages, databases) are not part of a snapshot.
  • 4 MiB. $.fs.read rejects files over 4 MiB, so a very long transcript shows an error in the pane instead of a timeline.
  • Rows only. The timeline is the transcript's prompts, tool calls and turn ends; thinking and attachments are kept in the fork but not listed.
  • Pane not drawn in claude -p. /timemachine list and fork work there; the pane needs the terminal UI.

Options

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 } } } }
OptionDefault
copyCommandtruecopy the resume command to the clipboard

Install

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.

Source 4 files
hooks/register.tsx 457 lines
1/**
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}
457
hooks/transcript.ts 226 lines
1/**
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')
226
hooks/snapshots.ts 125 lines
1/**
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
types/index.d.ts 12 lines
1export 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