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…

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
◉ and the files; it adds no row.◉ 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.u/r undo and redo: undo goes to the state the current one came from, redo to the newest state that came from it.○ … undone), one Enter away.your changes, kept before a jump) before any jump, so a jump needs no confirmation.8 files +212 −40 · api/ ×5 · tests/ ×2 · +1).↑ 3 newer, ↓ 2 older)./timeline <n>, /timeline undo, /timeline redo.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.
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..git/time-machine-<session>.index), so what you staged stays staged..env, node_modules/ and anything else .gitignore matches are neither saved nor put back./plugin marketplace add midudev/better-claude
/plugin install time-machine@better-claude
| Option | Default | What it does |
|---|---|---|
| Keep snapshots for (days) | 7 | Older snapshots are dropped when a session starts; git's next gc frees their space. |
/timeline says so and nothing is snapshotted..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.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.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.Measured on a repository of 12,800 files (Apple Silicon):
| Step | Time |
|---|---|
| First snapshot of a session | 283 ms |
| Start of a turn that changes files | 154 ms |
| End of a turn | 142 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).
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.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.$.state (the pane redraws from them) and in $.store under the session's id.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
hooks/register.tsx 798 lines1// 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}
798src/format.js 118 lines1/** @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}
118src/git.js 241 lines1// 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}
241src/states.js 57 lines1/** @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}
57src/tools.js 28 lines1// 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}
28src/bash.js 25 lines1// 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}
25types/index.d.ts 46 lines1/** 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