SLOPSHOPPER

time-machine

Every turn that changes files is a git snapshot off your history: /timeline shows them and puts the working tree back as it was before any turn, Bash changes…

newpaneguardcommandtoastprompt
★ 210v0.1.3MITupdated 2026-10-22midudev/better-claude/plugins/time-machine
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · time-machine
│ ┃ Time Machine ✕ › fix the failing auth test and add an audit log call │ ┃ ⏪ Time Machine 0 states │ ┃ ⏺ Read(src/auth.ts) │ ┃ No snapshots yet. Each turn that changes ⎿ Read 6 lines │ ┃ files lands here. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /timeline │ ┃ ⎿ time-machine: Time Machine: no turn has changed files yet. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Time Machine
⏪ Time Machine 0 states No snapshots yet. Each turn that changes files lands here.
README

Time Machine

A Claude Code mod: your files get a history of states, like an editor's undo tree. Every turn that changes files is a git snapshot off your history, and /timeline takes the working tree to any of them and back. Edits, writes and Bash all count: a sed, a codegen or an rm is undone like an Edit. Nothing is ever lost: your own unsaved changes are kept before a jump.

❯ /timeline

  ⏪ Time Machine                                                  5 states

  ⠹  add rate limiting to the API                                      12s
  │  8 files +212 −40 · api/ ×5 · middleware/ ×2 · +1
  ◉  #4  add a migration for the users table                            5m
  │  + migrations/0007_users.sql +48   ~ db/schema.ts +12 −3
  ○  #3  another way to do it                                           7m
  │  ~ src/auth.ts +9 −2
  ●  #1  start of the session                                          14m
     no files

  ──────────────────────────────────────────────────────────────────────
  selected #3 · 7m   current #4 · 5m   jumped from —
  › another way to do it: keep the sessions in Redis instead of the
    database, and drop the cleanup job
  ⏎ go here: ~ src/auth.ts +2 −9   − migrations/0007_users.sql −48
  ↑↓ choose · ⏎ go   undo   redo
  • One row per state, not per action. Going to a state moves the cursor ◉ and the files; it adds no row.
  • You always see where the files are: ◉ on their row and in the footer (selected is the state Enter goes to, current the one the files are in, jumped from the one they left on the last jump), or a yellow row when you changed files by hand since.
  • Enter goes to a state; choosing a row shows first which files would change. u/r undo and redo: undo goes to the state the current one came from, redo to the newest state that came from it.
  • Branches are kept. Go back and let Claude take another way: the old way stays, dimmed (○ … undone), one Enter away.
  • Your changes are never lost. Files changed by hand since the cursor are saved as a state (your changes, kept before a jump) before any jump, so a jump needs no confirmation.
  • Claude hears it once. With your next prompt it reads which files differ from how it left them; five jumps that end where it left them say nothing.
  • The turn under way shows at once, with a spinner and its time, the moment you send a prompt; its files and lines fill in as Claude edits, and it lands as a state when the turn ends (or goes, when it changed nothing).
  • The files fit one line, however many: one or two by their short path; more as the total, the lines and the biggest folders under the one they all share (8 files +212 −40 · api/ ×5 · tests/ ×2 · +1).
  • The footer stays put: docked beside the transcript it is pinned to the bottom, with the whole prompt of the chosen row (two lines), what Enter would do and the keys. Each part has a fixed height, so moving between rows only changes what is written; with more states than fit, the list is a window around the chosen row (↑ 3 newer, ↓ 2 older).
  • Nothing moves under the pointer: the running row is as tall as any other.
  • From the prompt: /timeline <n>, /timeline undo, /timeline redo.

How it differs from Claude Code's own checkpoints

Claude Code already rewinds the edits its file tools made (Esc Esc, /rewind). Time Machine snapshots the whole working tree with git instead, so it also brings back what Bash changed (a formatter, npm install touching the lockfile, a script that generates files, an rm), and the snapshots outlive the session: a resumed session finds them. The two live side by side.

What it never touches

  • Your history: snapshots are commits no branch points at, kept by refs under refs/time-machine/<session>/<n>. git log does not show them, but anything that reads every ref does: git log --all, gitk --all and the graph views (VS Code Git Graph, lazygit, Fork…) show one commit per state, branching off HEAD, with the turn's label (the first 60 characters of its prompt) as its message. git log --exclude='refs/time-machine/*' --all hides them. git push --mirror would publish them, untracked (not ignored) files and those labels included.
  • Your HEAD and branches: a jump moves the files, never HEAD. Going back past a commit or a checkout puts the old files back on the commit and branch you are on now, and the jump says so.
  • Your staging area: they are built in an index of its own per session (.git/time-machine-<session>.index), so what you staged stays staged.
  • Ignored files: .env, node_modules/ and anything else .gitignore matches are neither saved nor put back.

Install

/plugin marketplace add midudev/better-claude
/plugin install time-machine@better-claude

Configuration

OptionDefaultWhat it does
Keep snapshots for (days)7Older snapshots are dropped when a session starts; git's next gc frees their space.

Limits

  • It needs a git repository; elsewhere /timeline says so and nothing is snapshotted.
  • A snapshot stores every file that is not ignored, untracked ones included. A large untracked file that is not in .gitignore grows .git until its snapshot is dropped, and a file of secrets that is not in .gitignore (a .env.local, a key) is kept in .git the same way: add such files to .gitignore before the session starts. One already snapshotted leaves the next snapshots once it is ignored, and .git when its snapshots are dropped and git collects them.
  • A jump waits for the turn to end, and for any tool still writing (a background agent's included): until then it refuses. It also refuses when the files it would write include an ignored one on disk, or more than can all be checked.
  • A jump puts files back, not commits: if HEAD has moved since the state (a git commit, a git checkout), the files go back and HEAD and the branch stay, so git status shows the old files as changes on top of the new HEAD. Nothing is lost, and the jump message warns about it.
  • A turn whose tools leave the files as they were (a test run that writes nothing) adds no state.
  • A Bash command that only looks (ls, cat, grep, rg, git status, git log, … chained with |, && or ;) takes no snapshot. Any redirection, $(…), tee, xargs, find -delete or -exec, or a command it does not know, does: when unsure, it snapshots.

Cost

Measured on a repository of 12,800 files (Apple Silicon):

StepTime
First snapshot of a session283 ms
Start of a turn that changes files154 ms
End of a turn142 ms
A jump back~170 ms

Most of it is git add -A walking the tree, git's own floor; about 35 ms is the check that keeps files the .gitignore now matches out of the snapshots. The index starts as a copy of the repository's, which carries the stat data of every file (without it the first snapshot hashes the whole tree: 491 ms).

How it works

One hooks module (hooks/register.tsx) over plain modules in src/, each tested on its own with node --test:

  • src/git.js: snapshots, moves and refs, run through $.process.run.
  • src/states.js: the history as data: which states the files are made of, the next id, the merge after a hot reload.
  • src/tools.js and src/bash.js: which tool calls may write, Bash by its command. When unsure, a call may write and gets its snapshot.
  • src/format.js: how states and their files read, in the pane and in the transcript.

The hooks:

  • tool.call on any tool that may write (see src/tools.js: not Read, Grep, a Bash command that only looks or an MCP get_/list_ tool): the first one of a turn writes the tree as it is before it runs (add -A and write-tree in Time Machine's own index, seeded from HEAD with read-tree -m HEAD whenever HEAD moves).
  • turn.complete writes the tree again. Unchanged, nothing is kept. Changed, the turn becomes a state (commit-tree and a ref), its parent the state holding the starting tree, or a new start of the session / changes outside Claude state when none does.
  • A jump reads the tree, keeps it as a state when it is no state's, then moves the files with read-tree -m -u <now> <target>: files the target lacks are removed and the rest written.
  • prompt.submit, after jumps, compares the tree with the one Claude's last turn left and adds one note to the prompt's context when they differ.
  • The states and the cursor live in $.state (the pane redraws from them) and in $.store under the session's id.

Development

claude plugin validate ./plugins/time-machine          # from the repository root
claude plugin test ./plugins/time-machine              # hooks/register.test.tsx against the engine
node --test "plugins/time-machine/test/*.test.js"      # src/: git against a real git, the rest pure

MIT © DiegoMarty

Source 7 files
hooks/register.tsx 798 lines
1// Time Machine as a Claude Code mod: one hooks module over git.
2//
3// The working tree has a history of STATES, like an editor's undo tree: the start of the
4// session, the end of every turn that changed files, and changes made outside Claude. A
5// cursor says which one the files are in now; going to another state moves the cursor and
6// the files, and adds no row.
7//
8// - The first call of a turn that can change files (Edit, Write, NotebookEdit, Bash that does
9//   not only look) snapshots the tree first; the end of the turn snapshots it again and adds
10//   a state, its parent the state the turn started from.
11// - /timeline opens the states in a pane: choosing a row shows what Enter would change,
12//   Enter goes there, `u`/`r` undo and redo. /timeline <n>, undo and redo do the same.
13// - The turn under way shows at once at the top, its files filling in as Claude edits.
14// - Files changed by hand since the cursor are saved as a state before a jump: nothing is
15//   ever lost, so a jump needs no confirmation.
16// - Claude hears of jumps once, with the next prompt: what differs from the files it left.
17// - Snapshots are commits no branch points at (refs/time-machine/<session>/<id>), built in an
18//   index of their own: the staging area, HEAD and the branches are never touched.
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, EventOf, Register, RenderElement } from 'claude-code'
21
22import { ICON, activity, ago, digest, digestText, elapsed, shorten, summary, sweep } from '../src/format.js'
23import { MoveBlocked, REF_ROOT, changes, forget, isSeeded, keep, locate, move, pin, prune, treeOf } from '../src/git.js'
24import { lineOf, merge, nextId } from '../src/states.js'
25import { mayWrite } from '../src/tools.js'
26import type { Change, Live, Preview, State } from '../types'
27
28type Engine = EngineInterface
29type RenderOf<C extends string> = Extract<EventOf['ui.render'], { component: C }>
30type Repo = { top: string; gitDir: string; index: string }
31// `outside`: no turn of the main loop, a background agent's tools: it blocks no jump
32type Turn = { label: string; prompt: string; outside?: true; start: { tree: string; head: string | null } | null }
33
34const PANE = 'time-machine'
35const COMMAND = 'timeline'
36const DAY_MS = 24 * 60 * 60 * 1000
37const PROMPT_MAX = 2000
38// The note about a jump names at most NOTE_FILES files: past that, how many more
39const NOTE_FILES = 50
40// The pinned footer of a docked pane: its rule, where things stand, the prompt (two lines),
41// what Enter does, the keys
42const FOOTER_ROWS = 6
43const PROMPT_ROWS = 2
44const NOTE_MS = 6000
45const SPIN_MS = 120
46const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
47// The scanner under the running turn: a block of SWEEP_BLOCK cells crossing SWEEP_WIDTH
48const SWEEP_WIDTH = 8
49const SWEEP_BLOCK = 3
50const ICON_COLOR: Record<string, string> = { A: 'success', D: 'error', M: 'warning' }
51
52const SESSION = atom({ plugin: 'time-machine', key: 'session' } as const, '')
53const STATES = atom({ plugin: 'time-machine', key: 'states' } as const, [] as State[])
54const CURSOR = atom({ plugin: 'time-machine', key: 'cursor' } as const, null as number | null)
55// The state the files left on the last jump: "jumped from" in the footer
56const DEPARTED = atom({ plugin: 'time-machine', key: 'departed' } as const, null as number | null)
57const DIRTY = atom({ plugin: 'time-machine', key: 'dirty' } as const, false)
58const SELECTED = atom({ plugin: 'time-machine', key: 'selected' } as const, null as number | null)
59const PREVIEW = atom({ plugin: 'time-machine', key: 'preview' } as const, null as Preview | null)
60const LIVE = atom({ plugin: 'time-machine', key: 'live' } as const, null as Live | null)
61const NOTE = atom({ plugin: 'time-machine', key: 'note' } as const, null as string | null)
62
63let repo: Repo | null = null
64let sessionId = ''
65let keepMs = 7 * DAY_MS
66let turn: Turn | null = null
67// The spinner of the running turn; a refresh of its files already waiting in the chain, so a
68// burst of edits reads the tree once. Both run only while a pane is open.
69let spinner: { cancel: () => void } | null = null
70let paneOpen = false
71let liveDoing = activity(null)
72let liveQueued = false
73let ringAt: string | undefined
74// The rows a docked pane shows, newest first, by surface: each surface draws a window of its own
75const windows = new Map<string, number[]>()
76// The tree Claude last left the files in, and whether a jump has moved them since
77// Both are kept with the session's states, so a jump made before a restart still reaches Claude
78let modelTree: string | null = null
79let jumped = false
80// Tool calls that may write and are still running, a background agent's included: no jump
81// moves the files under one
82let writing = 0
83// Every git step goes one after another: a turn's start, its end and a jump never interleave
84let chain: Promise<unknown> = Promise.resolve()
85const exclusive = <T,>(work: () => Promise<T>): Promise<T> => {
86  const next = chain.then(work, work)
87  chain = next.catch(() => undefined)
88  return next
89}
90
91const runner = ($: Engine) => (argv: readonly string[], init?: object) => $.process.run(argv, init)
92const refOf = (id: number) => `${REF_ROOT}/${sessionId}/${id}`
93
94type Saved = { states?: State[]; cursor?: number | null; modelTree?: string | null; jumped?: boolean }
95
96async function save ($: Engine, states: State[], cursor: number | null) {
97  const at = await $.clock.now()
98  await Promise.all([
99    update($, STATES, () => states),
100    update($, CURSOR, () => cursor),
101    $.store.set(`session:${sessionId}`, { at, states, cursor, modelTree, jumped })
102  ])
103}
104
105/** The working tree as a tree object; an index another session removed is seeded again. */
106async function tree ($: Engine) {
107  if (isSeeded(repo!.index) && !await $.fs.exists(repo!.index)) forget(repo!.index)
108  return treeOf(runner($), repo!)
109}
110
111/** Saves what Claude last saw and whether a jump moved it since, with the states as they are. */
112async function remember ($: Engine) {
113  const [states, cursor] = await Promise.all([read($, STATES), read($, CURSOR)])
114  await save($, states, cursor)
115}
116
117async function current ($: Engine) {
118  const [cursor, states] = await Promise.all([read($, CURSOR), read($, STATES)])
119  return states.find(state => state.id === cursor)
120}
121
122async function addState ($: Engine, now: { tree: string; head: string | null }, kind: State['kind'], label: string, parent: State | undefined, prompt?: string) {
123  const run = runner($)
124  const id = nextId(await read($, STATES))
125  const commit = await keep(run, repo!, now, refOf(id), `time-machine: #${id} · ${label}`)
126  const state: State = {
127    id,
128    kind,
129    label,
130    at: await $.clock.now(),
131    commit,
132    tree: now.tree,
133    head: now.head,
134    parent: parent?.id ?? null,
135    files: parent ? await changes(run, repo!, parent.tree, now.tree) : [],
136    ...(prompt ? { prompt } : {})
137  }
138  await save($, [...await read($, STATES), state], state.id)
139  return state
140}
141
142/**
143 * The states of this session on (re)start: the live ones, which a hot reload keeps, merged
144 * with the saved ones (see merge), the refs of any state given a new id pointed again.
145 */
146async function loadStates ($: Engine, saved: Saved | undefined) {
147  const isLive = await read($, SESSION) === sessionId
148  const merged = merge(Array.isArray(saved?.states) ? saved.states : [], isLive ? await read($, STATES) : [])
149  // A state older than the snapshots kept has lost its commit to prune: it goes, and a state
150  // that came from it starts the line
151  const since = await $.clock.now() - keepMs
152  const kept = new Set(merged.states.filter(state => state.at >= since).map(state => state.id))
153  const states = merged.states.filter(state => kept.has(state.id)).map(state => state.parent !== null && !kept.has(state.parent) ? { ...state, parent: null } : state)
154  // A ref that cannot be pointed again (its commit collected) costs that state, not the session
155  if (repo) for (const state of merged.repaired) await pin(runner($), repo, refOf(state.id), state.commit).catch(() => undefined)
156
157  await update($, SESSION, () => sessionId)
158  const cursor = isLive ? await read($, CURSOR) : saved?.cursor ?? null
159  await save($, states, cursor !== null && kept.has(cursor) ? cursor : null)
160}
161
162/**
163 * Removes the indexes of sessions that ended without saying so (a crash, a kill): any of ours
164 * left untouched longer than the snapshots are kept.
165 */
166async function pruneIndexes ($: Engine) {
167  const since = await $.clock.now() - keepMs
168  for (const entry of await $.fs.list(repo!.gitDir)) {
169    const isOurs = /^time-machine-.+\.index$/.test(entry.name) && `${repo!.gitDir}/${entry.name}` !== repo!.index
170    if (isOurs && entry.mtimeMs < since) {
171      await $.process.run(['rm', '-f', `${repo!.gitDir}/${entry.name}`]).catch(() => undefined)
172    }
173  }
174}
175
176/** Drops the saved states of the sessions older than the snapshots kept: theirs are pruned. */
177async function pruneStore ($: Engine) {
178  const since = await $.clock.now() - keepMs
179  for (const key of await $.store.keys()) {
180    if (!key.startsWith('session:') || key === `session:${sessionId}`) continue
181    const saved = await $.store.get(key) as { at?: number } | undefined
182    if (typeof saved?.at !== 'number' || saved.at < since) await $.store.delete(key)
183  }
184}
185
186/**
187 * Snapshots the working tree before the turn's first change, once per turn: the tree only.
188 * It becomes a commit (and a state) at the end of the turn, if no state already holds it.
189 */
190async function startTurn ($: Engine) {
191  if (!repo) return
192  if (!turn) turn = { label: '(outside a turn)', prompt: '', outside: true, start: null }
193  if (turn.start) return
194  turn.start = await tree($)
195}
196
197/**
198 * The end of a turn: a state for where it started (unless one already is that tree) and
199 * one for where it ended, and the tree it read when it added them. A turn that changed
200 * nothing adds none.
201 */
202async function finishTurn ($: Engine): Promise<{ tree: string; head: string | null } | undefined> {
203  const ended = turn
204  turn = null
205  if (!repo) return
206  const now = await tree($)
207  // Every turn of the main loop ends with Claude having seen the files as they are now,
208  // read-only turns too. A background agent's says nothing of what the conversation saw
209  if (!ended?.outside) {
210    modelTree = now.tree
211    jumped = false
212  }
213  if (!ended?.start || now.tree === ended.start.tree) {
214    await remember($)
215    return
216  }
217  const { start } = ended
218  const here = await current($)
219  // The files moved since the cursor without Claude, or this is the first turn
220  const from = (here?.tree === start.tree ? here : [...await read($, STATES)].reverse().find(state => state.tree === start.tree)) ??
221    await addState($, start, here ? 'outside' : 'start', here ? 'changes outside Claude' : 'start of the session', here)
222  await addState($, now, 'turn', ended.label, from, ended.prompt)
223  await update($, DIRTY, () => false)
224  await previewChosen($)
225  return now
226}
227
228/** Reads where the files are: the cursor follows a state with the same tree, else `dirty`. */
229async function refresh ($: Engine) {
230  const now = await tree($)
231  const here = await current($)
232  if (here?.tree === now.tree) {
233    await update($, DIRTY, () => false)
234    return now
235  }
236  const states = await read($, STATES)
237  const match = [...states].reverse().find(state => state.tree === now.tree)
238  if (match) await save($, states, match.id)
239  await update($, DIRTY, () => !match && states.length > 0)
240  return now
241}
242
243/** Puts the files as they were in state `id`; the person's unsaved changes become a state first. */
244async function goTo ($: Engine, id: number, read0?: { tree: string; head: string | null }): Promise<string> {
245  if (!repo) return 'Time Machine: this folder is not a git repository.'
246  // Mid-turn, Claude works on files it has read: moving them under it would go unnoticed
247  if (turn && !turn.outside) return 'Time Machine: a turn is running. Jump when it ends.'
248  // A background agent's tool may be writing right now: the files would move under it
249  if (writing > 0) return 'Time Machine: a tool is still changing files. Jump when it ends.'
250  // A background agent's changes since the turn ended become a state of their own first
251  const landed = turn?.outside && turn.start ? await finishTurn($) : undefined
252  const target = (await read($, STATES)).find(state => state.id === id)
253  if (!target) return `Time Machine: there is no #${id}. /${COMMAND} lists them.`
254
255  // Closing that turn, or undo and redo, have just read the tree: no second walk
256  const now = landed ?? read0 ?? await refresh($)
257  const leaving = await read($, CURSOR)
258  // After a resume, nothing has told us what Claude saw: the files before the first jump
259  if (modelTree === null) modelTree = now.tree
260  if (now.tree === target.tree) {
261    await save($, await read($, STATES), target.id)
262    await update($, DIRTY, () => false)
263    return `Time Machine: the files are already at #${id}.`
264  }
265  let kept = ''
266  if (await read($, DIRTY)) {
267    const saved = await addState($, now, 'saved', 'your changes, kept before a jump', await current($))
268    kept = ` Your changes are safe: kept as #${saved.id}.`
269    await update($, DEPARTED, () => saved.id)
270  } else {
271    await update($, DEPARTED, () => leaving)
272  }
273  let moved: Change[]
274  try {
275    moved = await move(runner($), repo, now.tree, target.tree)
276  } catch (error) {
277    if (!(error instanceof MoveBlocked)) throw error
278    return `Time Machine: not going to #${id}: ${error.message}.${error.paths.length > 0 ? ' Move them away first.' : ''}`
279  }
280  await save($, await read($, STATES), target.id)
281  await update($, DIRTY, () => false)
282  jumped = true
283  await remember($)
284  await previewChosen($)
285  // A move never touches HEAD: past a commit or a checkout, `git status` shows the difference
286  const headMoved = target.head !== undefined && target.head !== now.head
287    ? ` HEAD has moved since #${id} (a commit or a checkout): the files went back, HEAD and the branch did not; git status shows the difference.`
288    : ''
289  const text = `At #${id} · ${target.label} (${summary(moved)} changed).${kept}${headMoved}`
290  await update($, NOTE, () => text)
291  $.clock.after(NOTE_MS, () => { void update($, NOTE, shown => shown === text ? null : shown) })
292  return text
293}
294
295/** Undo: the cursor's parent, or the cursor itself when the files moved off it. */
296async function undo ($: Engine): Promise<string> {
297  if (!repo) return 'Time Machine: this folder is not a git repository.'
298  const now = await refresh($)
299  const here = await current($)
300  if (!here) return 'Time Machine: nothing to undo yet.'
301  if (await read($, DIRTY)) return goTo($, here.id, now)
302  if (here.parent === null) return `Time Machine: #${here.id} is as far back as it goes.`
303  return goTo($, here.parent, now)
304}
305
306/** Redo: the newest state that came from the cursor. */
307async function redo ($: Engine): Promise<string> {
308  if (!repo) return 'Time Machine: this folder is not a git repository.'
309  const now = await refresh($)
310  if (await read($, DIRTY)) return 'Time Machine: the files changed since; undo first, or pick a state.'
311  const cursor = await read($, CURSOR)
312  const next = [...await read($, STATES)].reverse().find(state => state.parent === cursor)
313  return next ? goTo($, next.id, now) : 'Time Machine: nothing to redo.'
314}
315
316/** What going to state `id` would change from the files as they are, for the footer. */
317async function preview ($: Engine, id: number) {
318  // Run in the chain: by its turn the person may have moved on, and then this row is not worth a tree
319  if (await read($, SELECTED) !== id) return
320  const target = (await read($, STATES)).find(state => state.id === id)
321  if (!repo || !target) return
322  if (id === await read($, CURSOR) && !await read($, DIRTY)) {
323    await update($, PREVIEW, () => null)
324    return
325  }
326  const now = await tree($)
327  const files = await changes(runner($), repo, now.tree, target.tree)
328  if (await read($, SELECTED) === id) await update($, PREVIEW, () => ({ id, files }))
329}
330
331/** The chosen row's preview, again: after a jump or a turn the files it compared with moved. */
332async function previewChosen ($: Engine) {
333  await update($, PREVIEW, () => null)
334  const chosen = await read($, SELECTED)
335  if (chosen !== null) await preview($, chosen)
336}
337
338/** /timeline: no argument opens the pane; `<n>`, `undo` and `redo` move the files. */
339async function timeline ($: Engine, args: string): Promise<{ text: string }> {
340  const arg = args.trim().replace(/^#/, '').toLowerCase()
341  if (arg === 'undo') return { text: await exclusive(() => undo($)) }
342  if (arg === 'redo') return { text: await exclusive(() => redo($)) }
343  if (arg !== '') {
344    const id = Number(arg)
345    if (!Number.isInteger(id) || id < 1) return { text: `Time Machine: /${COMMAND} <n> | undo | redo` }
346    return { text: await exclusive(() => goTo($, id)) }
347  }
348  await $.ui.open({ id: PANE, title: 'Time Machine' })
349  paneOpen = true
350  if (!repo) return { text: 'Time Machine: this folder is not a git repository.' }
351  await exclusive(() => refresh($))
352  const states = await read($, STATES)
353  if (states.length === 0) return { text: 'Time Machine: no turn has changed files yet.' }
354  const [cursor, dirty, now] = await Promise.all([read($, CURSOR), read($, DIRTY), $.clock.now()])
355  const line = lineOf(states, cursor)
356  const lines = [...states].reverse().map(state => {
357    const node = state.id === cursor && !dirty ? '◉' : line.has(state.id) ? '●' : '○'
358    const what = digestText(state.files)
359    return ` ${node} ${`#${state.id}`.padEnd(4)} ${shorten(state.label, 34).padEnd(34)}  ${shorten(what, 44).padEnd(44)} ${ago(now - state.at)}`
360  })
361  if (dirty) lines.unshift(' ◌ your changes, not saved yet')
362  return { text: `⏪ Time Machine · ${states.length} states · /${COMMAND} <n> | undo | redo\n${lines.join('\n')}` }
363}
364
365/** The files the running turn has changed so far; one read of the tree for a burst of edits. */
366async function liveRefresh ($: Engine) {
367  liveQueued = false
368  if (!repo || !turn?.start) return
369  const now = await tree($)
370  const files = await changes(runner($), repo, turn.start.tree, now.tree)
371  await update($, LIVE, live => live ? { ...live, files } : live)
372}
373
374function startSpinner ($: Engine) {
375  if (spinner) return
376  spinner = $.clock.every(SPIN_MS, () => {
377    void update($, LIVE, live => live ? { ...live, frame: live.frame + 1 } : live)
378  })
379}
380
381/**
382 * Starts the running row: at once on the prompt, its spinner ticking while the pane is open
383 * (or starting as soon as the pane renders).
384 */
385async function liveStart ($: Engine, label: string, prompt: string) {
386  spinner?.cancel()
387  spinner = null
388  liveDoing = activity(null)
389  const at = await $.clock.now()
390  await update($, LIVE, () => ({ label, prompt, at, files: [], doing: liveDoing, frame: 0 }))
391  if (paneOpen) startSpinner($)
392}
393
394async function liveEnd ($: Engine) {
395  spinner?.cancel()
396  spinner = null
397  liveDoing = activity(null)
398  await update($, LIVE, () => null)
399}
400
401function rowOf (element: string | undefined) {
402  const id = element?.startsWith('row-') ? Number(element.slice(4)) : NaN
403  return Number.isInteger(id) ? id : null
404}
405
406async function select ($: Engine, id: number) {
407  await update($, SELECTED, () => id)
408  act($, () => preview($, id))
409}
410
411/** What a key of the pane runs: one at a time, and a failure says so in the pane. */
412function act ($: Engine, work: () => Promise<unknown>) {
413  void exclusive(work).catch(() => update($, NOTE, () => 'Time Machine could not do that; the files were left as they were.'))
414}
415
416async function jumpNote ($: Engine, from: string) {
417  const now = await tree($)
418  jumped = false
419  modelTree = now.tree
420  await remember($)
421  const files = await changes(runner($), repo!, from, now.tree)
422  if (files.length === 0) return null
423  const named = files.slice(0, NOTE_FILES).map((file: Change) => `${file.status} ${file.path}`).join(', ')
424  const list = files.length > NOTE_FILES ? `${named} and ${files.length - NOTE_FILES} more` : named
425  return `Time Machine: since your last turn the user moved the working tree to another state. Files that differ from how you left them: ${list}. Read a file again before editing it.`
426}
427
428export const register: Register = (on, options) => {
429  keepMs = (Number(options.keepDays) > 0 ? Number(options.keepDays) : 7) * DAY_MS
430
431  on('session.start', async ($, e, next) => {
432    await $.command.register({
433      name: COMMAND,
434      description: 'Time Machine: the states of your files, turn by turn · <n> | undo | redo',
435      argumentHint: '[n | undo | redo]'
436    })
437    sessionId = await $.session.id()
438    spinner?.cancel()
439    spinner = null
440    paneOpen = false
441    liveDoing = activity(null)
442    turn = null
443    writing = 0
444    ringAt = undefined
445    windows.clear()
446    const saved = await $.store.get(`session:${sessionId}`) as Saved | undefined
447    // A resumed or reloaded session picks up a jump Claude has not heard of yet
448    modelTree = typeof saved?.modelTree === 'string' ? saved.modelTree : null
449    jumped = saved?.jumped === true
450    repo = await locate(runner($), await $.session.cwd(), sessionId)
451    await loadStates($, saved)
452    await Promise.all([
453      update($, DIRTY, () => false),
454      update($, DEPARTED, () => null),
455      update($, NOTE, () => null),
456      update($, PREVIEW, () => null),
457      update($, SELECTED, () => null)
458    ])
459    void exclusive(async () => {
460      await pruneStore($)
461      if (repo) {
462        await prune(runner($), repo, await $.clock.now(), keepMs)
463        await pruneIndexes($)
464      }
465    }).catch(() => undefined)
466    return next(e)
467  }).catch(($, e, next) => next(e))
468
469  // The session's index goes with it (where `rm` cannot start, it stays: a file of the size of
470  // the repository's index)
471  on('session.end', async ($, e, next) => {
472    spinner?.cancel()
473    spinner = null
474    paneOpen = false
475    if (repo) await $.process.run(['rm', '-f', repo.index]).catch(() => undefined)
476    return next(e)
477  })
478
479  // One note per batch of jumps, with the next prompt: five jumps that end where Claude left
480  // the files say nothing
481  on('prompt.submit', async ($, e, next) => {
482    // A slash command's prompt never reaches the model as a turn: the note waits for a real one
483    if (e.command || !jumped || !repo || modelTree === null) return next(e)
484    const from = modelTree
485    const note = await exclusive(() => jumpNote($, from))
486    return next(note ? { ...e, context: [...(e.context ?? []), note] } : e)
487  }).catch(($, e, next) => next(e))
488
489  on('turn.start', async ($, e, next) => {
490    if (e.agentId === undefined) {
491      // A snapshot taken outside a turn (a background agent) ends where the next turn begins
492      if (turn?.start) await exclusive(() => finishTurn($)).catch(() => undefined)
493      turn = { label: shorten(e.text) || '(continued)', prompt: e.text.trim().slice(0, PROMPT_MAX), start: null }
494      if (repo) await liveStart($, turn.label, turn.prompt)
495    }
496    return next(e)
497  })
498
499  on('tool.call', async ($, e, next) => {
500    const mutates = repo !== null && mayWrite(e.tool, e as unknown as Record<string, unknown>)
501    // Once the turn has its snapshot, a tool never waits behind the chain
502    if (mutates && !turn?.start) await exclusive(() => startTurn($))
503    // The running row says what Claude is at while the tool runs, and that it thinks after
504    liveDoing = activity(e.tool, e as unknown as Record<string, unknown>)
505    if (paneOpen) await update($, LIVE, live => live ? { ...live, doing: liveDoing } : live).catch(() => undefined)
506    if (mutates) writing++
507    let result
508    try {
509      result = await next(e)
510    } finally {
511      if (mutates) writing--
512    }
513    liveDoing = activity(null)
514    if (paneOpen) await update($, LIVE, live => live ? { ...live, doing: liveDoing } : live).catch(() => undefined)
515    // A background agent's turn has no running row to fill; a closed pane reads no intermediate trees
516    if (paneOpen && mutates && turn?.start && !turn.outside && !liveQueued) {
517      liveQueued = true
518      void exclusive(() => liveRefresh($)).catch(() => undefined)
519    }
520    return result
521  }).catch(async ($, e, next) => {
522    // Past `next`, the snapshot was taken and the tool failed on its own: nothing to say
523    if (next.called) return next(e)
524    // A snapshot that fails never blocks the tool: the turn just has no way back. It still
525    // counts as writing, so no jump moves the files under it
526    await $.ui.toast('Time Machine could not snapshot this turn; it runs without a way back.')
527    const mutates = repo !== null && mayWrite(e.tool, e as unknown as Record<string, unknown>)
528    if (mutates) writing++
529    try {
530      return await next(e)
531    } finally {
532      if (mutates) writing--
533    }
534  })
535
536  on('turn.complete', async ($, e, next) => {
537    const result = await next(e)
538    if (e.agentId === undefined) {
539      await exclusive(() => finishTurn($)).catch(async error => {
540        await $.ui.toast(`Time Machine could not close the turn: ${(error as Error).message}`)
541      })
542      await liveEnd($)
543    }
544    return result
545  })
546
547  on('command.run', { command: COMMAND }, ($, e) => timeline($, e.args))
548    .catch(() => ({ text: 'Time Machine could not do that; the files were left as they were.' }))
549
550  // The pane's selection follows its focus ring: the arrows, Tab or a click on a row
551  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
552    const id = rowOf(e.element)
553    // The ring holds a place among the elements drawn, not an element: a window that moved
554    // under it would land the next arrow rows away. So the window moves only at its edges,
555    // where the ring would leave the rows (onto undo below, round onto redo above): the next
556    // row is chosen instead, and the window, one row on, brings it under the ring. Anything
557    // else (a click on redo, Shift+Tab off the newest row) goes where it was going; a click on
558    // undo from the window's last row reads as an arrow, which the engine does not tell apart
559    const from = rowOf(ringAt)
560    if (id === null && from !== null && e.origin.kind === 'person') {
561      for (const [surface, shown] of windows) {
562        const down = e.element === 'undo' && shown.at(-1) === from
563        const up = (e.element === 'redo' || e.element === undefined) && shown[0] === from
564        if (!down && !up) continue
565        const rows = [...await read($, STATES)].reverse()
566        const to = rows[rows.findIndex(state => state.id === from) + (down ? 1 : -1)]
567        if (!to) break
568        // A key pressed before the redraw counts from the row chosen, in the window as it will be
569        ringAt = `row-${to.id}`
570        windows.set(surface, down ? [...shown.slice(1), to.id] : [to.id, ...shown.slice(0, -1)])
571        await select($, to.id)
572        return {}
573      }
574    }
575    if (id !== null) await select($, id)
576    const result = await next(e)
577    if (!result.deny) ringAt = e.element
578    return result
579  })
580
581  on('ui.close', { id: PANE }, ($, e, next) => {
582    paneOpen = false
583    spinner?.cancel()
584    spinner = null
585    return next(e)
586  })
587
588  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => drawPane($, e))
589}
590
591/** The pane: the states as a timeline, and a footer of fixed height pinned to its bottom. */
592async function drawPane ($: Engine, e: RenderOf<'Pane'>) {
593  const { Box, Button, Text } = $.ui.resolve(e)
594  if (!repo) return <Text dimColor>Not a git repository: nothing to snapshot.</Text>
595  const wasOpen = paneOpen
596  paneOpen = true
597  const [states, cursor, dirty, selected, shown, note, live, departed, now] = await Promise.all([
598    read($, STATES),
599    read($, CURSOR),
600    read($, DIRTY),
601    read($, SELECTED),
602    read($, PREVIEW),
603    read($, NOTE),
604    read($, LIVE),
605    read($, DEPARTED),
606    $.clock.now()
607  ])
608  if (live) {
609    startSpinner($)
610    if (!wasOpen) {
611      if (live.doing !== liveDoing) void update($, LIVE, cur => cur ? { ...cur, doing: liveDoing } : cur).catch(() => undefined)
612      if (turn?.start && !turn.outside && !liveQueued) {
613        liveQueued = true
614        void exclusive(() => liveRefresh($)).catch(() => undefined)
615      }
616    }
617  }
618  const byId = new Map(states.map(state => [state.id, state]))
619  const line = lineOf(states, cursor)
620  const rows = [...states].reverse()
621  const columns = Math.max(20, e.props.bodyColumns)
622  const chosen = byId.get((selected ?? cursor) as number)
623
624  // Docked, the pane has a height of its own: the footer is pinned to its bottom and the list
625  // shows the window of rows around the chosen one. Inline, the pane is as tall as its tree
626  const pinned = e.props.placement === 'dock'
627  const listRows = e.props.scroll.bodyRows - 2 - FOOTER_ROWS - (live ? 2 : 0) - (dirty ? 2 : 0)
628  const fits = !pinned || rows.length * 2 <= listRows
629  const room = fits ? rows.length : Math.max(1, Math.floor((listRows - 2) / 2))
630  const focus = Math.max(0, rows.findIndex(state => state.id === chosen?.id))
631  // Held by its top row, so a state landing above it does not move it; it moves only as far as
632  // keeps the chosen row in it (see ui.focus)
633  let first = fits ? 0 : rows.findIndex(state => state.id === windows.get(e.surface)?.[0])
634  if (!fits) {
635    if (first < 0) first = focus - Math.floor(room / 2)
636    first = Math.min(Math.max(first, focus - room + 1), focus)
637    first = Math.min(Math.max(0, first), rows.length - room)
638  }
639  const visible = rows.slice(first, first + room)
640  if (fits) windows.delete(e.surface)
641  else windows.set(e.surface, visible.map(state => state.id))
642
643  // One line of the timeline: the rail on the left (a node or its line), the content beside it
644  const rail = (key: string, mark: string, content: RenderElement, color?: string) => (
645    <Box key={key} flexDirection="row" height={1} overflow="hidden">
646      <Box width={3} flexShrink={0}><Text color={color} dimColor={!color}>{mark}</Text></Box>
647      <Box flexGrow={1} flexDirection="row" overflow="hidden">{content}</Box>
648    </Box>
649  )
650  const files = (key: string, list: Change[], empty = 'no files') => {
651    if (list.length === 0) return <Text key={key} dimColor>{empty}</Text>
652    const d = digest(list)
653    if (d.kind === 'few') {
654      return (
655        <Box key={key} flexDirection="row" gap={2}>
656          {d.files.map(file => (
657            <Box key={`${key}-${file.path}`} flexDirection="row" gap={1}>
658              <Text color={ICON_COLOR[file.status] ?? 'warning'}>{ICON[file.status] ?? '~'}</Text>
659              <Text dimColor wrap="truncate-start">{file.name}</Text>
660              {file.added ? <Text color="success">+{file.added}</Text> : null}
661              {file.removed ? <Text color="error">−{file.removed}</Text> : null}
662            </Box>
663          ))}
664        </Box>
665      )
666    }
667    return (
668      <Box key={key} flexDirection="row" gap={1}>
669        <Text dimColor>{d.count} files</Text>
670        <Text color="success">+{d.added}</Text>
671        <Text color="error">−{d.removed}</Text>
672        <Text dimColor wrap="truncate-end">· {d.groups.join(' · ')}{d.more ? ` · +${d.more}` : ''}</Text>
673      </Box>
674    )
675  }
676  // Where things stand: a label, the state, and how long ago it was
677  const where = (key: string, label: string, color: string, id: number | null, { instead, when = true }: { instead?: string; when?: boolean } = {}) => {
678    const state = id === null ? undefined : byId.get(id)
679    return (
680      <Box key={key} flexDirection="row" gap={1} flexShrink={0}>
681        <Text dimColor>{label}</Text>
682        <Text bold color={color}>{instead ?? (state ? `#${state.id}` : '—')}</Text>
683        {!instead && when && state && <Text dimColor>· {ago(now - state.at)}</Text>}
684      </Box>
685    )
686  }
687  const promptOf = (state: State | undefined) => {
688    if (!state) return ''
689    const text = state.prompt ?? (state.kind === 'start' ? 'The files as they were when the session started.'
690      : state.kind === 'outside' ? 'Files changed outside Claude, between two turns.'
691        : state.kind === 'saved' ? 'Your own changes, kept before a jump.' : state.label)
692    const flat = text.replace(/\s+/g, ' ').trim()
693    const max = (columns - 2) * PROMPT_ROWS - 1
694    return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
695  }
696
697  return (
698    <Box flexDirection="column" height={pinned ? e.props.scroll.bodyRows : undefined}>
699      <Box key="head" flexDirection="row" justifyContent="space-between" gap={2} height={1} overflow="hidden">
700        <Box flexDirection="row" gap={1} flexShrink={0}>
701          <Text bold color="claude">⏪ Time Machine</Text>
702        </Box>
703        {note
704          ? <Text color="success" wrap="truncate-end">{note}</Text>
705          : <Text dimColor>{states.length} state{states.length === 1 ? '' : 's'}</Text>}
706      </Box>
707
708      <Box key="list" flexDirection="column" marginTop={1} flexGrow={1} overflow="hidden">
709        {states.length === 0 && !live && <Text key="empty" dimColor italic>No snapshots yet. Each turn that changes files lands here.</Text>}
710        {/* The turn under way: as tall as any row, so it never shifts them when it lands */}
711        {live && rail('live', SPIN[live.frame % SPIN.length]!, (
712          <Box flexGrow={1} flexDirection="row" justifyContent="space-between" gap={2}>
713            <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
714              <Text color="claude" wrap="truncate-end">{live.label}</Text>
715            </Box>
716            <Text dimColor>{elapsed(now - live.at).padStart(3)}</Text>
717          </Box>
718        ), 'claude')}
719        {live && rail('live-doing', '│', (
720          <Box flexDirection="row" gap={1}>
721            {/* The scanner: a block sweeping a track of fixed width, so the text never moves */}
722            <Box flexDirection="row" flexShrink={0}>
723              <Text dimColor>{'▱'.repeat(sweep(live.frame, SWEEP_WIDTH, SWEEP_BLOCK))}</Text>
724              <Text color="claude">{'▰'.repeat(SWEEP_BLOCK)}</Text>
725              <Text dimColor>{'▱'.repeat(SWEEP_WIDTH - SWEEP_BLOCK - sweep(live.frame, SWEEP_WIDTH, SWEEP_BLOCK))}</Text>
726            </Box>
727            <Text dimColor wrap="truncate-end">{live.doing}{live.files.length > 0 ? ` · ${digestText(live.files)}` : ''}</Text>
728          </Box>
729        ))}
730        {dirty && rail('dirty', '◌', <Text color="warning" wrap="truncate-end">your changes, not saved yet</Text>, 'warning')}
731        {dirty && rail('dirty-since', '│', <Text dimColor wrap="truncate-end">since #{cursor}</Text>)}
732        {!fits && rail('newer', ' ', <Text dimColor>{first > 0 ? `↑ ${first} newer` : ' '}</Text>)}
733        {visible.map((state, i) => {
734          const isHere = state.id === cursor && !dirty
735          const isOff = !line.has(state.id)
736          const isLast = first + i === rows.length - 1
737          const tag = state.kind === 'saved' ? 'kept' : state.kind === 'outside' ? 'by hand' : ''
738          return (
739            <Box key={`state-${state.id}`} flexDirection="column">
740              {rail(`head-${state.id}`, isHere ? '◉' : isOff ? '○' : '●', (
741                <Box flexGrow={1} flexDirection="row" justifyContent="space-between" gap={2}>
742                  {/* The title clips first; the tag and the time keep their width */}
743                  <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
744                    <Button key={`row-${state.id}`} label={`#${state.id}  ${state.label}`} plain dimColor={isOff}
745                      autoFocus={isHere ? true : undefined} onPress={() => act($, () => goTo($, state.id))} />
746                  </Box>
747                  <Box flexDirection="row" gap={1} flexShrink={0}>
748                    {tag && <Text color="warning">{tag}</Text>}
749                    <Text dimColor>{ago(now - state.at).padStart(3)}</Text>
750                  </Box>
751                </Box>
752              ), isHere ? 'claude' : isOff ? undefined : 'text')}
753              {rail(`files-${state.id}`, isLast ? ' ' : '│', files(`f-${state.id}`, state.files))}
754            </Box>
755          )
756        })}
757        {!fits && rail('older', ' ', <Text dimColor>{first + room < rows.length ? `↓ ${rows.length - first - room} older` : ' '}</Text>)}
758      </Box>
759
760      {/* The footer: pinned to the bottom when docked, each part one fixed height, so moving
761          between rows only changes what is written in it */}
762      {states.length > 0 && (
763        <Box key="footer" flexDirection="column" flexShrink={0}>
764          <Text dimColor>{'─'.repeat(columns)}</Text>
765          <Box key="where" flexDirection="row" gap={3} height={1} overflow="hidden">
766            {where('selected', 'selected', 'claude', selected ?? cursor)}
767            {where('current', 'current', 'success', dirty ? null : cursor, { instead: dirty ? 'unsaved' : undefined })}
768            {where('from', 'jumped from', 'warning', departed, { when: false })}
769          </Box>
770          <Box key="prompt" flexDirection="row" height={PROMPT_ROWS} overflow="hidden">
771            <Box width={2} flexShrink={0}><Text color="claude">›</Text></Box>
772            <Text italic={!chosen?.prompt} dimColor={!chosen?.prompt} wrap="wrap">{promptOf(chosen)}</Text>
773          </Box>
774          <Box key="detail" flexDirection="row" gap={1} height={1} overflow="hidden">
775            {!chosen || (chosen.id === cursor && !dirty)
776              ? <Text dimColor wrap="truncate-end">◉ you are here</Text>
777              : shown?.id !== chosen.id
778                ? <Text dimColor wrap="truncate-end">⏎ reading…</Text>
779                : shown.files.length === 0
780                  ? <Text color="suggestion" wrap="truncate-end">⏎ nothing would change</Text>
781                  : (
782                    <Box flexDirection="row" gap={1}>
783                      <Text color="suggestion">⏎ go here:</Text>
784                      {files(`p-${chosen.id}`, shown.files)}
785                    </Box>
786                    )}
787          </Box>
788          <Box key="keys" flexDirection="row" gap={2} height={1} overflow="hidden">
789            <Text dimColor>↑↓ choose · ⏎ go</Text>
790            <Button key="undo" label="undo" hotkey="u" plain dimColor onPress={() => act($, () => undo($))} />
791            <Button key="redo" label="redo" hotkey="r" plain dimColor onPress={() => act($, () => redo($))} />
792          </Box>
793        </Box>
794      )}
795    </Box>
796  )
797}
798
src/format.js 118 lines
1/** @typedef {import('../types').Change} Change */
2
3const LABEL_MAX = 60
4// A change of up to FEW files names them; past that, the total and the GROUPS biggest folders
5const FEW = 2
6const GROUPS = 2
7
8export const ICON = /** @type {Record<string, string>} */ ({ A: '+', D: '−', M: '~' })
9
10/** @param {string} text */
11export function shorten (text, max = LABEL_MAX) {
12  const line = text.replace(/\s+/g, ' ').trim()
13  return line.length > max ? `${line.slice(0, max - 1)}…` : line
14}
15
16/** `3 files +12 −4`. @param {Change[]} files */
17export function summary (files) {
18  const added = files.reduce((sum, file) => sum + (file.added ?? 0), 0)
19  const removed = files.reduce((sum, file) => sum + (file.removed ?? 0), 0)
20  return files.length === 0 ? 'no files' : `${files.length} file${files.length === 1 ? '' : 's'} +${added} −${removed}`
21}
22
23/** @param {string} path */
24const shortPath = path => path.split('/').slice(-2).join('/')
25
26/**
27 * The files of a change in one line, however many. One or two: each with its short path.
28 * More: the total, the lines, and where they are: the biggest folders under the one they
29 * all share (which every row would repeat, so it is left out).
30 * @param {Change[]} files
31 */
32export function digest (files) {
33  if (files.length <= FEW) return { kind: /** @type {const} */ ('few'), files: files.map(file => ({ ...file, name: shortPath(file.path) })) }
34
35  const split = files.map(file => file.path.split('/'))
36  let shared = 0
37  while (split.every(parts => parts.length > shared + 1 && parts[shared] === split[0][shared])) shared++
38  /** @type {Map<string, number>} */
39  const counts = new Map()
40  for (const parts of split) {
41    const rest = parts.slice(shared)
42    const group = rest.length > 1 ? `${rest[0]}/` : rest[0]
43    counts.set(group, (counts.get(group) ?? 0) + 1)
44  }
45  const ranked = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
46  return {
47    kind: /** @type {const} */ ('many'),
48    count: files.length,
49    added: files.reduce((sum, file) => sum + (file.added ?? 0), 0),
50    removed: files.reduce((sum, file) => sum + (file.removed ?? 0), 0),
51    groups: ranked.slice(0, GROUPS).map(([name, n]) => n > 1 ? `${name} ×${n}` : name),
52    more: Math.max(0, ranked.length - GROUPS)
53  }
54}
55
56/** The same line as plain text, for the transcript. @param {Change[]} files */
57export function digestText (files) {
58  const d = digest(files)
59  if (d.kind === 'few') return d.files.map(file => `${ICON[file.status] ?? '~'} ${file.name}`).join('  ')
60  return `${d.count} files +${d.added} −${d.removed} · ${d.groups.join(' · ')}${d.more ? ` · +${d.more}` : ''}`
61}
62
63/** How long ago, in the unit that reads: `now`, `7m`, `3h`, `2d`. @param {number} ms */
64export function ago (ms) {
65  const minutes = Math.round(ms / 60000)
66  if (minutes < 1) return 'now'
67  if (minutes < 60) return `${minutes}m`
68  const hours = Math.floor(minutes / 60)
69  return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`
70}
71
72/** How long the running turn has taken: `8s`, `1m 05s`. @param {number} ms */
73export function elapsed (ms) {
74  const seconds = Math.max(0, Math.floor(ms / 1000))
75  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
76}
77
78const THINKING = 'thinking…'
79// What each tool is doing, in a word; the rest read as their own name
80const VERB = /** @type {Record<string, string>} */ ({ Read: 'reading', Edit: 'editing', MultiEdit: 'editing', Write: 'writing', NotebookEdit: 'editing', Bash: 'running', Grep: 'searching', Glob: 'looking for', WebFetch: 'reading', WebSearch: 'searching the web for', Agent: 'asking an agent:', Task: 'asking an agent:' })
81
82/**
83 * What Claude is at, from the tool it calls: `editing hooks/register.tsx`, `running npm test`,
84 * `searching "drawPane"`. Without a tool (between two), it is thinking.
85 * @param {string | null} tool
86 * @param {Record<string, unknown>} [input]
87 */
88export function activity (tool, input = {}) {
89  if (!tool) return THINKING
90  const text = (/** @type {string} */ key) => typeof input[key] === 'string' ? /** @type {string} */ (input[key]) : ''
91  // Bash says what it does in `description` ("Type-check the mod"); without one, the command
92  // without what only sets it up (`cd x &&`, `FOO=1`), which would fill the line
93  if (tool === 'Bash' && text('description')) return shorten(text('description'), 60)
94  const what = text('file_path') ? shortPath(text('file_path'))
95    : text('notebook_path') ? shortPath(text('notebook_path'))
96      : text('command') ? shorten(bare(text('command')), 40)
97        : text('pattern') ? `"${shorten(text('pattern'), 30)}"`
98          : text('query') ? `"${shorten(text('query'), 30)}"`
99            : text('url') ? shorten(text('url'), 40)
100              : text('description') ? shorten(text('description'), 40) : ''
101  const verb = VERB[tool] ?? tool.replace(/^mcp__[^_]+__/, '').replace(/_/g, ' ')
102  return what ? `${verb} ${what}` : verb
103}
104
105/** A command without the set-up in front: `cd src && FOO=1 npm test` is `npm test`. @param {string} command */
106const bare = command => command.replace(/^\s*(?:cd\s+\S+\s*&&\s*|[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '') || command
107
108/**
109 * The scanner of a running turn at `frame`: a block of `size` sweeping a track of `width`, back
110 * and forth. Gives the cells before it, so the track never changes length.
111 * @param {number} frame
112 */
113export function sweep (frame, width = 8, size = 3) {
114  const span = width - size
115  const step = frame % (2 * span)
116  return step <= span ? step : 2 * span - step
117}
118
src/git.js 241 lines
1// The git side of Time Machine: snapshots of the working tree as commits no branch points
2// at, and a move of the working tree from one of them to another.
3//
4// Everything goes through `run(argv, init)`, shaped like `$.process.run`, so the mod hands
5// it the engine and the tests hand it a real git on a temporary repository.
6//
7// - A snapshot stages the whole working tree (tracked, changed and untracked files, not the
8//   ignored ones) into an index of its own (`repo.index`, one per session), beside the
9//   repository's, so the person's staging area, HEAD and branches are never touched.
10// - Each snapshot is a commit kept alive by a ref under refs/time-machine/, not by a
11//   branch: `git log` never shows it (`git log --all` does), and `prune` drops the old ones.
12// - A move never touches HEAD either: going back past a commit puts the old files back with
13//   HEAD where it is, so each snapshot's commit has the HEAD it was taken on as its parent.
14// - `move` takes the working tree from one tree to another with `read-tree -m -u`: files the
15//   target lacks are removed, files it has are written. The caller snapshots first whatever
16//   it wants to keep a way back to. An ignored file is never in a snapshot, so a move that
17//   would write over one refuses (MoveBlocked) instead of losing it.
18
19export const REF_ROOT = 'refs/time-machine'
20// Snapshots are no one's commits: a fixed identity, so a repository without user.name works
21const IDENTITY = {
22  GIT_AUTHOR_NAME: 'time-machine',
23  GIT_AUTHOR_EMAIL: 'time-machine@localhost',
24  GIT_COMMITTER_NAME: 'time-machine',
25  GIT_COMMITTER_EMAIL: 'time-machine@localhost'
26}
27const TIMEOUT_MS = 120_000
28// Paths handed to one git call as arguments, well under any system's argument limit
29const PATHS_PER_CALL = 500
30// The HEAD each index was last seeded from: while HEAD stays, the index already holds it, and
31// seeding again would only make git hash the changed files again
32const seededFrom = new Map()
33
34/**
35 * A move refused before it touched a file: it would write over ignored files no snapshot holds
36 * (`paths`), or it changes more files than can all be checked (`paths` empty).
37 */
38export class MoveBlocked extends Error {
39  /** @param {string[]} paths @param {string} [why] */
40  constructor (paths, why) {
41    super(why ?? `it would write over ignored files no snapshot holds (${paths.join(', ')})`)
42    this.name = 'MoveBlocked'
43    this.paths = paths
44  }
45}
46
47async function git (run, argv, init = {}) {
48  const result = await run(['git', ...argv], { timeoutMs: TIMEOUT_MS, ...init })
49  if (result.exitCode !== 0) {
50    const said = (result.stderr || result.stdout || '').trim().split('\n')[0]
51    throw new Error(`git ${argv[0]}: ${said || `exit ${result.exitCode}`}`)
52  }
53  return result.stdout.trim()
54}
55
56/**
57 * The repository the session works in, or null outside one. An index of its own per session:
58 * two sessions in one repository never share a lock.
59 */
60export async function locate (run, cwd, session) {
61  try {
62    const [top, gitDir] = await Promise.all([
63      git(run, ['rev-parse', '--show-toplevel'], { cwd }),
64      git(run, ['rev-parse', '--absolute-git-dir'], { cwd })
65    ])
66    return { top, gitDir, index: `${gitDir}/time-machine-${session}.index` }
67  } catch {
68    return null
69  }
70}
71
72function indexEnv (repo) {
73  // Without an index of its own, git would stage into the person's
74  if (!repo.index) throw new Error('time-machine: no index of its own')
75  return { ...IDENTITY, GIT_INDEX_FILE: repo.index }
76}
77
78/**
79 * Forgets that `index` was seeded: another session's clean-up removed it (one left idle longer
80 * than the snapshots are kept), and an index started empty would leave out the files HEAD
81 * tracks and .gitignore matches. The next snapshot seeds it again.
82 */
83export function forget (index) {
84  seededFrom.delete(index)
85}
86
87export function isSeeded (index) {
88  return seededFrom.has(index)
89}
90
91/** The working tree as a tree object, through Time Machine's own index: `{ tree, head }`. */
92export async function treeOf (run, repo) {
93  const init = { cwd: repo.top, env: indexEnv(repo) }
94  const { index } = repo
95  const head = await headOf(run, repo)
96  // The index is seeded from HEAD, so a tracked file the .gitignore matches stays in it, and
97  // seeded again when HEAD moves (a commit, a checkout); a one-tree merge keeps the stat data
98  // of the entries HEAD has unchanged
99  if (head !== null && seededFrom.get(index) !== head) {
100    // A missing index starts as a copy of the repository's: it carries the stat data of every
101    // file, so git hashes only what changed instead of the whole tree (12.8k files: 424 ms to
102    // 96). Where `cp` cannot start (Windows outside Git Bash) it starts empty, just slower
103    if (!seededFrom.has(index)) await run(['cp', '-n', `${repo.gitDir}/index`, index], { cwd: repo.top }).catch(() => undefined)
104    // A copy taken mid-merge holds unmerged entries, which a one-tree merge refuses for good:
105    // then the index starts over from HEAD, without the stat data
106    await git(run, ['read-tree', '-m', 'HEAD'], init).catch(() => git(run, ['read-tree', 'HEAD'], init))
107    seededFrom.set(index, head)
108  }
109  await git(run, ['add', '-A', '--', '.'], init)
110  await untrackIgnored(run, repo, head, init)
111  return { tree: await git(run, ['write-tree'], init), head }
112}
113
114/**
115 * A file the index holds stays in it after the .gitignore starts matching it: a .env saved
116 * once, then ignored, would carry every secret written to it into the snapshots. Such files
117 * leave the index, unless HEAD tracks them (then they are the repository's to keep).
118 */
119async function untrackIgnored (run, repo, head, init) {
120  const ignored = (await git(run, ['ls-files', '-z', '--cached', '--ignored', '--exclude-standard'], init)).split('\0').filter(Boolean)
121  if (ignored.length === 0) return
122  let leave = ignored
123  if (head !== null) {
124    // `missing` for each path HEAD does not have, in one process
125    const found = await git(run, ['cat-file', '--batch-check'], { cwd: repo.top, stdin: ignored.map(path => `${head}:${path}\n`).join('') })
126    const lines = found.split('\n')
127    leave = ignored.filter((_, i) => lines[i]?.endsWith(' missing'))
128  }
129  if (leave.length === 0) return
130  await git(run, ['rm', '-q', '--cached', '--pathspec-from-file=-', '--pathspec-file-nul'], { ...init, stdin: leave.map(path => `:(literal)${path}`).join('\0') })
131}
132
133async function headOf (run, repo) {
134  const result = await run(['git', 'rev-parse', '-q', '--verify', 'HEAD'], { cwd: repo.top })
135  return result.exitCode === 0 ? result.stdout.trim() : null
136}
137
138export async function keep (run, repo, { tree, head }, ref, message) {
139  const parent = head ? ['-p', head] : []
140  const commit = await git(run, ['commit-tree', tree, ...parent, '-m', message], { cwd: repo.top, env: IDENTITY })
141  await git(run, ['update-ref', ref, commit], { cwd: repo.top })
142  return commit
143}
144
145export async function snapshot (run, repo, ref, message) {
146  const now = await treeOf(run, repo)
147  return { tree: now.tree, commit: await keep(run, repo, now, ref, message) }
148}
149
150/**
151 * A git listing in `-z` form, whole entries only: past the engine's 4 MiB of output the last
152 * entry is cut in the middle, so it goes (a change of 50,000 files lists a few less, rather
153 * than a path that does not exist).
154 */
155async function whole (run, repo, argv) {
156  const result = await run(['git', ...argv], { cwd: repo.top, timeoutMs: TIMEOUT_MS })
157  if (result.exitCode !== 0) throw new Error(`git ${argv[0]}: ${(result.stderr || '').trim().split('\n')[0] || `exit ${result.exitCode}`}`)
158  if (!result.isStdoutTruncated) return result.stdout
159  return result.stdout.slice(0, result.stdout.lastIndexOf('\0') + 1)
160}
161
162/**
163 * A git `-z` listing, every entry: one cut at the engine's 4 MiB of output would hide the
164 * entries past the cut, so it refuses the move instead (MoveBlocked, with no paths).
165 */
166async function every (run, repo, argv, { stdin, okCodes = [0] } = {}) {
167  const result = await run(['git', ...argv], { cwd: repo.top, timeoutMs: TIMEOUT_MS, ...(stdin === undefined ? {} : { stdin }) })
168  if (!okCodes.includes(result.exitCode)) throw new Error(`git ${argv[0]}: ${(result.stderr || '').trim().split('\n')[0] || `exit ${result.exitCode}`}`)
169  if (result.isStdoutTruncated) throw new MoveBlocked([], 'it changes more files than can all be checked for ignored ones')
170  return result.stdout.split('\0').filter(Boolean)
171}
172
173/** What changed from tree `a` to tree `b`: `[{ path, status, added, removed }]`. */
174export async function changes (run, repo, a, b) {
175  if (a === b) return []
176  const [numstat, status] = await Promise.all([
177    whole(run, repo, ['diff', '--numstat', '--no-renames', '-z', a, b]),
178    whole(run, repo, ['diff', '--name-status', '--no-renames', '-z', a, b])
179  ])
180  const statusOf = new Map()
181  const parts = status.split('\0').filter(Boolean)
182  for (let i = 0; i + 1 < parts.length; i += 2) statusOf.set(parts[i + 1], parts[i])
183  return numstat.split('\0').filter(Boolean).map(line => {
184    // `added<TAB>removed<TAB>path`: a path may hold tabs of its own, so only the first two split
185    const first = line.indexOf('\t')
186    const second = line.indexOf('\t', first + 1)
187    const added = line.slice(0, first)
188    const removed = line.slice(first + 1, second)
189    const path = line.slice(second + 1)
190    return {
191      path,
192      status: statusOf.get(path) ?? 'M',
193      // Binary files count as "-" in numstat
194      added: added === '-' ? null : Number(added),
195      removed: removed === '-' ? null : Number(removed)
196    }
197  })
198}
199
200/**
201 * Takes the working tree from tree `from` to tree `to`, and gives what moved. `from` has to
202 * be the tree `treeOf` just gave: Time Machine's index then matches the working tree, and a
203 * two-tree merge from it is a fast-forward of the files, removals included.
204 */
205export async function move (run, repo, from, to) {
206  if (from === to) return []
207  // Only a file the target adds can land on an ignored one (everything else is in `from`).
208  // The check needs every one of them: its own listing, never the summary `changes` may cut
209  const added = await every(run, repo, ['diff', '--name-only', '--diff-filter=A', '--no-renames', '-z', from, to])
210  if (added.length > 0) {
211    // Which of them the .gitignore matches, in one process (exit 1: none does)…
212    const ignored = await every(run, repo, ['check-ignore', '-z', '--stdin'], { stdin: added.join('\0') + '\0', okCodes: [0, 1] })
213    // …and of those, which are on disk: the ones the move would write over
214    const blocked = []
215    for (let i = 0; i < ignored.length; i += PATHS_PER_CALL) {
216      blocked.push(...await every(run, repo, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', ...ignored.slice(i, i + PATHS_PER_CALL).map(path => `:(literal)${path}`)]))
217    }
218    if (blocked.length > 0) throw new MoveBlocked(blocked)
219  }
220  const moved = await changes(run, repo, from, to)
221  await git(run, ['read-tree', '-m', '-u', from, to], { cwd: repo.top, env: indexEnv(repo) })
222  return moved
223}
224
225export async function pin (run, repo, ref, commit) {
226  await git(run, ['update-ref', ref, commit], { cwd: repo.top })
227}
228
229/** Drops a snapshot's ref; its commit goes with git's next gc. */
230export async function drop (run, repo, ref) {
231  await run(['git', 'update-ref', '-d', ref], { cwd: repo.top })
232}
233
234/** Drops every snapshot older than `maxAgeMs`, in one process however many; gives how many went. */
235export async function prune (run, repo, now, maxAgeMs) {
236  const list = await git(run, ['for-each-ref', '--format=%(refname) %(committerdate:unix)', REF_ROOT], { cwd: repo.top })
237  const old = list.split('\n').filter(Boolean).map(line => line.split(' ')).filter(([, seconds]) => now - Number(seconds) * 1000 > maxAgeMs)
238  if (old.length > 0) await git(run, ['update-ref', '--stdin'], { cwd: repo.top, stdin: old.map(([ref]) => `delete ${ref}\n`).join('') })
239  return old.length
240}
241
src/states.js 57 lines
1/** @typedef {import('../types').State} State */
2
3/** The ids from `cursor` back to the start: the states the files are made of now. @param {State[]} states @param {number | null} cursor */
4export function lineOf (states, cursor) {
5  /** @type {Set<number>} */
6  const line = new Set()
7  if (cursor === null) return line
8  /** @type {Map<number, number | null>} */
9  const parentOf = new Map()
10  for (const state of states) parentOf.set(state.id, state.parent)
11  let id = cursor
12  while (id !== null && !line.has(id)) {
13    line.add(id)
14    id = parentOf.get(id) ?? null
15  }
16  return line
17}
18
19/**
20 * The next id: one past the highest. It comes from the states themselves, never from a
21 * counter of the module, which a hot reload starts over while the states stay.
22 * @param {State[]} states
23 */
24export const nextId = states => states.reduce((max, state) => Math.max(max, state.id), 0) + 1
25
26/**
27 * The saved states and the live ones (what a hot reload keeps), merged by commit, in the order
28 * they were made. A reload that raced the end of a turn could hand out an id twice: the later
29 * state with a taken id gets a new one. A state whose parent is that id came after the first,
30 * so it stays the first's child. `repaired` lists the states whose ref has to be pointed again.
31 * @param {State[]} saved
32 * @param {State[]} live
33 */
34export function merge (saved, live) {
35  /** @type {Map<string, State>} */
36  const byCommit = new Map()
37  for (const state of [...saved, ...live]) byCommit.set(state.commit, state)
38  const merged = [...byCommit.values()].sort((a, b) => a.at - b.at || a.id - b.id)
39
40  let max = nextId(merged) - 1
41  /** @type {Map<number, State>} */
42  const firstById = new Map()
43  /** @type {State[]} */
44  const repaired = []
45  const states = merged.map(state => {
46    const first = firstById.get(state.id)
47    if (!first) {
48      firstById.set(state.id, state)
49      return state
50    }
51    const moved = { ...state, id: ++max }
52    repaired.push(moved, first)
53    return moved
54  })
55  return { states, repaired }
56}
57
src/tools.js 28 lines
1// Which tool calls may change the working tree, and so need the turn's snapshot first. When
2// unsure, a tool may write: a snapshot it did not need costs a tree walk, a missing one costs
3// the way back.
4
5import { readOnly } from './bash.js'
6
7// Tools that never write the working tree. An agent's own tools raise their own calls, so
8// starting one writes nothing by itself
9const LOOKS = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'TodoWrite', 'TodoRead', 'Agent', 'Task',
10  'AskUserQuestion', 'ToolSearch', 'Skill', 'ListMcpResourcesTool', 'ReadMcpResourceTool', 'ReadMcpResourceDirTool',
11  'TaskGet', 'TaskList', 'TaskOutput', 'ExitPlanMode', 'EnterPlanMode', 'LSP'])
12// An MCP tool whose name says it only reads: `mcp__github__get_issue`, `mcp__fs__list_directory`
13const MCP_LOOKS = /^mcp__.+__(?:get|list|read|search|find|fetch|query|view|show|describe|lookup|check|count|stat)(?:_|$)/i
14// …unless a verb that writes is anywhere in it: `find_and_replace`, `check_and_fix`
15const MCP_WRITES = /(?:^|_)(?:write|edit|replace|create|update|delete|remove|move|rename|apply|fix|set|save|patch|insert|put|add|append|upsert|store|upload|download|copy|clear|reset|commit|merge|run|exec|execute|format|generate|install|import|sync|push|pull|restore|extract)(?:_|$)/i
16
17/**
18 * Whether the call may change files. Bash by its command (see bash.js); a known tool by its
19 * name; any other, an MCP writer or a tool this list has never heard of, may.
20 * @param {string} tool
21 * @param {Record<string, unknown>} [input]
22 */
23export function mayWrite (tool, input = {}) {
24  if (tool === 'Bash') return !(typeof input.command === 'string' && readOnly(input.command))
25  if (LOOKS.has(tool)) return false
26  return !(MCP_LOOKS.test(tool) && !MCP_WRITES.test(tool.replace(/^mcp__.+?__/, '')))
27}
28
src/bash.js 25 lines
1// Which Bash commands only look. A turn of `ls`, `grep` and `git status` takes no snapshot:
2// every segment of the command has to be one of these, and nothing in it may write. When
3// unsure, a command can write, and its turn gets its snapshot.
4
5const GIT = 'git(?:\\s+-C\\s+\\S+)?\\s+'
6const LOOKS = new RegExp(`^(?:cd|ls|ll|pwd|cat|head|tail|less|wc|grep|egrep|rg|ag|find|fd|tree|file|stat|du|df|which|type|echo|printf|date|whoami|uname|diff|cmp|jq|${GIT}(?:status|log|diff|show|blame|grep|ls-files|ls-tree|rev-parse|rev-list|describe|shortlog|reflog|for-each-ref|show-ref|cat-file|merge-base|name-rev|check-ignore|count-objects))(?:\\s|$)`)
7// Commands whose arguments can make them write or run something: only these exact forms look
8const LOOKS_EXACTLY = new RegExp(`^(?:env|${GIT}(?:branch(?:\\s+(?:-a|-r|-v|-vv|--list|--show-current))*|remote(?:\\s+-v)?|stash\\s+list|config\\s+--get\\S*\\s+\\S+))\\s*$`)
9// Whatever writes or runs something else, wherever it is in the command: a redirection, a
10// substitution (`$(…)`, backticks, `<(…)`), tee and xargs, and the options that write a file or
11// run a command (find -delete/-exec/-fls, fd -x/--exec, git diff --output, tree -o, rg --pre).
12// Some of them are harmless elsewhere (grep -o): those just get a snapshot they did not need
13const WRITES = /[>`]|[$<]\(|\b(?:tee|xargs)\b|\s-(?:delete|exec|execdir|ok|fls|fprint\S*|[xXo])(?:\s|$)|\s--(?:exec(?:-batch)?|output|pre)\b/
14// Redirections that write nothing: an error stream into the output, or anything into /dev/null
15const HARMLESS = /\s*(?:\d?>&\d|\d?>\s*\/dev\/null)/g
16
17/** @param {string} command */
18export function readOnly (command) {
19  const plain = command.replace(HARMLESS, '')
20  if (WRITES.test(plain)) return false
21  // A lone `&` runs two commands as much as `;` does
22  const segments = plain.split(/\|\||&&|[|;&\n]/).map(part => part.trim()).filter(Boolean)
23  return segments.length > 0 && segments.every(part => LOOKS.test(part) || LOOKS_EXACTLY.test(part))
24}
25
types/index.d.ts 46 lines
1/** One file that differs between two states. `added`/`removed` are null for a binary file. */
2export type Change = { path: string; status: string; added: number | null; removed: number | null }
3
4/**
5 * A state the working tree was in and can go back to: the start of the session, the end
6 * of a turn, changes made outside Claude, or the person's own changes saved before a jump.
7 * `parent` is the state it came from (undo goes there); `files`, what differs from it.
8 */
9export type State = {
10  id: number
11  kind: 'start' | 'turn' | 'outside' | 'saved'
12  label: string
13  at: number
14  commit: string
15  tree: string
16  /** The HEAD the snapshot was taken on (null before the first commit); absent in states saved before it was kept. */
17  head?: string | null
18  parent: number | null
19  files: Change[]
20  /** The prompt of the turn, whole (up to 2,000 characters); absent for other kinds. */
21  prompt?: string
22}
23
24/** The turn under way, drawn at the top of the timeline while it runs: its files so far. */
25/** `doing` is what Claude is at right now (`editing src/a.ts`, `thinking…`); `frame` moves the animation. */
26export type Live = { label: string; prompt: string; at: number; files: Change[]; doing: string; frame: number }
27
28/** What going to state `id` would change, from the files as they are now. */
29export type Preview = { id: number; files: Change[] }
30
31declare module 'claude-code' {
32  interface PluginState {
33    'time-machine': {
34      session: string
35      states: State[]
36      cursor: number | null
37      departed: number | null
38      dirty: boolean
39      selected: number | null
40      preview: Preview | null
41      live: Live | null
42      note: string | null
43    }
44  }
45}
46