Agent Time Machine: a branching timeline of tool calls with shadow-git snapshots — inspect, diff, explain test failures, restore, branch, compare, re-run

English | 한국어
When an agent changes several files and ends up somewhere wrong, a Git diff only tells you "what changed". Time Machine records in what order, which tool call, on which file state, produced which result, and lets you go back to any step to see the files and tool calls as they were then. It also supports tracing causality between file edits and test failures, restoring a point in time, branching, comparing two runs, and re-running a step with the same input.
Session #184
├── ① Read auth.py
├── ② Grep JWT
├── ③ Edit auth.py └── snapshot A
├── ④ Bash pytest └── 3 failed
├── ⑤ Edit middleware.py └── snapshot B
└── ⑥ Bash pytest └── 12 passed (fixed by ⑤)
/plugin marketplace add YeonwooSung/my-claude-code-mods
/plugin install time-machine@my-claude-code-mods
Requires Claude Code 2.1.287 or later (mods enabled by default) and git.
Everything goes through a single /tm <subcommand>.
| Command | What it does |
|---|---|
/tm or /tm timeline [--all] [--branch b] | Timeline tree (last 30 steps; --all for everything). Each step shows its number, tool, target and markers (test result, denied, error, snapshot) |
/tm show <n> | Step details: input (truncated display), result head, changed files, test result, snapshot commit and time |
/tm diff <a> [b] | diff --stat and patch between the snapshots of steps a and b (default: current); at most 400 lines, nothing past that is read |
/tm why [n] | Suspects and fixed-by for test run n (default: the last failure). A passing run has no suspects |
/tm restore <n> [--yes] | Restores the file state of step n. Dry run without --yes |
/tm branch <n> [name] [--yes] | Like restore, but records the following steps on a new branch |
/tm compare <a> <b> | Compares two paths (a branch or session:<id>[/<branch>]): fork point, each side's steps, file diff stats, last test result |
/tm rerun <n> [--write] [--isolated [--yes]] | Runs the tool again with step n's input and compares the results |
/tm sessions | Recorded sessions for this project (session id, "(this session)" marker, start time) |
/tm-pane | Live timeline pane |
Status line: tm ⑫ · 5 snaps · 41ms (last step number, snapshot count, average snapshot time). When snapshots are off, the status line only shows tm ⑫ · snapshots off; the reason appears at the head of the /tm timeline (snapshots off: …) and in the messages of other subcommands.
<storeDir>/<projectKey>/shadow.git (a bare repository). projectKey is the project root's name plus the first 8 characters of its path hash. Each session uses its own index (index/<sessionId>), and refs are refs/tm/<sessionId>/<branch>. The shadow repository's git environment (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY and so on) is pinned and any inherited GIT_CONFIG_PARAMETERS is cleared, so nothing is written to your .git. Current .gitignore rules are respected even for files an earlier shadow snapshot tracked: those files are removed from the shadow index before staging, without deleting working files or past snapshots. Your .git/info/exclude is copied into the shadow repository the first time the session uses git (the first mutating tool, or the first /tm command that uses git) and is not updated afterwards (except for linked worktrees, where .git is a file).cat > f <<EOF also write files. Nothing is committed when nothing changed. With the skipSearchBash setting on, Bash calls classified as search (rg, grep, ls, cat …) are skipped. Read-only tools are never snapshotted.external step so it is not blamed on the agent./tm command that uses git), so session start is not slowed down.concurrent. The external-change check is skipped while another mutating tool is in flight (so that tool's writes are not counted as external changes).git gc --auto. The index files of deleted sessions, and temp folders older than a day left behind by interrupted --isolated reruns, are removed too.| Grade | Examples |
|---|---|
| exact | Tracked file contents at snapshot time (shadow git commits), tool inputs, tool result text (head) and its hash, exit status of test commands |
| parsed | Pass/fail counts, failing test names and failure locations file:line read from test output (only formats the parser knows: pytest, jest/vitest, go, cargo, node:test) |
| inferred | Causal suspect ranking, "fixed by", fork point of a path comparison |
| not reproduced | Model responses, external services, time, randomness |
/tm why)/tm restore <n> and /tm branch <n> [name] are dry runs without --yes: they only show the files that would change (A/M/D) and their count.--yes, the current state is first saved as a pre-restore snapshot, then the work tree is set to the target step's state. Tracked files absent from the target are deleted; ignored files are left alone..env that used to be tracked is now in .gitignore, or a file out is now the ignored directory out/), the restore refuses and touches nothing: ignored files are in no snapshot, so overwriting them could not be undone. Move the paths named in the message aside and run it again. A directory holding only ignored files (e.g. restoring pkg/a.py when only __pycache__/ is left in pkg/) is judged by the files inside it, so the restore goes ahead if no file actually collides./tm why: its tree equals step n's, so files changed only on the branch you left do not show up as failure suspects on the new branch. How the work tree changed is shown in the timeline (work tree: …) and in workTree of /tm show.--yes, and every rerun (except the --isolated dry run), run only when the user typed them directly (origin composer/bridge/sdk). Otherwise they refuse and name the origin. Dry runs and read-only subcommands work from anywhere. None of this is exposed as a tool the model can call./tm rerun)Agent/Task, Skill, SendMessage, KillShell) need --write (mutating tools are snapshotted afterwards; for subagents and the like, the calls inside are recorded as their own steps). Either way it runs only when the user typed it directly. If the results hash the same it reports "identical"; otherwise a line diff summary, and for tests the pass/fail change.--isolated (Bash only; snapshot copy, not a sandbox): extracts the snapshot right before that step into a temp directory (<storeDir>/<projectKey>/tmp/…) and runs there. It is best effort, since ignored files (dependencies and so on) are missing, and it does not go through the permission dialog. So without --yes it is a dry run: it only shows the command to run ($ …), the snapshot it would use and caveats, and runs nothing. /tm rerun <n> --isolated --yes also runs only when the user typed it directly, and refuses commands that contain an absolute path into the project, as a guard against obvious writes to the original tree. Environment variables, home configuration and external services remain accessible. External changes are checked before and after the run, and if the project changes anyway it is recorded as an external step.v1 only restores files and runs tools again; it does not replay model responses, external services (WebFetch, MCP), time or randomness. Model turns are recorded only as summaries and hashes.
v2 design sketch (not implemented): (1) a model-response cassette where a turn.step hook emits recorded chunks, keyed by request message hash + model + index. (2) A tool-result cassette where a tool.call hook answers with the result without calling next, keyed by (tool, input hash, previous snapshot commit). (3) WebFetch/WebSearch/MCP covered by the tool-result cassette; Bash that uses the network marked as not reproducible. (4) Time and randomness dependencies only warned about. (5) Replay run = new session + baseline restore + cassette mode; a cassette miss marks a "nondeterministic fork point".
| Key | Default | Meaning |
|---|---|---|
storeDir | "" (~/.claude/time-machine) | Where snapshots and timelines live, one folder per project. Must be absolute (or ~/…) and outside the project (otherwise snapshots are turned off) |
retentionDays | 14 | Deletes session records older than this (once a day, detached after startup). 0 keeps everything |
recordModelOutput | false | Stores model response bodies and the first 200 characters of prompts. When off, only lengths and hashes |
skipSearchBash | false | Skips the snapshot after Bash classified as search |
<storeDir>/<projectKey>/
shadow.git/ # shadow repository
index/<sessionId> # per-session git index
tmp/ # temp work directories for --isolated reruns
sessions/<sessionId>/meta.json # { sessionId, root, startedAt }
sessions/<sessionId>/<load>-<n>.jsonl
JSONL record kinds: session, turn, step, model, branch. A chunk rolls over to the next number past 256 KiB. A step's tool input is cut at 4 KiB per string field and the result head at 2 KiB (hash and length are kept), with secrets masked. Parsed failure names are also masked before being stored; test counts and invocation hashes are unchanged. The full content of Write and old/new_string of Edit are in the snapshot.
| Environment | Behavior |
|---|---|
| Terminal | All commands, pane, status line |
| VS Code chat panel | Hooks and command text only (pane and status line are not shown) |
claude -p | Recording and snapshots work. Commands run in the same process via stream-json input |
/, storeDir is relative or inside the project, or the baseline snapshot takes more than 5 seconds (the timeline keeps recording). After that, a single failed snapshot (timeout, contention with another process) leaves only that step without a snapshot; snapshots are turned off only after 3 or more consecutive failures spanning more than 3 minutes. Lock files left behind by a dead git are removed after 2 minutes and the snapshot is retried. The reason appears at the head of the /tm timeline and in other subcommands' messages (the status line only shows snapshots off).git add -A fails, snapshots keep failing and are eventually turned off. Nested repositories' contents are not snapshotted (they are kept only as gitlinks).external change step before every mutating tool.--isolated only changes the working directory; it does not isolate the environment, home directory, network or external services. Commands can still touch the real tree indirectly.node_modules, .env) are not snapshotted or restored./rewind. Time Machine also catches files changed by Bash and adds the timeline, causality and comparison.Test run identity is a hash of the complete command (whitespace outside quotes is normalized for simple commands; commands with newlines or shell expansions are preserved) plus cwd. Earlier logs without an identity remain readable but are not linked as a baseline, fix or improvement. A same-command pass is still an inference, not proof of causation.
Storage state appears in the timeline, pane and status line: recording, degraded (some timeline data was saved but a write failed), or not persisted (no timeline write has succeeded in this load). Failed file bodies, including metadata and rolled-over chunks, stay in memory across clear/resume and retry on subsequent recording events and session end. A same-session resume reuses the in-memory recorder so unsaved steps keep their numbers. A continuing failure followed by process termination still loses unsaved memory. Secret keys such as password, authorization, api_key and access_token mask their entire values recursively; masked/truncated disk inputs are marked lossy. This supplements string masking and does not sanitize snapshot file contents.
Secret masking is best-effort and covers credential flags, sensitive headers, URL passwords, curl/mysql credentials and common credential fields in JSON strings. It does not sanitize snapshot file contents or retroactively rewrite saved files. Review files before sharing.
Reports and panels mask supported credential formats before truncation. Time Machine rerun previews mask displayed commands while replay uses the original in-memory input. Existing logs and snapshot file contents are not rewritten.
CLAUDE292=~/.vscode/extensions/anthropic.claude-code-2.1.292-darwin-arm64/resources/native-binary/claude
"$CLAUDE292" plugin test time-machine # from the repo root
npx -y -p typescript@5 tsc -p time-machine --noEmit # after copying types into time-machine/.claude-plugin/types/ (do not commit)
"$CLAUDE292" plugin validate time-machine
Unit tests use a fake git runner to check command order, arguments, environment, output parsing and error paths. The test runner's $ has no process, so real git is checked end to end (-p stream-json, one process).
hooks/register.tsx 738 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import { why } from './core/causality.ts'
4import { classifyCommand, isolationProblem } from './core/classify.ts'
5import { compare, compareOutputs } from './core/compare.ts'
6import { contentHash } from './core/hash.ts'
7import {
8 circled,
9 label,
10 renderCompare,
11 renderIsolatedPlan,
12 renderRerun,
13 renderRestorePlan,
14 renderSessions,
15 renderStep,
16 renderTimeline,
17 renderWhy,
18 statusLine,
19} from './core/render.ts'
20import { ChunkWriter, DurableWriter, parseChunks, projectKey, type RecordingStatus } from './core/store.ts'
21import { redactSecrets } from './core/redact.ts'
22import { parseTestOutput } from './core/testparse.ts'
23import { branchTaken, Recorder } from './core/timeline.ts'
24import { ACTING_TOOLS, callInput, isMutating, resultText, summarizeInput, summarizeResult, targetOf } from './core/tools.ts'
25import type { SnapshotRef, Step } from './core/types.ts'
26import { isInside, ShadowGit, type Runner } from './git.ts'
27
28const PANE = 'time-machine'
29// Names this module load's timeline chunks, so a reload never overwrites an earlier load's.
30const LOAD_ID = Date.now().toString(36)
31const DAY = 86_400_000
32const TOO_SLOW_MS = 5_000
33const MAX_LIVE_INPUTS = 500
34// Raw inputs kept for /tm rerun stay under this in all; the oldest go first, and one larger is not kept.
35const MAX_LIVE_BYTES = 32 * 1024 * 1024
36const DIFF_LINES = 400
37// Snapshots turn off only after failing this many times in a row over at least this long: a stale lock
38// (cleared once it is old enough) or one slow moment skips a snapshot or two, not the rest of the session.
39const FAILS_BEFORE_OFF = 3
40const FAILING_MS_BEFORE_OFF = 180_000
41const USAGE =
42 'usage: /tm [timeline [--all] [--branch b] | show <n> | diff <a> [b] | why [n] | restore <n> [--yes] | branch <n> [name] [--yes] | compare <a> <b> | rerun <n> [--write] [--isolated [--yes]] | sessions]'
43
44type Ctx = {
45 rec: Recorder
46 root: string
47 base: string
48 dir: string
49 git?: ShadowGit
50 ready?: Promise<void>
51 off?: string
52 head?: string
53 writer: DurableWriter
54 warnedStore?: boolean
55 inflight: number
56 snapMs: number[]
57 failing?: { count: number; since: number; error: string }
58 // The external-change check in progress: parallel mutating calls wait for it before they run.
59 checking?: Promise<Step | undefined>
60 live: Map<number, Record<string, unknown>>
61 liveBytes: Map<number, number>
62 rerunOf?: number
63 ended?: boolean
64}
65
66let ctx: Ctx | undefined
67// /clear and /resume must not discard dirty file bodies while the module remains alive.
68const retired = new Set<Ctx>()
69// Session starts in this module load: each gets its own chunk names, which still sort in start order.
70let starts = 0
71let currentOptions: PluginOptions = {}
72
73const message = (error: unknown): string => (error instanceof Error ? error.message : String(error))
74
75function runnerOf($: EngineInterface): Runner {
76 return async (argv, opts) => {
77 const r = await $.process.run(argv, opts)
78 return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }
79 }
80}
81
82const refOf = (c: Ctx): string => `refs/tm/${c.rec.session.sessionId}/${c.rec.branch}`
83
84async function storeDir($: EngineInterface, options: PluginOptions): Promise<string> {
85 const dir = (String(options.storeDir ?? '').trim() || '~/.claude/time-machine').replace(/\/+$/, '')
86 if (!/^~(?=\/|$)/.test(dir)) return dir
87 return dir.replace(/^~/, (await $.env.get('HOME')) ?? '.')
88}
89
90// Records written in order: every chunk a batch touched gets its final body.
91async function persistOne($: EngineInterface, c: Ctx): Promise<void> {
92 c.writer.add(c.rec.drain())
93 await c.writer.flush(async (name, body) => { await $.fs.write(`${c.dir}/${name}`, body) })
94 const status = c.writer.status
95 if (status.error !== undefined && !c.warnedStore) {
96 c.warnedStore = true
97 try {
98 $.ui.toast(`time-machine: ${status.state}; unsaved records retained for retry (${redactSecrets(status.error).slice(0, 200)})`)
99 } catch { /* recording must not break the session */ }
100 }
101 if (status.error === undefined) c.warnedStore = false
102}
103
104async function persist($: EngineInterface, c: Ctx): Promise<void> {
105 await persistOne($, c)
106 for (const previous of retired) {
107 if (previous !== c) await persistOne($, previous)
108 if (previous.writer.status.pending === 0) retired.delete(previous)
109 }
110 paint($, c)
111}
112
113function recordingStatus(c: Ctx): RecordingStatus {
114 const own = c.writer.status
115 const pending = [...retired].filter(previous => previous !== c).reduce((sum, previous) => sum + previous.writer.status.pending, 0)
116 return pending > 0 ? { state: own.state === 'not persisted' ? own.state : 'degraded', pending: own.pending + pending, error: 'earlier session records await retry' } : own
117}
118
119function paint($: EngineInterface, c: Ctx): void {
120 try {
121 $.ui.status(statusLine(c.rec, c.snapMs, c.off, recordingStatus(c)))
122 $.ui.invalidate('ui.render')
123 } catch {
124 // no status line here
125 }
126}
127
128// A session id as a folder name: one path segment, never `.` or `..`.
129const SESSION_ID = /^(?!\.\.?$)[A-Za-z0-9._-]{1,128}$/
130
131// A recorded session's timeline from its folder, or undefined when there is none to read.
132async function loadRecorder($: EngineInterface, dir: string): Promise<Recorder | undefined> {
133 try {
134 const entries = await $.fs.list(dir)
135 const files = await Promise.all(
136 entries.filter(e => e.kind === 'file' && e.name.endsWith('.jsonl')).map(async e => ({ name: e.name, text: String(await $.fs.read(`${dir}/${e.name}`)) })),
137 )
138 return Recorder.fromRecords(parseChunks(files))
139 } catch {
140 return undefined
141 }
142}
143
144async function startSession($: EngineInterface): Promise<Ctx> {
145 if (ctx) {
146 await persist($, ctx)
147 if (ctx.writer.status.pending > 0) retired.add(ctx)
148 }
149 const sessionId = await $.session.id()
150 const root = await $.session.root()
151 const base = `${await storeDir($, currentOptions)}/${projectKey(root)}`
152 const dir = `${base}/sessions/${sessionId}`
153 // Disk may be missing the newest steps after a failed flush. Resume that in-memory history
154 // before allocating step numbers, or a reload would collapse old/new steps with the same n.
155 const remembered = [...retired].reverse().find(previous => previous.dir === dir && previous.root === root)
156 const rec = remembered?.rec ?? (await loadRecorder($, dir)) ?? new Recorder({ sessionId, root, startedAt: Date.now(), loadId: LOAD_ID })
157 starts += 1
158 // Four digits: chunk names sort in start order through 9999 starts in one module load.
159 const c: Ctx = {
160 rec,
161 root,
162 base,
163 dir,
164 writer: new DurableWriter(new ChunkWriter(`${LOAD_ID}${String(starts).padStart(4, '0')}`)),
165 inflight: 0,
166 snapMs: [],
167 live: new Map(),
168 liveBytes: new Map(),
169 }
170 const last = rec.lastSnapshot()
171 if (last?.snapshot) c.head = last.snapshot.commit
172 c.writer.queueFile('meta.json', JSON.stringify({ sessionId, root, startedAt: rec.session.startedAt }))
173 await persist($, c)
174 return c
175}
176
177// The context for this session, started again after a /clear or a resume.
178async function live($: EngineInterface): Promise<Ctx | undefined> {
179 if (ctx && !ctx.ended) return ctx
180 try {
181 ctx = await startSession($)
182 } catch {
183 ctx = undefined
184 }
185 return ctx
186}
187
188// Git, ready to use from this hook, or undefined with `c.off` saying why.
189// The first caller creates git and its readiness synchronously, so concurrent first calls share them.
190async function ensureGit($: EngineInterface, c: Ctx): Promise<ShadowGit | undefined> {
191 if (c.off) return undefined
192 if (c.git) {
193 c.git.runner = runnerOf($)
194 await c.ready
195 return c.off ? undefined : c.git
196 }
197 const git = new ShadowGit(runnerOf($), `${c.base}/shadow.git`, c.root, `${c.base}/index/${c.rec.session.sessionId}`)
198 c.git = git
199 c.ready = (async () => {
200 const home = await $.env.get('HOME').catch(() => undefined)
201 if (c.root === '/' || (home !== undefined && c.root.replace(/\/+$/, '') === home.replace(/\/+$/, ''))) {
202 c.off = 'the session root is the home or the root directory'
203 } else if (!c.base.startsWith('/') || /(^|\/)\.\.?(\/|$)/.test(c.base)) {
204 c.off = 'storeDir must be an absolute path'
205 } else if (c.base === c.root || isInside(c.base, c.root)) {
206 // Its own files would be snapshotted, and every snapshot would change it.
207 c.off = 'the time-machine store is inside the project'
208 }
209 if (c.off) return
210 try {
211 await git.runner(['mkdir', '-p', `${c.base}/index`])
212 await git.init()
213 if (c.head === undefined) {
214 const t0 = Date.now()
215 const snap = await git.snapshot(undefined, `baseline ${c.rec.session.sessionId}`, refOf(c))
216 const ms = Date.now() - t0
217 c.head = snap.commit
218 if (!c.rec.get(0)) c.rec.baseline(Date.now(), { ...snap, ms })
219 if (ms > TOO_SLOW_MS) c.off = `the baseline snapshot took ${ms}ms; per-step snapshots would slow every tool`
220 }
221 } catch (error) {
222 c.off = `git unavailable: ${message(error)}`
223 }
224 if (c.off) return
225 // Off every tool's path: pruning and gc run detached, and their failures never turn snapshots off.
226 try {
227 $.clock.after(0, () => {
228 prune($, c, git).catch(() => undefined)
229 })
230 } catch {
231 // pruned another day
232 }
233 })()
234 await c.ready
235 if (!c.rec.get(0)) c.rec.baseline(Date.now())
236 await persist($, c)
237 return c.off ? undefined : git
238}
239
240// A call's raw input, kept for /tm rerun within MAX_LIVE_INPUTS and MAX_LIVE_BYTES, oldest dropped first.
241function keepLive(c: Ctx, n: number, input: Record<string, unknown>): void {
242 let size: number
243 try {
244 size = JSON.stringify(input).length
245 } catch {
246 return
247 }
248 if (size > MAX_LIVE_BYTES) return
249 c.live.set(n, input)
250 c.liveBytes.set(n, size)
251 let total = 0
252 for (const v of c.liveBytes.values()) total += v
253 while (c.live.size > MAX_LIVE_INPUTS || total > MAX_LIVE_BYTES) {
254 const oldest = c.live.keys().next().value!
255 total -= c.liveBytes.get(oldest) ?? 0
256 c.live.delete(oldest)
257 c.liveBytes.delete(oldest)
258 }
259}
260
261// `own`: the caller's own call, when it is counted in flight.
262async function takeSnapshot($: EngineInterface, c: Ctx, label: string, own = 0): Promise<SnapshotRef | undefined> {
263 const git = await ensureGit($, c)
264 if (!git) return undefined
265 const concurrent = c.inflight > own
266 const t0 = Date.now()
267 try {
268 const s = await git.snapshot(c.head, label, refOf(c))
269 const ms = Date.now() - t0
270 c.head = s.commit
271 c.failing = undefined
272 c.snapMs.push(ms)
273 if (c.snapMs.length > 1_000) c.snapMs.shift()
274 return { ...s, ms, ...(concurrent ? { concurrent: true } : {}) }
275 } catch (error) {
276 const now = Date.now()
277 const f = (c.failing ??= { count: 0, since: now, error: '' })
278 f.count += 1
279 f.error = message(error)
280 if (f.count >= FAILS_BEFORE_OFF && now - f.since >= FAILING_MS_BEFORE_OFF) c.off = `snapshot failed ${f.count} times in a row: ${f.error}`
281 return undefined
282 }
283}
284
285// Changes made outside the agent since the last snapshot become an `external` step of their own.
286// Skipped while another mutating call is in flight: its changes are not external. A check already
287// running is waited for instead, so a parallel call's writes never land inside it. `own` is 1 when the
288// caller is a tool call already counted in flight.
289async function checkExternal($: EngineInterface, c: Ctx, own = 0): Promise<Step | undefined> {
290 if (c.checking) {
291 await c.checking.catch(() => undefined)
292 return undefined
293 }
294 if (c.inflight > own) return undefined
295 const check = (async () => {
296 const snap = await takeSnapshot($, c, 'external', own)
297 if (!snap?.changed) return undefined
298 const step = c.rec.begin({ id: `external-${Date.now()}`, tool: 'external', kind: 'external', at: Date.now() })
299 c.rec.finish(step, Date.now(), { snapshot: snap })
300 return step
301 })()
302 c.checking = check
303 try {
304 return await check
305 } finally {
306 if (c.checking === check) c.checking = undefined
307 }
308}
309
310async function prune($: EngineInterface, c: Ctx, git: ShadowGit): Promise<void> {
311 const key = `lastPrune:${projectKey(c.root)}`
312 const last = Number((await $.store.get(key)) ?? 0)
313 if (Date.now() - last < DAY) return
314 await $.store.set(key, Date.now())
315 const days = Number(currentOptions.retentionDays ?? 14)
316 const sessions = `${c.base}/sessions`
317 // retentionDays 0 keeps every session; leftovers below are still swept.
318 for (const entry of days > 0 ? await $.fs.list(sessions).catch(() => []) : []) {
319 if (entry.kind !== 'dir' || entry.name === c.rec.session.sessionId) continue
320 const target = `${sessions}/${entry.name}`
321 try {
322 const meta = JSON.parse(String(await $.fs.read(`${target}/meta.json`))) as { startedAt?: number }
323 if (Date.now() - Number(meta.startedAt ?? Date.now()) < days * DAY || !isInside(target, sessions)) continue
324 await runnerOf($)(['rm', '-rf', target])
325 if (SESSION_ID.test(entry.name)) await runnerOf($)(['rm', '-f', `${c.base}/index/${entry.name}`, `${c.base}/index/${entry.name}.lock`])
326 await git.deleteRefs(`refs/tm/${entry.name}/`)
327 } catch {
328 // leave it for next time
329 }
330 }
331 // Indexes whose session folder is gone (pruned before indexes were): the index is only that session's.
332 for (const entry of await $.fs.list(`${c.base}/index`).catch(() => [])) {
333 const id = entry.name.replace(/\.lock$/, '')
334 if (entry.kind !== 'file' || id === c.rec.session.sessionId || !SESSION_ID.test(id)) continue
335 if (await $.fs.exists(`${sessions}/${id}`).catch(() => true)) continue
336 await runnerOf($)(['rm', '-f', `${c.base}/index/${entry.name}`]).catch(() => undefined)
337 }
338 // Isolated reruns' copies a crash left behind: named `<load>-<step>-<base-36 time>`, removed after a day.
339 const tmpRoot = `${c.base}/tmp`
340 for (const entry of await $.fs.list(tmpRoot).catch(() => [])) {
341 const at = parseInt(/^[0-9a-z]+-\d+-([0-9a-z]+)(?:\.index)?$/.exec(entry.name)?.[1] ?? '', 36)
342 const target = `${tmpRoot}/${entry.name}`
343 if (!(Date.now() - at > DAY) || !isInside(target, tmpRoot)) continue
344 await runnerOf($)(['rm', '-rf', target]).catch(() => undefined)
345 }
346 await git.gc().catch(() => undefined)
347}
348
349// Finishes a begun step on every path: a recording error still ends it, with what it gathered.
350async function settle($: EngineInterface, c: Ctx, step: Step | undefined, counted: boolean, mutating: boolean, answer: unknown, cwd?: string): Promise<void> {
351 if (counted) c.inflight = Math.max(0, c.inflight - 1)
352 if (!step) return
353 try {
354 const result = summarizeResult(answer)
355 const snapshot = mutating ? await takeSnapshot($, c, `step ${step.n} ${step.tool}`) : undefined
356 const liveInput = c.live.get(step.n)
357 const command = String(liveInput?.command ?? step.input.command ?? '')
358 const invocation = cwd !== undefined && (liveInput || !step.inputLossy) ? { command, cwd } : undefined
359 const test = step.tool === 'Bash' && classifyCommand(command) === 'test' ? parseTestOutput(resultText(answer), result.isError, invocation) : undefined
360 c.rec.finish(step, Date.now(), { result, ...(snapshot ? { snapshot } : {}), ...(test ? { test } : {}) })
361 await persist($, c)
362 paint($, c)
363 } catch {
364 // recording never breaks the call; the step is still finished
365 try {
366 if (step.endedAt === undefined) c.rec.finish(step, Date.now())
367 await persist($, c)
368 } catch {
369 // dropped
370 }
371 }
372}
373
374// Origins a person typed: the only ones allowed to change files or run commands through /tm.
375const TYPED = new Set(['composer', 'bridge', 'sdk'])
376
377// The subcommand, when `args` would change files or run commands: restore/branch --yes, and every rerun
378// but an --isolated dry run (a "read-only" step may be a subagent that edits files once run again).
379function changesFiles(args: string): string | undefined {
380 const words = args.trim().split(/\s+/).filter(w => w !== '')
381 const sub = words.find(w => !w.startsWith('--'))
382 const flags = new Set(words.filter(w => w.startsWith('--')))
383 if ((sub === 'restore' || sub === 'branch') && flags.has('--yes')) return sub
384 if (sub === 'rerun' && !(flags.has('--isolated') && !flags.has('--yes'))) return sub
385 return undefined
386}
387
388const num = (word: string | undefined): number | undefined => (word !== undefined && /^\d+$/.test(word) ? Number(word) : undefined)
389
390async function restore($: EngineInterface, c: Ctx, n: number, yes: boolean, branch?: string): Promise<string> {
391 if (!c.rec.get(n)) return `time-machine: no step ${n}`
392 const git = await ensureGit($, c)
393 if (!git) return `time-machine: snapshots are off (${c.off ?? 'not ready'}); there is nothing to restore`
394 const target = c.rec.snapshotAt(n)
395 if (!target?.snapshot) return `time-machine: no snapshot at or before step ${n}`
396 await checkExternal($, c)
397 const from = c.head!
398 const files = await git.nameStatus(from, target.snapshot.commit)
399 const name = branch === undefined ? undefined : branch || c.rec.nextBranchName()
400 const asked = c.rec.get(n)!
401 if (!yes) return renderRestorePlan(asked, target, files, false, name)
402 if (name !== undefined && branchTaken(c.rec, name)) return `time-machine: branch '${name}' already exists`
403 // A name git would refuse as a ref would fail only after the work tree was rewritten.
404 if (name !== undefined && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || name.includes('..') || name.endsWith('.lock') || name.endsWith('.')))
405 return `time-machine: '${name}' is not a usable branch name (letters, digits, '.', '_', '-')`
406 // Applied first: a refused restore (the work tree moved since `from`) leaves no fork and no restore step.
407 // The ref is the new branch's when branching, so the branch left behind keeps its history.
408 const ref = name !== undefined ? `refs/tm/${c.rec.session.sessionId}/${name}` : refOf(c)
409 const commit = await git.restore(target.snapshot.commit, ref, from, `restore to step ${n}${name !== undefined ? ` on branch ${name}` : ''}`, name !== undefined)
410 c.head = commit
411 c.rec.restoreStep({ at: Date.now(), to: n, from, commit, files, ...(name !== undefined ? { branch: name } : {}) })
412 await persist($, c)
413 paint($, c)
414 const list = files.slice(0, 20).map(f => `${f.status} ${f.path}`).join(', ')
415 const note =
416 `[time-machine] The user restored the working tree to step ${n} (${label(c.rec.get(n)!)}). ${files.length} file(s) changed${list ? `: ${list}` : ''}. ` +
417 'Earlier tool results about these files are out of date; re-read files before editing them.' +
418 (name !== undefined ? ` Work continues on branch '${name}'; the conversation itself was not rewound.` : '')
419 let told = true
420 try {
421 const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
422 if (typeof appended?.deny === 'string') told = false
423 } catch {
424 // the restore stands; the model was not told
425 told = false
426 }
427 return renderRestorePlan(asked, target, files, true, name, told)
428}
429
430// A branch name, or `session:<id>[/<branch>]` for another recorded session of this project.
431async function pathOf($: EngineInterface, c: Ctx, spec: string): Promise<{ steps: Step[]; head?: string } | string> {
432 let rec: Recorder | undefined = c.rec
433 let branch = spec
434 if (spec.startsWith('session:')) {
435 const [id = '', b] = spec.slice('session:'.length).split('/')
436 if (!SESSION_ID.test(id)) return `time-machine: '${id}' is not a session id`
437 rec = await loadRecorder($, `${c.base}/sessions/${id}`)
438 if (!rec) return `time-machine: no recorded session ${id}`
439 branch = b ?? 'main'
440 }
441 if (!rec.branches.has(branch)) return `time-machine: no branch '${branch}'`
442 const steps = rec.path(branch)
443 const head = rec.lastSnapshot(branch)?.snapshot?.commit
444 return head !== undefined ? { steps, head } : { steps }
445}
446
447async function rerun($: EngineInterface, c: Ctx, n: number, write: boolean, isolated: boolean, yes: boolean): Promise<string> {
448 const step = c.rec.get(n)
449 if (!step || step.kind !== 'tool') return `time-machine: no tool step ${n}`
450 const input = c.live.get(n) ?? (step.inputLossy ? undefined : step.input)
451 if (!input) return `time-machine: step ${n}'s input was cut or redacted when stored and this load did not record it live, so it cannot be replayed`
452 if (isolated) {
453 if (step.tool !== 'Bash') return 'time-machine: --isolated replays Bash steps only'
454 const command = String(input.command ?? '')
455 const problem = isolationProblem(command, c.root)
456 if (yes && problem) return `$ ${redactSecrets(command)}\ntime-machine: ${problem}`
457 const git = await ensureGit($, c)
458 const before = git ? c.rec.snapshotAt(step.parent) : undefined
459 const blocked = !git ? `snapshots are off (${c.off ?? 'not ready'})` : !before?.snapshot ? `no snapshot before step ${n}` : problem
460 if (!yes) return renderIsolatedPlan(step, command, before, blocked)
461 if (!git || !before?.snapshot) return `$ ${redactSecrets(command)}\ntime-machine: ${blocked}`
462 const tmpRoot = `${c.base}/tmp`
463 const tmp = `${tmpRoot}/${LOAD_ID}-${n}-${Date.now().toString(36)}`
464 const run = runnerOf($)
465 // Changes made before it are not the command's; one it makes to the project anyway is recorded.
466 await checkExternal($, c)
467 let out: string
468 try {
469 await run(['mkdir', '-p', tmp])
470 await git.exportTo(before.snapshot.commit, tmp)
471 const r = await run(['sh', '-lc', command], { cwd: tmp, timeoutMs: 600_000 })
472 const text = [r.stdout, r.stderr].filter(s => s !== '').join('\n')
473 const now = step.test ? parseTestOutput(text, r.exitCode !== 0) : undefined
474 const where = `snapshot copy (not a sandbox) of ${circled(before.n)}'s snapshot; ignored files such as dependencies are not in the copy`
475 out = [`$ ${redactSecrets(command)}`, renderRerun(step, compareOutputs(step.result, text, step.test, now), where)].join('\n')
476 } finally {
477 if (isInside(tmp, tmpRoot)) await run(['rm', '-rf', tmp, `${tmp}.index`]).catch(() => undefined)
478 }
479 const stray = await checkExternal($, c)
480 if (stray) {
481 await persist($, c)
482 paint($, c)
483 out += `\nThe project changed while it ran: recorded as ${circled(stray.n)} ${label(stray)}.`
484 }
485 return out
486 }
487 if ((isMutating(step.tool, input, currentOptions.skipSearchBash === true) || ACTING_TOOLS.has(step.tool)) && !write)
488 return `time-machine: step ${n} (${step.tool}) can change files; add --write to run it again here (${ACTING_TOOLS.has(step.tool) ? 'the calls it makes are recorded as steps of their own' : 'a snapshot is taken after it'})`
489 c.rerunOf = n
490 let answer: unknown
491 try {
492 answer = await $.tool.call({ tool: step.tool, ...input } as never)
493 } finally {
494 c.rerunOf = undefined
495 }
496 const text = resultText(answer)
497 const now = step.test ? parseTestOutput(text, summarizeResult(answer).isError) : undefined
498 return renderRerun(step, compareOutputs(step.result, text, step.test, now), 'here, against the current files')
499}
500
501async function runTm($: EngineInterface, c: Ctx, args: string): Promise<string> {
502 const words = args.trim().split(/\s+/).filter(w => w !== '')
503 const flags = new Set(words.filter(w => w.startsWith('--')))
504 const rest = words.filter(w => !w.startsWith('--'))
505 const sub = rest[0] ?? 'timeline'
506 switch (sub) {
507 case 'timeline': {
508 const branchAt = words.indexOf('--branch')
509 const branch = branchAt >= 0 ? words[branchAt + 1] : undefined
510 if (branch !== undefined && !c.rec.branches.has(branch)) return `time-machine: no branch '${branch}'`
511 return renderTimeline(c.rec, { all: flags.has('--all'), ...(branch ? { branch } : {}), ...(c.off ? { off: c.off } : {}), snapMs: c.snapMs, recording: recordingStatus(c) })
512 }
513 case 'show': {
514 const n = num(rest[1])
515 const step = n === undefined ? undefined : c.rec.get(n)
516 return step ? renderStep(step) : `time-machine: no step ${rest[1] ?? ''}`.trim()
517 }
518 case 'diff': {
519 const a = num(rest[1])
520 if (a === undefined) return USAGE
521 const git = await ensureGit($, c)
522 if (!git) return `time-machine: snapshots are off (${c.off ?? 'not ready'})`
523 const from = c.rec.snapshotAt(a)?.snapshot?.commit
524 let to: string | undefined
525 const b = num(rest[2])
526 if (b !== undefined) to = c.rec.snapshotAt(b)?.snapshot?.commit
527 else {
528 await checkExternal($, c)
529 to = c.head
530 }
531 if (from === undefined || to === undefined) return 'time-machine: no snapshot for that step'
532 const { stat, patch, more } = await git.diffText(from, to, DIFF_LINES)
533 return [stat.trimEnd() || '(no difference)', '', patch, ...(more ? [`… (cut at ${DIFF_LINES} lines; the stat above lists every file)`] : [])].join('\n')
534 }
535 case 'why': {
536 const w = why(c.rec.path(), num(rest[1]))
537 if (!w) return rest[1] ? `time-machine: step ${rest[1]} is not a test run on this branch` : 'time-machine: no failing test run on this branch'
538 return renderWhy(w, n => {
539 const s = c.rec.get(n)
540 return s ? `${circled(n)} ${label(s)}` : undefined
541 })
542 }
543 case 'restore':
544 case 'branch': {
545 const n = num(rest[1])
546 if (n === undefined) return USAGE
547 return restore($, c, n, flags.has('--yes'), sub === 'branch' ? (rest[2] ?? '') : undefined)
548 }
549 case 'compare': {
550 if (rest.length < 3) return USAGE
551 const a = await pathOf($, c, rest[1]!)
552 const b = await pathOf($, c, rest[2]!)
553 if (typeof a === 'string') return a
554 if (typeof b === 'string') return b
555 let stat: string | undefined
556 if (a.head && b.head) {
557 const git = await ensureGit($, c)
558 if (git) stat = (await git.diffText(a.head, b.head).catch(() => ({ stat: '(snapshots no longer available)', patch: '' }))).stat
559 }
560 return renderCompare(rest[1]!, rest[2]!, compare(a.steps, b.steps), stat)
561 }
562 case 'rerun': {
563 const n = num(rest[1])
564 if (n === undefined) return USAGE
565 return rerun($, c, n, flags.has('--write'), flags.has('--isolated'), flags.has('--yes'))
566 }
567 case 'sessions': {
568 const sessions = `${c.base}/sessions`
569 const rows: { id: string; startedAt: number; current: boolean }[] = []
570 for (const entry of await $.fs.list(sessions).catch(() => [])) {
571 if (entry.kind !== 'dir') continue
572 let startedAt = 0
573 try {
574 startedAt = Number((JSON.parse(String(await $.fs.read(`${sessions}/${entry.name}/meta.json`))) as { startedAt?: number }).startedAt ?? 0)
575 } catch {
576 // unknown start
577 }
578 rows.push({ id: entry.name, startedAt, current: entry.name === c.rec.session.sessionId })
579 }
580 return renderSessions(rows)
581 }
582 default:
583 return USAGE
584 }
585}
586
587export const register: Register = (on, options) => {
588 currentOptions = options
589
590 on('session.start', async ($, e, next) => {
591 try {
592 ctx = await startSession($)
593 for (const command of [
594 { name: 'tm', description: 'Agent Time Machine: timeline, show, diff, why, restore, branch, compare, rerun, sessions', argumentHint: '[timeline|show n|diff a [b]|why [n]|restore n|branch n|compare a b|rerun n|sessions]' },
595 { name: 'tm-pane', description: 'Open the live Time Machine timeline pane' },
596 ]) {
597 try {
598 await $.command.register(command)
599 } catch {
600 // the command stays unavailable
601 }
602 }
603 paint($, ctx)
604 } catch {
605 ctx = undefined
606 }
607 return next(e)
608 })
609
610 on('session.end', async ($, e, next) => {
611 const result = await next(e)
612 if (ctx) await persist($, ctx)
613 if ((e.reason === 'clear' || e.reason === 'resume') && ctx) ctx.ended = true
614 return result
615 }).catch(($, e, next) => next(e))
616
617 on('turn.start', async ($, e, next) => {
618 try {
619 const c = await live($)
620 if (c) {
621 c.rec.startTurn(Date.now(), e.text, currentOptions.recordModelOutput === true)
622 await persist($, c)
623 }
624 } catch {
625 // not recorded
626 }
627 return next(e)
628 })
629
630 on('tool.call', async ($, e, next) => {
631 const c = await live($).catch(() => undefined)
632 if (!c) return next(e)
633 const raw = e as unknown as Record<string, unknown>
634 const tool = String(e.tool)
635 let step: Step | undefined
636 let mutating = false
637 let counted = false
638 let cwd: string | undefined
639 try {
640 if (tool === 'Bash') cwd = await $.session.cwd().catch(() => undefined)
641 mutating = isMutating(tool, raw, currentOptions.skipSearchBash === true)
642 // Counted before the check, so a parallel call does not check (and blame this one's writes) too.
643 if (mutating) {
644 c.inflight += 1
645 counted = true
646 await checkExternal($, c, 1)
647 }
648 const { input, lossy } = summarizeInput(raw)
649 const target = targetOf(tool, raw, c.root)
650 step = c.rec.begin({
651 id: e.tool_use_id ?? `call-${Date.now()}`,
652 tool,
653 input,
654 inputLossy: lossy,
655 at: Date.now(),
656 ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
657 ...(target !== undefined ? { target } : {}),
658 ...(c.rerunOf !== undefined ? { rerunOf: c.rerunOf } : {}),
659 })
660 c.rerunOf = undefined
661 keepLive(c, step.n, callInput(raw))
662 } catch {
663 // recording never blocks the tool
664 }
665 try {
666 const answer = await next(e)
667 await settle($, c, step, counted, mutating, answer, cwd)
668 return answer
669 } catch (error) {
670 await settle($, c, step, counted, mutating, { isError: true, text: message(error) }, cwd)
671 throw error
672 }
673 }).catch(($, e, next) => next(e))
674
675 on('turn.step', async function* ($, e, next) {
676 let text = ''
677 const tools: { id: string; tool: string }[] = []
678 for await (const chunk of next(e)) {
679 try {
680 if (chunk.kind === 'text') text += chunk.text
681 else if (chunk.kind === 'tool') tools.push({ id: chunk.id, tool: chunk.name })
682 else if (chunk.kind === 'stop' && ctx && !ctx.ended)
683 ctx.rec.addModel({
684 turn: ctx.rec.turn,
685 index: e.index,
686 ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
687 model: e.model,
688 stopReason: chunk.stopReason,
689 usage: chunk.usage,
690 text: { chars: text.length, hash: contentHash(text), ...(currentOptions.recordModelOutput === true ? { body: text } : {}) },
691 toolUses: tools,
692 })
693 } catch {
694 // not recorded
695 }
696 yield chunk
697 }
698 if (ctx) await persist($, ctx).catch(() => undefined)
699 })
700
701 on('command.run', { command: 'tm' }, async ($, e) => {
702 try {
703 const c = await live($)
704 if (!c) return { text: 'time-machine: not recording (the session could not be read)' }
705 const args = e.args ?? ''
706 const sub = changesFiles(args)
707 const kind = e.origin?.kind ?? 'unknown'
708 if (sub !== undefined && !TYPED.has(kind)) return { text: `time-machine: ${sub} changes files or runs commands and only runs when you type it (this came from ${kind})` }
709 return { text: redactSecrets(await runTm($, c, args)) }
710 } catch (error) {
711 return { text: `time-machine: ${redactSecrets(message(error))}` }
712 }
713 })
714
715 on('command.run', { command: 'tm-pane' }, async $ => {
716 await $.ui.open({ id: PANE, title: 'Time Machine' })
717 return { text: 'Time Machine pane opened (shown in the terminal and the desktop app).' }
718 })
719
720 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
721 const { Box, Text } = $.ui.resolve(e)
722 let lines: string[]
723 try {
724 lines = ctx ? renderTimeline(ctx.rec, { ...(ctx.off ? { off: ctx.off } : {}), snapMs: ctx.snapMs, recording: recordingStatus(ctx) }).split('\n') : ['Time Machine: not recording']
725 } catch (error) {
726 lines = [`time-machine: ${redactSecrets(message(error))}`]
727 }
728 const width = e.props.bodyColumns ?? 80
729 return (
730 <Box flexDirection="column">
731 {lines.map(line => (
732 <Text>{(line.length > width ? `${line.slice(0, width - 1)}…` : line) || ' '}</Text>
733 ))}
734 </Box>
735 )
736 })
737}
738hooks/core/causality.ts 92 lines1import { sameFile } from './testparse.ts'
2import type { Step, TestRun } from './types.ts'
3
4export type Suspect = { path: string; score: number; reasons: string[]; steps: number[] }
5export type Why = {
6 run: Step
7 base?: Step
8 suspects: Suspect[]
9 fixedBy?: { run: Step; files: string[]; steps: number[] }
10 improved?: { run: Step; from: number; to: number }
11}
12
13const MAX_SUSPECTS = 5
14const basename = (path: string): string => path.split('/').filter(Boolean).at(-1) ?? path
15const isRun = (step: Step): step is Step & { test: TestRun } => step.test !== undefined
16
17function comparable(a: Step & { test: TestRun }, b: Step & { test: TestRun }): boolean {
18 const x = a.test.identity
19 const y = b.test.identity
20 return x !== undefined && y !== undefined && a.agentId === b.agentId && a.test.framework === b.test.framework && x.cwd === y.cwd && x.commandHash === y.commandHash
21}
22
23// Files that snapshots after path[fromIdx] up to path[toIdx] changed, each with the steps that changed it.
24export function changesBetween(path: readonly Step[], fromIdx: number, toIdx: number): Map<string, number[]> {
25 const out = new Map<string, number[]>()
26 for (let i = Math.max(0, fromIdx + 1); i <= toIdx && i < path.length; i += 1) {
27 const step = path[i]!
28 if (!step.snapshot?.changed || step.kind === 'baseline') continue
29 for (const file of step.snapshot.files) {
30 const steps = out.get(file.path) ?? []
31 steps.push(step.n)
32 out.set(file.path, steps)
33 }
34 }
35 return out
36}
37
38// Inferred, not proof: a file the failure output names outranks one it does not; recent and repeated
39// changes rank higher among equals.
40function rank(path: readonly Step[], changes: Map<string, number[]>, test: TestRun, baseIdx: number, runIdx: number): Suspect[] {
41 const position = new Map(path.map((step, i) => [step.n, i]))
42 const span = Math.max(1, runIdx - baseIdx)
43 const suspects: Suspect[] = []
44 for (const [file, steps] of changes) {
45 const reasons: string[] = []
46 let score = 0
47 if (test.failures.some(f => f.file !== undefined && sameFile(f.file, file)) || test.paths.some(p => sameFile(p, file))) {
48 score += 100
49 reasons.push('named in the failure output')
50 } else if (test.paths.some(p => basename(p) === basename(file))) {
51 score += 60
52 reasons.push('its file name appears in the failure output')
53 }
54 if (test.locations.some(l => sameFile(l.file, file))) {
55 score += 50
56 reasons.push('a failure location points into it')
57 }
58 const last = steps[steps.length - 1]!
59 score += Math.round((20 * ((position.get(last) ?? baseIdx) - baseIdx)) / span)
60 reasons.push(`last changed at step ${last}`)
61 score += Math.min(10, 2 * steps.length)
62 if (steps.length > 1) reasons.push(`changed ${steps.length} times`)
63 suspects.push({ path: file, score, reasons, steps })
64 }
65 return suspects.sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : 1)).slice(0, MAX_SUSPECTS)
66}
67
68// For the test run `runN` (default: the last failing one on the path): what changed since the last
69// passing run, ranked; which later edits made it pass; whether a later run failed less.
70export function why(path: readonly Step[], runN?: number): Why | undefined {
71 const runs = path.flatMap((step, i) => (isRun(step) ? [{ step, i }] : []))
72 const target = runN === undefined ? [...runs].reverse().find(r => !r.step.test.ok) : runs.find(r => r.step.n === runN)
73 if (!target) return undefined
74 const { step: run, i: runIdx } = target
75 const okBefore = [...runs].reverse().find(r => r.i < runIdx && r.step.test.ok && comparable(run, r.step))
76 const baseIdx = okBefore ? okBefore.i : path[0]?.kind === 'baseline' ? 0 : -1
77 const out: Why = { run, suspects: [] }
78 const base = baseIdx >= 0 ? path[baseIdx] : undefined
79 if (base) out.base = base
80 if (!run.test.ok) out.suspects = rank(path, changesBetween(path, baseIdx, runIdx), run.test, baseIdx, runIdx)
81 const later = runs.filter(r => r.i > runIdx && comparable(run, r.step))
82 const fixed = later.find(r => r.step.test.ok)
83 if (!run.test.ok && fixed) {
84 const changes = changesBetween(path, runIdx, fixed.i)
85 out.fixedBy = { run: fixed.step, files: [...changes.keys()], steps: [...new Set([...changes.values()].flat())].sort((a, b) => a - b) }
86 }
87 const before = run.test.failed
88 const fewer = before === undefined ? undefined : later.find(r => r.step.test.failed !== undefined && r.step.test.failed < before && !r.step.test.ok)
89 if (fewer && before !== undefined) out.improved = { run: fewer.step, from: before, to: fewer.step.test.failed! }
90 return out
91}
92hooks/core/classify.ts 191 lines1export type BashCategory = 'test' | 'build' | 'install' | 'git' | 'search' | 'other'
2
3// Blank out single/double quoted strings and $(...) bodies before splitting
4function blankOutStringLiterals(command: string): string {
5 let result = ''
6 let i = 0
7
8 while (i < command.length) {
9 // Single-quoted string: no escaping inside
10 if (command[i] === "'") {
11 result += "'"
12 i++
13 while (i < command.length && command[i] !== "'") {
14 result += ' '
15 i++
16 }
17 if (i < command.length) {
18 result += "'"
19 i++
20 }
21 }
22 // Double-quoted string: escaping possible
23 else if (command[i] === '"') {
24 result += '"'
25 i++
26 while (i < command.length && command[i] !== '"') {
27 if (command[i] === '\\' && i + 1 < command.length) {
28 result += ' '
29 i += 2
30 } else {
31 result += ' '
32 i++
33 }
34 }
35 if (i < command.length) {
36 result += '"'
37 i++
38 }
39 }
40 // $(...) substitution
41 else if (command[i] === '$' && i + 1 < command.length && command[i + 1] === '(') {
42 result += ' '
43 i += 2
44 let depth = 1
45 while (i < command.length && depth > 0) {
46 if (command[i] === '(') {
47 depth++
48 } else if (command[i] === ')') {
49 depth--
50 }
51 result += depth === 0 ? ')' : ' '
52 i++
53 }
54 } else {
55 result += command[i]
56 i++
57 }
58 }
59
60 return result
61}
62
63// Normalise: strip leading env vars and wrappers
64function normalise(segment: string): string {
65 if (typeof segment !== 'string') return ''
66
67 let s = segment.trim()
68 const wrappers = ['pnpm exec', 'poetry run', 'yarn dlx', 'uv run', 'npx', 'bunx', 'env', 'time', 'sudo']
69
70 let changed = true
71 while (changed) {
72 changed = false
73
74 // Strip leading env var assignments (VAR=value ...)
75 const envMatch = s.match(/^([A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/)
76 if (envMatch) {
77 s = s.slice(envMatch[0].length).trim()
78 changed = true
79 continue
80 }
81
82 // Strip leading wrappers
83 for (const wrapper of wrappers) {
84 if (s.startsWith(wrapper + ' ')) {
85 s = s.slice(wrapper.length + 1).trim()
86 changed = true
87 break
88 }
89 }
90 }
91
92 return s
93}
94
95// Classify gradle/gradlew/mvn commands by analyzing task tokens
96function classifyGradleMvn(segment: string): BashCategory {
97 const tokens = segment.split(/\s+/)
98
99 for (let i = 0; i < tokens.length; i++) {
100 const token = tokens[i] ?? ''
101
102 // Skip if it starts with - (flag or option)
103 if (token.startsWith('-')) {
104 // Special case: if this is -x and next token is test, skip both
105 if (token === '-x' && i + 1 < tokens.length && tokens[i + 1] === 'test') {
106 i++ // skip the test token too
107 }
108 continue
109 }
110
111 // Check if this is a test task
112 if (token === 'test' || token === 'verify' || token === 'check' || token.endsWith(':test')) {
113 return 'test'
114 }
115 }
116
117 return 'build'
118}
119
120const RULES: [BashCategory, RegExp][] = [
121 ['test', /^(jest|vitest|mocha|pytest|tox|nox|rspec|phpunit|ctest|nosetests|bats|karma|ava|jasmine)\b/],
122 ['test', /^cypress\s+run\b/],
123 ['test', /^playwright\s+test\b/],
124 ['test', /^(npm|bun)\s+(run\s+)?test\b/],
125 ['test', /^npm\s+t\b/],
126 ['test', /^(pnpm|yarn)\s+((-r|--filter\s+\S+)\s+)?(run\s+)?test\b/],
127 ['test', /^(go|cargo|deno|dotnet|mix|swift)\s+test\b/],
128 ['test', /^cargo\s+nextest\s+run\b/],
129 ['test', /^node\s+--test\b/],
130 ['test', /^(python|python3)\s+-m\s+(unittest|pytest)\b/],
131 ['test', /^make\s+(test|check)\b/],
132 ['test', /^claude\s+plugin\s+test\b/],
133 ['install', /^(npm|pnpm|yarn|bun)\s+(install|i|add|ci)\b/],
134 ['install', /^yarn\s*$/],
135 ['install', /^(pip|pip3)\s+install\b/],
136 ['install', /^uv\s+(pip|sync|add)\b/],
137 ['install', /^poetry\s+install\b/],
138 ['install', /^brew\s+install\b/],
139 ['install', /^(apt|apt-get)\s+install\b/],
140 ['install', /^cargo\s+(add|install|fetch)\b/],
141 ['install', /^go\s+(get|mod\s+download)\b/],
142 ['install', /^bundle\s+install\b/],
143 ['build', /^(tsc|webpack|esbuild|rollup|make|cmake|ninja|bazel)\b/],
144 ['build', /^(npm|pnpm|yarn|bun)\s+(run\s+)?build\b/],
145 ['build', /^vite\s+build\b/],
146 ['build', /^(cargo|go|swift|dotnet)\s+build\b/],
147 ['build', /^docker\s+build\b/],
148 ['git', /^(git|gh)\b/],
149 ['search', /^(rg|grep|ag|ack|find|fd|ls|tree|cat|head|tail|wc)\b/],
150]
151
152const RANK: BashCategory[] = ['test', 'build', 'install', 'git', 'search', 'other']
153
154function classifySegment(segment: string): BashCategory {
155 const normalized = normalise(segment)
156
157 // Special handling for gradle/mvn commands
158 const cmd = normalized.replace(/^\.\//, '').split(/\s+/)[0]
159 if (cmd === 'gradle' || cmd === 'gradlew' || cmd === 'mvn') {
160 return classifyGradleMvn(normalized)
161 }
162
163 return RULES.find(([, pattern]) => pattern.test(normalized))?.[0] ?? 'other'
164}
165
166export function classifyCommand(command: string): BashCategory {
167 if (typeof command !== 'string') return 'other'
168
169 const blanked = blankOutStringLiterals(command)
170 const segments = blanked
171 .split(/&&|\|\||;|\||\n|[&](?![&])/)
172 .map(s => s.trim())
173 .filter(Boolean)
174
175 let best: BashCategory = 'other'
176 for (const segment of segments) {
177 const category = classifySegment(segment)
178 if (RANK.indexOf(category) < RANK.indexOf(best)) best = category
179 }
180 return best
181}
182
183// `--isolated` changes only the working directory: a command that names the project root by its
184// absolute path still reaches the real tree.
185export function isolationProblem(command: string, root: string): string | undefined {
186 const base = root.replace(/\/+$/, '')
187 if (base === '') return undefined
188 const escaped = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
189 return new RegExp(`(^|[^\\w./~-])${escaped}(?=$|[^\\w.-])`).test(command) ? "the command refers to the project's absolute path, so it would not be isolated" : undefined
190}
191hooks/core/compare.ts 103 lines1import { contentHash } from './hash.ts'
2import { redactSecrets } from './redact.ts'
3import { RESULT_HEAD_MAX } from './tools.ts'
4import type { Step, StepResult, TestRun } from './types.ts'
5
6const MAX_STEPS = 500
7const SAMPLES = 3
8
9export const keyOf = (step: Step): string => (step.target ? `${step.tool} ${step.target}` : step.tool)
10
11// Index pairs of a longest common subsequence.
12export function lcsPairs(a: readonly string[], b: readonly string[]): [number, number][] {
13 const n = a.length
14 const m = b.length
15 const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1))
16 for (let i = n - 1; i >= 0; i -= 1)
17 for (let j = m - 1; j >= 0; j -= 1) dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
18 const pairs: [number, number][] = []
19 let i = 0
20 let j = 0
21 while (i < n && j < m) {
22 if (a[i] === b[j]) {
23 pairs.push([i, j])
24 i += 1
25 j += 1
26 } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) i += 1
27 else j += 1
28 }
29 return pairs
30}
31
32export type Compared = {
33 // Leading steps with the same tool and target on both paths.
34 common: number
35 // Steps matched (same key) after the shared start.
36 matched: number
37 divergeA?: Step
38 divergeB?: Step
39 onlyA: Step[]
40 onlyB: Step[]
41 lastTestA?: Step
42 lastTestB?: Step
43}
44
45export function compare(pathA: readonly Step[], pathB: readonly Step[]): Compared {
46 const a = pathA.filter(s => s.kind !== 'baseline').slice(-MAX_STEPS)
47 const b = pathB.filter(s => s.kind !== 'baseline').slice(-MAX_STEPS)
48 const ka = a.map(keyOf)
49 const kb = b.map(keyOf)
50 let common = 0
51 while (common < ka.length && common < kb.length && ka[common] === kb[common]) common += 1
52 const pairs = lcsPairs(ka.slice(common), kb.slice(common))
53 const inA = new Set(pairs.map(p => p[0] + common))
54 const inB = new Set(pairs.map(p => p[1] + common))
55 const out: Compared = {
56 common,
57 matched: pairs.length,
58 onlyA: a.filter((_, i) => i >= common && !inA.has(i)),
59 onlyB: b.filter((_, i) => i >= common && !inB.has(i)),
60 }
61 if (a[common]) out.divergeA = a[common]
62 if (b[common]) out.divergeB = b[common]
63 const lastA = [...a].reverse().find(s => s.test)
64 const lastB = [...b].reverse().find(s => s.test)
65 if (lastA) out.lastTestA = lastA
66 if (lastB) out.lastTestB = lastB
67 return out
68}
69
70export type OutputComparison = {
71 identical: boolean
72 addedCount: number
73 removedCount: number
74 added: string[]
75 removed: string[]
76 was?: TestRun
77 now?: TestRun
78}
79
80// Same hash: identical. Otherwise a line multiset difference over the first 2 KiB of each (redacted alike).
81export function compareOutputs(was: StepResult | undefined, nowText: string, wasTest?: TestRun, nowTest?: TestRun): OutputComparison {
82 const identical = was !== undefined && was.hash === contentHash(nowText)
83 const before = new Map<string, number>()
84 for (const line of (was?.head ?? '').split('\n')) before.set(line, (before.get(line) ?? 0) + 1)
85 const added: string[] = []
86 for (const line of redactSecrets(nowText).slice(0, RESULT_HEAD_MAX).split('\n')) {
87 const left = before.get(line) ?? 0
88 if (left > 0) before.set(line, left - 1)
89 else added.push(line)
90 }
91 const removed = [...before.entries()].flatMap(([line, k]) => Array.from({ length: k }, () => line))
92 const out: OutputComparison = {
93 identical,
94 addedCount: identical ? 0 : added.length,
95 removedCount: identical ? 0 : removed.length,
96 added: identical ? [] : added.slice(0, SAMPLES),
97 removed: identical ? [] : removed.slice(0, SAMPLES),
98 }
99 if (wasTest) out.was = wasTest
100 if (nowTest) out.now = nowTest
101 return out
102}
103hooks/core/hash.ts 21 lines1// FNV-1a over UTF-16 code units with a murmur3 finalizer; `seed` picks an independent function.
2export function hash32(text: string, seed = 0): number {
3 let h = (0x811c9dc5 ^ seed) >>> 0
4 for (let i = 0; i < text.length; i += 1) {
5 h ^= text.charCodeAt(i)
6 h = Math.imul(h, 0x01000193)
7 }
8 h ^= h >>> 16
9 h = Math.imul(h, 0x85ebca6b)
10 h ^= h >>> 13
11 h = Math.imul(h, 0xc2b2ae35)
12 h ^= h >>> 16
13 return h >>> 0
14}
15
16const hex = (n: number): string => (n >>> 0).toString(16).padStart(8, '0')
17
18export function contentHash(text: string): string {
19 return hex(hash32(text, 0)) + hex(hash32(text, 0x9747b28c))
20}
21hooks/core/render.ts 227 lines1import type { Why } from './causality.ts'
2import { keyOf, type Compared, type OutputComparison } from './compare.ts'
3import { redactSecrets } from './redact.ts'
4import type { Recorder } from './timeline.ts'
5import type { FileChange, Step, TestRun } from './types.ts'
6
7const clip = (text: string, n: number): string => {
8 const line = redactSecrets(text).replace(/[\r\n\t]+/g, ' ')
9 return line.length <= n ? line : `${line.slice(0, Math.max(0, n - 1))}…`
10}
11
12export function circled(n: number): string {
13 if (n === 0) return '⓪'
14 if (n >= 1 && n <= 20) return String.fromCharCode(0x2460 + n - 1)
15 return `(${n})`
16}
17
18export function testSummary(t: TestRun): string {
19 const parts: string[] = []
20 if (t.failed) parts.push(`${t.failed} failed`)
21 if (t.errors) parts.push(`${t.errors} error(s)`)
22 if (t.passed !== undefined) parts.push(`${t.passed} passed`)
23 if (parts.length === 0) parts.push(t.ok ? 'passed' : 'failed')
24 return `${t.ok ? '✓' : '✗'} ${parts.join(', ')}`
25}
26
27export function filesSummary(files: readonly FileChange[], total: number, n = 3): string {
28 const head = files.slice(0, n).map(f => `${f.status} ${f.path}`).join(', ')
29 return total > n ? `${head} +${total - n}` : head
30}
31
32export function label(step: Step): string {
33 return clip(rawLabel(step), 80)
34}
35
36function rawLabel(step: Step): string {
37 if (step.kind === 'baseline') return 'baseline'
38 if (step.kind === 'external') return 'external change'
39 if (step.kind === 'restore') return `restore → ${circled(Number(step.input.to))}`
40 return keyOf(step)
41}
42
43// The sub-lines under a step: test outcome or error, the snapshot it took, a rerun marker.
44export function notes(step: Step): string[] {
45 const out: string[] = []
46 if (step.test) out.push(testSummary(step.test))
47 else if (step.result?.denied) out.push('⊘ denied')
48 else if (step.result?.isError) out.push('✗ error')
49 if (step.kind === 'baseline') out.push(step.snapshot ? `snapshot ${step.snapshot.commit.slice(0, 7)} · ${step.snapshot.filesTotal} file(s)` : 'no snapshot')
50 else if (step.snapshot?.changed) {
51 // A branch restore changed nothing along its path; how the work tree moved is in its input.
52 const workTree = step.kind === 'restore' && Array.isArray(step.input.workTree) ? (step.input.workTree as FileChange[]) : undefined
53 const files = workTree ? `work tree: ${filesSummary(workTree, workTree.length)}` : filesSummary(step.snapshot.files, step.snapshot.filesTotal)
54 out.push(`snapshot ${step.snapshot.commit.slice(0, 7)} · ${files}${step.snapshot.concurrent ? ' (concurrent)' : ''}`)
55 }
56 if (step.rerunOf !== undefined) out.push(`rerun of ${circled(step.rerunOf)}`)
57 return out
58}
59
60export function avg(xs: readonly number[]): number | undefined {
61 return xs.length > 0 ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : undefined
62}
63
64export type TimelineOptions = { all?: boolean; branch?: string; limit?: number; off?: string; snapMs?: readonly number[]; recording?: import('./store.ts').RecordingStatus }
65
66export function renderTimeline(rec: Recorder, o: TimelineOptions = {}): string {
67 const branch = o.branch ?? rec.branch
68 const steps = o.all ? rec.all() : rec.path(branch)
69 const shown = o.all ? steps : steps.slice(-(o.limit ?? 30))
70 const count = rec.all().filter(s => s.kind !== 'baseline').length
71 const snaps = rec.all().filter(s => s.kind !== 'baseline' && s.snapshot?.changed).length
72 const mean = avg(o.snapMs ?? [])
73 const lines = [`Session ${rec.session.sessionId.slice(0, 8)} · branch ${branch} · ${count} step(s) · ${snaps} snapshot(s)${mean !== undefined ? `, avg ${mean}ms` : ''}`]
74 if (o.off) lines.push(`snapshots off: ${o.off}`)
75 if (o.recording) lines.push(`storage: ${o.recording.state}${o.recording.error !== undefined ? ` · ${o.recording.pending} file(s) awaiting retry; records retained in memory` : ''}`)
76 if (shown.every(s => s.kind === 'baseline')) return [...lines, '', 'No tool calls recorded yet.'].join('\n')
77 if (steps.length > shown.length) lines.push(`(${steps.length - shown.length} earlier step(s) hidden; /tm timeline --all)`)
78 lines.push('│')
79 let prevBranch = shown[0]!.branch
80 shown.forEach((step, i) => {
81 const last = i === shown.length - 1
82 if (!o.all && step.branch !== prevBranch) lines.push(`│ ⑂ branch ${step.branch} (from ${circled(step.parent)})`)
83 prevBranch = step.branch
84 const tag = o.all && step.branch !== 'main' ? ` [${step.branch}]` : ''
85 const agent = step.agentId ? ` (agent ${step.agentId.slice(0, 6)})` : ''
86 lines.push(`${last ? '└──' : '├──'} ${circled(step.n)} ${clip(label(step), 60)}${agent}${tag}`)
87 for (const note of notes(step)) lines.push(`${last ? ' ' : '│'} └── ${note}`)
88 })
89 return lines.map(redactSecrets).join('\n')
90}
91
92export function renderStep(step: Step): string {
93 const took = step.endedAt !== undefined ? ` · took ${step.endedAt - step.startedAt}ms` : ''
94 const lines = [
95 `${circled(step.n)} ${label(step)}`,
96 `kind ${step.kind} · branch ${step.branch} · parent ${circled(step.parent)} · turn ${step.turn}${step.agentId ? ` · agent ${step.agentId}` : ''}${took}`,
97 ]
98 const keys = Object.keys(step.input)
99 if (keys.length > 0) {
100 lines.push('input:')
101 for (const key of keys) {
102 const value = step.input[key]
103 lines.push(` ${key}: ${clip(typeof value === 'string' ? value : JSON.stringify(value) ?? '', 300)}`)
104 }
105 if (step.inputLossy) lines.push(' (stored input was cut or redacted)')
106 }
107 if (step.result) {
108 lines.push(`result: ${step.result.denied ? 'denied' : step.result.isError ? 'error' : 'ok'} · ${step.result.chars} chars`)
109 for (const line of step.result.head.split('\n').slice(0, 40)) lines.push(` │ ${line}`)
110 if (step.result.chars > step.result.head.length) lines.push(' │ …')
111 }
112 if (step.snapshot) {
113 const s = step.snapshot
114 lines.push(`snapshot: ${s.commit.slice(0, 12)}${s.changed ? '' : ' (no change)'} · ${s.ms}ms${s.concurrent ? ' · concurrent' : ''}`)
115 for (const f of s.files) lines.push(` ${f.status} ${f.path}`)
116 if (s.filesTotal > s.files.length && step.kind !== 'baseline') lines.push(` … +${s.filesTotal - s.files.length} more`)
117 }
118 if (step.test) {
119 lines.push(`test (${step.test.framework}): ${testSummary(step.test)}`)
120 for (const f of step.test.failures.slice(0, 10)) lines.push(` ✗ ${clip(redactSecrets(f.name), 200)}${f.file ? ` (${f.file}${f.line !== undefined ? `:${f.line}` : ''})` : ''}`)
121 }
122 return lines.map(redactSecrets).join('\n')
123}
124
125// `labels(n)` names a step for the fixed-by line (`⑤ Edit middleware.py`); without it, the bare number.
126export function renderWhy(w: Why, labels?: (n: number) => string | undefined): string {
127 const t = w.run.test!
128 const lines = [`Why did ${circled(w.run.n)} ${label(w.run)} ${t.ok ? 'pass' : 'fail'}? ${testSummary(t)} (${t.framework})`]
129 lines.push(`Compared with: ${w.base ? `${circled(w.base.n)} ${label(w.base)}${w.base.test ? ' (last passing run)' : ' (no passing run before it)'}` : 'the start of the session'}`)
130 if (!t.ok) {
131 if (w.suspects.length === 0) lines.push('Suspects: no file changed in between (look at the environment, test order, or external services)')
132 else {
133 lines.push('Suspects (files changed since then, ranked):')
134 w.suspects.forEach((s, i) => {
135 lines.push(` ${i + 1}. ${s.path} score ${s.score} — ${s.reasons.join('; ')}`)
136 lines.push(` changed by ${s.steps.map(circled).join(' ')}`)
137 })
138 }
139 if (t.failures.length > 0) lines.push(`Failures: ${t.failures.slice(0, 5).map(f => `${clip(redactSecrets(f.name), 200)}${f.line !== undefined ? ` (${f.file}:${f.line})` : ''}`).join(', ')}`)
140 }
141 if (w.fixedBy) {
142 const run = `${circled(w.fixedBy.run.n)} ${label(w.fixedBy.run)} ${testSummary(w.fixedBy.run.test!)}`
143 if (w.fixedBy.steps.length === 0) lines.push(`Passed later at ${run} with no file changes in between (flaky, or the environment changed)`)
144 else if (labels) lines.push(`Fixed by: ${w.fixedBy.steps.map(n => labels(n) ?? circled(n)).join(', ')} → ${run}`)
145 else lines.push(`Fixed by: ${w.fixedBy.steps.map(circled).join(', ')} (${w.fixedBy.files.join(', ')}) → ${run}`)
146 }
147 if (w.improved) lines.push(`Improved: ${w.improved.from} → ${w.improved.to} failed at ${circled(w.improved.run.n)}`)
148 lines.push('(inferred from failure-output mentions, recency and change counts — not proof)')
149 return lines.map(redactSecrets).join('\n')
150}
151
152export function renderCompare(nameA: string, nameB: string, c: Compared, stat?: string): string {
153 const step = (s: Step | undefined): string => (s ? `${circled(s.n)} ${label(s)}` : '(end)')
154 const lines = [`Compare ${nameA} ↔ ${nameB}`, `Shared start: ${c.common} step(s)`, `Diverged at: ${nameA} ${step(c.divergeA)} · ${nameB} ${step(c.divergeB)}`]
155 lines.push(`Only on ${nameA} (${c.onlyA.length}): ${c.onlyA.slice(0, 20).map(step).join(', ') || '—'}`)
156 lines.push(`Only on ${nameB} (${c.onlyB.length}): ${c.onlyB.slice(0, 20).map(step).join(', ') || '—'}`)
157 lines.push(`Matched after the divergence: ${c.matched}`)
158 const test = (s: Step | undefined): string => (s?.test ? testSummary(s.test) : 'no test run')
159 lines.push(`Last test: ${nameA} ${test(c.lastTestA)} · ${nameB} ${test(c.lastTestB)}`)
160 if (stat !== undefined) lines.push(`Files (${nameA} head → ${nameB} head):`, stat.trimEnd() || ' (identical)')
161 return lines.map(redactSecrets).join('\n')
162}
163
164export function renderRerun(step: Step, c: OutputComparison, where: string): string {
165 const lines = [`Rerun of ${circled(step.n)} ${label(step)} (${where})`]
166 if (c.identical) lines.push('Result: identical')
167 else {
168 lines.push(`Result: changed — +${c.addedCount} −${c.removedCount} line(s) (first 2 KiB compared)`)
169 for (const line of c.removed) lines.push(` − ${clip(line, 160)}`)
170 for (const line of c.added) lines.push(` + ${clip(line, 160)}`)
171 }
172 if (c.was && c.now) lines.push(`Test: ${testSummary(c.was)} → ${testSummary(c.now)}`)
173 return lines.map(redactSecrets).join('\n')
174}
175
176// `/tm rerun n --isolated` without `--yes`: the command first, then where it would run and why it may not.
177// `before` is the step whose snapshot the copy would hold; `blocked` says why it cannot run now.
178export function renderIsolatedPlan(step: Step, command: string, before: Step | undefined, blocked?: string): string {
179 const copy = before?.snapshot ? `a throwaway copy of ${circled(before.n)}'s snapshot (${before.snapshot.commit.slice(0, 7)})` : 'a throwaway copy of the snapshot before it'
180 const lines = [
181 `$ ${redactSecrets(command)}`,
182 `Dry run of ${circled(step.n)} ${label(step)}: nothing ran. With --yes this runs through \`sh -lc\` in ${copy}, without a permission prompt.`,
183 'Snapshot copy only, not a sandbox: environment variables, your home directory, absolute paths and external services remain accessible. Ignored files such as dependencies are not in the copy; project changes are recorded as external steps.',
184 ]
185 lines.push(blocked !== undefined ? `It would not run now: ${blocked}` : `Run /tm rerun ${step.n} --isolated --yes to run it.`)
186 return lines.map(redactSecrets).join('\n')
187}
188
189// `step` is the step asked for; `target` the step whose snapshot is used (the same, or the nearest earlier one).
190export function renderRestorePlan(step: Step, target: Step, files: readonly FileChange[], applied: boolean, branch?: string, told = true): string {
191 const n = step.n
192 const what = `${circled(n)} ${label(step)}`
193 const head = `snapshot ${target.snapshot?.commit.slice(0, 7) ?? '?'}${target.n !== n ? ` from ${circled(target.n)}` : ''}`
194 const lines = applied
195 ? [`Restored to ${what} (${head}): ${files.length} file(s) changed.`]
196 : [`Restore to ${what} (${head}): ${files.length} file(s) would change.`]
197 for (const f of files.slice(0, 50)) lines.push(` ${f.status} ${f.path}`)
198 if (files.length > 50) lines.push(` … +${files.length - 50} more`)
199 if (applied) {
200 lines.push(`The state before the restore was snapshotted, so this can be undone. ${told ? 'The model was told the files changed.' : 'The note to the model could not be added; tell it the files changed before it edits them.'}`)
201 if (branch !== undefined) lines.push(`New steps go to branch '${branch}'. The conversation itself was not rewound.`)
202 } else {
203 lines.push(branch !== undefined ? `Run /tm branch ${n} ${branch} --yes to apply.` : `Run /tm restore ${n} --yes to apply (the current state is snapshotted first, so it can be undone).`)
204 }
205 return lines.map(redactSecrets).join('\n')
206}
207
208export function renderSessions(rows: readonly { id: string; startedAt: number; current: boolean }[]): string {
209 if (rows.length === 0) return 'No recorded sessions for this project.'
210 const sorted = [...rows].sort((a, b) => b.startedAt - a.startedAt)
211 const started = (at: number): string => {
212 const date = new Date(at)
213 return Number.isFinite(at) && Number.isFinite(date.getTime()) ? date.toISOString() : '?'
214 }
215 return ['Recorded sessions (newest first):', ...sorted.map(r => ` ${r.id}${r.current ? ' (this session)' : ''} · started ${started(r.startedAt)}`)].join('\n')
216}
217
218export function statusLine(rec: Recorder, snapMs: readonly number[], off?: string, recording?: import('./store.ts').RecordingStatus): string {
219 const steps = rec.all().filter(s => s.kind !== 'baseline')
220 const last = steps.at(-1)?.n ?? 0
221 const storage = recording ? ` · ${recording.state}` : ''
222 if (off) return `tm ${circled(last)} · snapshots off${storage}`
223 const snaps = steps.filter(s => s.snapshot?.changed).length
224 const mean = avg(snapMs)
225 return `tm ${circled(last)} · ${snaps} snaps${mean !== undefined ? ` · ${mean}ms` : ''}${storage}`
226}
227hooks/core/store.ts 112 lines1import { hash32 } from './hash.ts'
2import type { Rec } from './types.ts'
3
4// One timeline file stays under this. `$.fs.write` rewrites a file whole, so each record's write costs
5// up to a chunk: small chunks keep a long session's writes linear in its records.
6export const CHUNK_BYTES = 256 * 1024
7
8// `<last path segment>-<8 hex of the whole path>`: readable, and distinct for same-named folders.
9export function projectKey(root: string): string {
10 const trimmed = root.replace(/\/+$/, '') || '/'
11 const last = trimmed.split('/').filter(Boolean).at(-1) ?? 'root'
12 return `${last.replace(/[^\w.-]/g, '_')}-${hash32(trimmed).toString(16).padStart(8, '0')}`
13}
14
15export function chunkName(loadId: string, index: number): string {
16 return `${loadId}-${String(index).padStart(4, '0')}.jsonl`
17}
18
19const encoder = new TextEncoder()
20
21// One module load's records. The host has no append, so each add returns the current chunk's whole
22// body to write; past `max` bytes the next record starts a new chunk.
23export class ChunkWriter {
24 private lines: string[] = []
25 private bytes = 0
26 private index = 1
27
28 constructor(
29 readonly loadId: string,
30 readonly max = CHUNK_BYTES,
31 ) {}
32
33 add(rec: Rec): { name: string; body: string } {
34 const line = JSON.stringify(rec)
35 const size = encoder.encode(line).length + 1
36 if (this.lines.length > 0 && this.bytes + size > this.max) {
37 this.index += 1
38 this.lines = []
39 this.bytes = 0
40 }
41 this.lines.push(line)
42 this.bytes += size
43 return { name: chunkName(this.loadId, this.index), body: `${this.lines.join('\n')}\n` }
44 }
45}
46
47export type RecordingStatus = { state: 'recording' | 'degraded' | 'not persisted'; pending: number; error?: string }
48
49// Keep the latest full body of every dirty file, including rolled-over chunks. Failed writes
50// remain dirty until acknowledged; concurrent flushes cannot overwrite a newer body with an older one.
51export class DurableWriter {
52 private readonly pending = new Map<string, string>()
53 private writing = Promise.resolve()
54 private saved = false
55 private error: string | undefined
56
57 constructor(private readonly chunks: ChunkWriter) {}
58
59 queueFile(name: string, body: string): void {
60 this.pending.set(name, body)
61 }
62
63 add(records: readonly Rec[]): void {
64 for (const rec of records) {
65 const { name, body } = this.chunks.add(rec)
66 this.queueFile(name, body)
67 }
68 }
69
70 get status(): RecordingStatus {
71 return {
72 state: this.error === undefined ? 'recording' : this.saved ? 'degraded' : 'not persisted',
73 pending: this.pending.size,
74 ...(this.error !== undefined ? { error: this.error } : {}),
75 }
76 }
77
78 flush(write: (name: string, body: string) => Promise<void>): Promise<void> {
79 this.writing = this.writing.then(async () => {
80 try {
81 for (const [name, body] of this.pending) {
82 await write(name, body)
83 if (name.endsWith('.jsonl')) this.saved = true
84 if (this.pending.get(name) === body) this.pending.delete(name)
85 }
86 this.error = undefined
87 } catch (error) {
88 this.error = error instanceof Error ? error.message : String(error)
89 }
90 })
91 return this.writing
92 }
93}
94
95// A session's chunks in write order (load id, then chunk number); lines that are not records are skipped.
96export function parseChunks(files: readonly { name: string; text: string }[]): Rec[] {
97 const out: Rec[] = []
98 const chunks = files.filter(f => f.name.endsWith('.jsonl')).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
99 for (const file of chunks) {
100 for (const line of file.text.split('\n')) {
101 if (line.trim() === '') continue
102 try {
103 const rec = JSON.parse(line) as Rec
104 if (rec && typeof rec === 'object' && typeof rec.type === 'string') out.push(rec)
105 } catch {
106 // a torn or foreign line
107 }
108 }
109 }
110 return out
111}
112hooks/core/redact.ts 31 lines1// Canonical source; run node scripts/sync-redactors.mjs after editing.
2// Best-effort removal of obvious secrets from command lines before they are exported.
3const VALUE = String.raw`("(?:\\[\s\S]|[^"\\])*"|'[^']*'|(?:\\[\s\S]|[^\s'"\\])+)`
4const HEADER = String.raw`(?:authorization|[a-z0-9][a-z0-9-]*-(?:key|token|secret))`
5// Only match user:password arguments, leaving username-only options (e.g. mysql -u) alone.
6const CREDENTIAL = String.raw`("(?:\\[\s\S]|[^":\\])*:(?:\\[\s\S]|[^"\\])*"|'[^':]*:[^']*'|[^\s'":]*:[^\s'"]*)`
7const RULES: [RegExp, string][] = [
8 // Shell-quoted headers must be handled before key/value rules: an escaped quote is
9 // part of the secret, while the opposite quote is ordinary data inside this shell word.
10 [new RegExp(String.raw`("` + HEADER + String.raw`\s*:\s*)(?:\\[\s\S]|[^"\\])*"`, 'gi'), '$1[REDACTED]"'],
11 [new RegExp(String.raw`('` + HEADER + String.raw`\s*:\s*)[^']*'`, 'gi'), "$1[REDACTED]'"],
12 [new RegExp(String.raw`(\b` + HEADER + String.raw`\s*:\s*)(?:\\[\s\S]|[^'"\r\n\\])+`, 'gi'), '$1[REDACTED]'],
13 [new RegExp(String.raw`((?:["']?)(?:password|passwd|pwd|token|(?:access|refresh|id|session|auth)[_-]?token|(?:api|access|secret|private)[_-]?key|client[_-]?secret|secret|authorization|credentials?|cookies?)(?:["']?)\s*:\s*)` + VALUE, 'gi'), '$1[REDACTED]'],
14 [/(\bbearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[REDACTED]'],
15 [new RegExp(String.raw`(--(?:password|token|api-key|secret)(?:=|\s+))` + VALUE, 'gi'), '$1[REDACTED]'],
16 [/([a-z][a-z0-9+.-]*:\/\/[^\s/:@'"]*:)[^\s/@'"]*@/gi, '$1[REDACTED]@'],
17 [new RegExp(String.raw`(\s(?:--user(?:=|\s+)|-u\s*))` + CREDENTIAL, 'g'), '$1[REDACTED]'],
18 [/(\bmysql\w*\b[^\n;&|]*?\s-p)(?=\S)("[^"]*"|'[^']*'|\S+)/g, '$1[REDACTED]'],
19 [new RegExp(String.raw`\b([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)=` + VALUE, 'gi'), '$1=[REDACTED]'],
20]
21
22export function redactSecrets(text: string): string {
23 return RULES.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text)
24}
25
26// Match complete key names, including common camelCase/underscore/hyphen spellings.
27// Do not match ordinary names such as token_count or keyboard.
28export function isSecretKey(key: string): boolean {
29 return /^(?:password|passwd|pwd|authorization|proxyauthorization|apikey|accesskey|secretkey|privatekey|clientsecret|secret|token|accesstoken|refreshtoken|idtoken|sessiontoken|authtoken|credential|credentials|cookie|setcookie)$/i.test(key.replace(/[_\s-]/g, '')) || /^[a-z0-9][a-z0-9-]*-(?:key|token|secret)$/i.test(key)
30}
31hooks/core/testparse.ts 170 lines1import type { TestFailure, TestRun } from './types.ts'
2import { contentHash } from './hash.ts'
3import { redactSecrets } from './redact.ts'
4
5export function testIdentity(command: string, cwd: string): NonNullable<TestRun['identity']> {
6 let normalized = ''
7 let quote = ''
8 // Multiline shell/heredoc whitespace can be data, so preserve it exactly.
9 if (command.includes('\n') || command.includes('$') || command.includes('`')) return { commandHash: contentHash(command), cwd }
10 const text = command.trim()
11 for (let i = 0; i < text.length; i += 1) {
12 const ch = text[i]!
13 if (ch === '\\' && quote !== "'") {
14 normalized += ch + (text[++i] ?? '')
15 } else if (quote) {
16 normalized += ch
17 if (ch === quote) quote = ''
18 } else if (ch === "'" || ch === '"') {
19 quote = ch
20 normalized += ch
21 } else if (ch === ' ' || ch === '\t') {
22 if (!normalized.endsWith(' ')) normalized += ' '
23 } else normalized += ch
24 }
25 return { commandHash: contentHash(normalized), cwd }
26}
27
28const MAX_FAILURES = 50
29const MAX_LOCATIONS = 100
30const MAX_PATHS = 200
31const SCAN_MAX = 128 * 1024
32const EXT = String.raw`(?:py|[cm]?[jt]sx?|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|scala|exs?)`
33const FILE = String.raw`(?<![\w.@/-])(?:\.{0,2}\/)?(?:[\w.@-]+\/)*[\w.@-]+\.${EXT}`
34const PATH_TOKEN = new RegExp(String.raw`${FILE}\b`, 'g')
35const LOCATION = new RegExp(String.raw`(${FILE})(?::|", line )(\d+)`, 'g')
36const VENDOR = /node_modules|site-packages|dist-packages|\/lib\/python|<frozen|^internal\/|\/rustc\//
37
38type Counts = Pick<TestRun, 'passed' | 'failed' | 'errors' | 'skipped'>
39type Found = Counts & { framework: string; failures: TestFailure[] }
40
41function count(text: string, re: RegExp): Counts {
42 const c: Counts = {}
43 for (const m of text.matchAll(re)) {
44 const n = Number(m[1])
45 const word = (m[2] ?? '').toLowerCase()
46 if (word.startsWith('pass')) c.passed = (c.passed ?? 0) + n
47 else if (word.startsWith('fail')) c.failed = (c.failed ?? 0) + n
48 else if (word.startsWith('error')) c.errors = (c.errors ?? 0) + n
49 else if (word.startsWith('skip') || word.startsWith('ignored')) c.skipped = (c.skipped ?? 0) + n
50 }
51 return c
52}
53
54function parseCargo(text: string): Found | undefined {
55 const results = [...text.matchAll(/test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed/g)]
56 if (results.length === 0) return undefined
57 return {
58 framework: 'cargo',
59 passed: results.reduce((s, m) => s + Number(m[1]), 0),
60 failed: results.reduce((s, m) => s + Number(m[2]), 0),
61 failures: [...text.matchAll(/^---- (\S+) stdout ----$/gm)].map(m => ({ name: m[1]! })),
62 }
63}
64
65function parsePytest(text: string): Found | undefined {
66 const summary = /(?:^|=+ )((?:\d+ (?:failed|passed|skipped|errors?|xfailed|xpassed|deselected|warnings?|rerun)(?:, )?)+) in [\d.]+s/m.exec(text)
67 const lines = [...text.matchAll(/^(?:FAILED|ERROR) (\S+?)(?:::(\S+))?(?: - .*)?$/gm)]
68 if (!summary && lines.length === 0) return undefined
69 return {
70 framework: 'pytest',
71 ...count(summary?.[1] ?? '', /(\d+) (passed|failed|errors?|skipped)\b/g),
72 failures: lines.map(m => ({ name: m[2] ? `${m[1]}::${m[2]}` : m[1]!, file: m[1]! })),
73 }
74}
75
76function parseJest(text: string): Found | undefined {
77 const jest = /^Tests:\s+(.+)$/m.exec(text)
78 const vitest = jest ? undefined : /^[ \t]*Tests\s+(.+?)\s*(?:\(\d+\))?\s*$/m.exec(text)
79 const line = jest?.[1] ?? vitest?.[1]
80 if (line === undefined) return undefined
81 const failures: TestFailure[] = []
82 for (const m of text.matchAll(/^[ \t]*● (.+?)\s*$/gm)) if (!/^Console\b/.test(m[1]!)) failures.push({ name: m[1]! })
83 for (const m of text.matchAll(/^[ \t]*(?:FAIL|×|✗)\s+(\S+\.[cm]?[jt]sx?)\s+>\s+(.+?)\s*$/gm)) failures.push({ name: `${m[1]} > ${m[2]}`, file: m[1]! })
84 return { framework: jest ? 'jest' : 'vitest', ...count(line, /(\d+) (passed|failed|skipped)\b/g), failures }
85}
86
87function parseGo(text: string): Found | undefined {
88 const fails = [...text.matchAll(/^[ \t]*--- FAIL: (\S+)/gm)]
89 const passes = [...text.matchAll(/^[ \t]*--- PASS: /gm)]
90 if (fails.length === 0 && passes.length === 0 && !/^(?:ok|FAIL)\s+\S+\s+[\d.]+s/m.test(text)) return undefined
91 return {
92 framework: 'go',
93 failed: fails.length,
94 ...(passes.length > 0 ? { passed: passes.length } : {}),
95 failures: fails.map(m => ({ name: m[1]! })),
96 }
97}
98
99function parseNodeTest(text: string): Found | undefined {
100 const pass = /^# pass (\d+)$/m.exec(text)
101 const fail = /^# fail (\d+)$/m.exec(text)
102 if (!pass && !fail) return undefined
103 return {
104 framework: 'node:test',
105 ...(pass ? { passed: Number(pass[1]) } : {}),
106 ...(fail ? { failed: Number(fail[1]) } : {}),
107 failures: [...text.matchAll(/^not ok \d+ - (.+)$/gm)].map(m => ({ name: m[1]!.trim() })),
108 }
109}
110
111export function sameFile(a: string, b: string): boolean {
112 const x = a.replace(/^\.\//, '')
113 const y = b.replace(/^\.\//, '')
114 return x === y || x.endsWith(`/${y}`) || y.endsWith(`/${x}`)
115}
116
117// Locations and paths come from the first and last 128 KiB only: a hook must not stall on a huge dump.
118function scanWindow(text: string): string {
119 return text.length <= 2 * SCAN_MAX ? text : `${text.slice(0, SCAN_MAX)}\n${text.slice(-SCAN_MAX)}`
120}
121
122function locationsOf(text: string): { file: string; line: number }[] {
123 const out: { file: string; line: number }[] = []
124 const seen = new Set<string>()
125 for (const m of text.matchAll(LOCATION)) {
126 const file = m[1]!
127 const key = `${file}:${m[2]}`
128 if (VENDOR.test(file) || seen.has(key)) continue
129 seen.add(key)
130 out.push({ file, line: Number(m[2]) })
131 if (out.length >= MAX_LOCATIONS) break
132 }
133 return out
134}
135
136function pathsOf(text: string): string[] {
137 const out = new Set<string>()
138 for (const m of text.matchAll(PATH_TOKEN)) {
139 if (VENDOR.test(m[0])) continue
140 out.add(m[0])
141 if (out.size >= MAX_PATHS) break
142 }
143 return [...out]
144}
145
146// What a test command's output says: framework, counts, failing tests and where they point.
147export function parseTestOutput(text: string, isError: boolean, invocation?: { command: string; cwd: string }): TestRun {
148 const found = parseCargo(text) ?? parsePytest(text) ?? parseJest(text) ?? parseGo(text) ?? parseNodeTest(text)
149 const window = scanWindow(text)
150 const locations = locationsOf(window)
151 const failures = (found?.failures ?? []).slice(0, MAX_FAILURES).map(f => ({ ...f, name: redactSecrets(f.name) }))
152 for (const loc of locations) {
153 const failure = failures.find(f => f.file !== undefined && f.line === undefined && sameFile(f.file, loc.file))
154 if (failure) failure.line = loc.line
155 }
156 const run: TestRun = {
157 framework: found?.framework ?? 'unknown',
158 ...(invocation ? { identity: testIdentity(invocation.command, invocation.cwd) } : {}),
159 ok: !isError && (found?.failed ?? 0) === 0 && (found?.errors ?? 0) === 0,
160 failures,
161 locations,
162 paths: pathsOf(window),
163 }
164 if (found?.passed !== undefined) run.passed = found.passed
165 if (found?.failed !== undefined) run.failed = found.failed
166 if (found?.errors !== undefined) run.errors = found.errors
167 if (found?.skipped !== undefined) run.skipped = found.skipped
168 return run
169}
170hooks/core/timeline.ts 232 lines1import { contentHash } from './hash.ts'
2import { type BranchRec, type FileChange, MAX_FILES, type ModelRec, type Rec, type SessionRec, type SnapshotRef, type Step, type StepKind, type StepResult, type TestRun, type TurnRec } from './types.ts'
3
4export type BeginArgs = {
5 id: string
6 tool: string
7 kind?: StepKind
8 agentId?: string
9 input?: Record<string, unknown>
10 inputLossy?: boolean
11 target?: string
12 at: number
13 rerunOf?: number
14}
15
16// The session's steps as a tree: each step points at its parent, each branch at the step it grew from.
17export class Recorder {
18 version = 0
19 branch = 'main'
20 turn = 0
21 readonly turns: TurnRec[] = []
22 readonly models: ModelRec[] = []
23 readonly branches = new Map<string, BranchRec>([['main', { name: 'main', fromStep: 0, at: 0 }]])
24 private steps = new Map<number, Step>()
25 private order: number[] = []
26 private nextN = 1
27 private out: Rec[] = []
28
29 constructor(readonly session: SessionRec) {
30 this.out.push({ type: 'session', ...session })
31 }
32
33 // Records not yet written out, oldest first; each is handed out once. A step begun and finished
34 // since the last drain goes out once, as finished.
35 drain(): Rec[] {
36 const out = this.out
37 this.out = []
38 const last = new Map<number, number>()
39 out.forEach((r, i) => {
40 if (r.type === 'step') last.set(r.n, i)
41 })
42 return out.filter((r, i) => r.type !== 'step' || last.get(r.n) === i)
43 }
44
45 get size(): number {
46 return this.order.length
47 }
48
49 get(n: number): Step | undefined {
50 return this.steps.get(n)
51 }
52
53 // By number: the baseline, taken when git is first needed, comes first.
54 all(): Step[] {
55 return [...this.order].sort((a, b) => a - b).map(n => this.steps.get(n)!)
56 }
57
58 baseline(at: number, snapshot?: SnapshotRef): Step {
59 const step: Step = { n: 0, id: 'baseline', turn: this.turn, branch: 'main', parent: 0, tool: 'baseline', kind: 'baseline', input: {}, startedAt: at, endedAt: at }
60 if (snapshot) step.snapshot = snapshot
61 this.put(step)
62 this.emit(step)
63 return step
64 }
65
66 startTurn(at: number, text: string, keepHead = false): TurnRec {
67 this.turn += 1
68 const rec: TurnRec = { n: this.turn, at, prompt: { chars: text.length, hash: contentHash(text) } }
69 if (keepHead) rec.prompt.head = text.slice(0, 200)
70 this.turns.push(rec)
71 this.out.push({ type: 'turn', ...rec })
72 this.version += 1
73 return rec
74 }
75
76 begin(a: BeginArgs): Step {
77 const step: Step = {
78 n: this.nextN,
79 id: a.id,
80 turn: this.turn,
81 branch: this.branch,
82 parent: this.head(this.branch),
83 tool: a.tool,
84 kind: a.kind ?? 'tool',
85 input: a.input ?? {},
86 startedAt: a.at,
87 }
88 this.nextN += 1
89 if (a.agentId !== undefined) step.agentId = a.agentId
90 if (a.inputLossy) step.inputLossy = true
91 if (a.target !== undefined) step.target = a.target
92 if (a.rerunOf !== undefined) step.rerunOf = a.rerunOf
93 this.put(step)
94 // Written out now, not only when finished: a later step may name it as its parent, and a reload
95 // before it finishes would otherwise cut that step's path short.
96 this.emit(step)
97 return step
98 }
99
100 // The step is complete: written out again, with whatever it gathered.
101 finish(step: Step, at: number, extra: { result?: StepResult; snapshot?: SnapshotRef; test?: TestRun } = {}): void {
102 step.endedAt = at
103 if (extra.result) step.result = extra.result
104 if (extra.snapshot) step.snapshot = extra.snapshot
105 if (extra.test) step.test = extra.test
106 this.emit(step)
107 }
108
109 // A restore applied as a step: `commit` has step `to`'s tree, `files` is how the work tree changed
110 // (from `from`). On a new branch the step grows from `to`, so its tree equals `to`'s and it changed
111 // nothing along the branch's path: the work-tree list is kept in its input, for display only, and the
112 // abandoned branch's files never reach causality. On the same branch the files are its changes.
113 restoreStep(a: { at: number; to: number; from: string; commit: string; files: readonly FileChange[]; branch?: string }): Step {
114 if (a.branch !== undefined) this.fork(a.branch, a.to, a.at)
115 const shown = a.files.slice(0, MAX_FILES)
116 const onBranch = a.branch !== undefined
117 const step = this.begin({ id: `restore-${a.at}`, tool: 'restore', kind: 'restore', input: { to: a.to, from: a.from, ...(onBranch ? { workTree: shown } : {}) }, target: `step ${a.to}`, at: a.at })
118 this.finish(step, a.at, { snapshot: { commit: a.commit, changed: a.files.length > 0, files: onBranch ? [] : shown, filesTotal: onBranch ? 0 : a.files.length, ms: 0 } })
119 return step
120 }
121
122 addModel(rec: ModelRec): void {
123 this.models.push(rec)
124 this.out.push({ type: 'model', ...rec })
125 this.version += 1
126 }
127
128 // The newest step on `branch`, else the step the branch grew from.
129 head(branch: string): number {
130 for (let i = this.order.length - 1; i >= 0; i -= 1) {
131 const step = this.steps.get(this.order[i]!)!
132 if (step.branch === branch && step.kind !== 'baseline') return step.n
133 }
134 return this.branches.get(branch)?.fromStep ?? 0
135 }
136
137 fork(name: string, fromStep: number, at: number): BranchRec {
138 if (this.branches.has(name)) throw new Error(`branch '${name}' already exists`)
139 if (!this.steps.has(fromStep)) throw new Error(`no step ${fromStep}`)
140 const rec: BranchRec = { name, fromStep, at }
141 this.branches.set(name, rec)
142 this.branch = name
143 this.out.push({ type: 'branch', ...rec })
144 this.version += 1
145 return rec
146 }
147
148 nextBranchName(): string {
149 let k = 1
150 while (this.branches.has(`b${k}`)) k += 1
151 return `b${k}`
152 }
153
154 // From the start to the head of `branch` along parents: a branch includes the history it grew from.
155 path(branch = this.branch): Step[] {
156 const out: Step[] = []
157 const seen = new Set<number>()
158 let n: number | undefined = this.head(branch)
159 while (n !== undefined && !seen.has(n)) {
160 seen.add(n)
161 const step = this.steps.get(n)
162 if (!step) break
163 out.push(step)
164 n = step.kind === 'baseline' || step.n === step.parent ? undefined : step.parent
165 }
166 return out.reverse()
167 }
168
169 // The nearest step at or before `n`, along its own ancestry, that holds a snapshot.
170 snapshotAt(n: number): Step | undefined {
171 const seen = new Set<number>()
172 let step = this.steps.get(n)
173 while (step && !seen.has(step.n)) {
174 seen.add(step.n)
175 if (step.snapshot) return step
176 if (step.kind === 'baseline') return undefined
177 step = this.steps.get(step.parent)
178 }
179 return undefined
180 }
181
182 lastSnapshot(branch = this.branch): Step | undefined {
183 return this.snapshotAt(this.head(branch))
184 }
185
186 // A recorder rebuilt from its own records (a reload); later records of a step replace earlier ones.
187 static fromRecords(recs: readonly Rec[]): Recorder | undefined {
188 const first = recs.find(r => r.type === 'session')
189 if (!first || first.type !== 'session') return undefined
190 const { type: _type, ...session } = first
191 const r = new Recorder(session)
192 r.out = []
193 for (const rec of recs) {
194 if (rec.type === 'step') {
195 const { type: _t, ...step } = rec
196 r.put(step)
197 r.nextN = Math.max(r.nextN, step.n + 1)
198 } else if (rec.type === 'turn') {
199 const { type: _t, ...turn } = rec
200 r.turns.push(turn)
201 r.turn = Math.max(r.turn, turn.n)
202 } else if (rec.type === 'model') {
203 const { type: _t, ...model } = rec
204 r.models.push(model)
205 } else if (rec.type === 'branch') {
206 const { type: _t, ...branch } = rec
207 r.branches.set(branch.name, branch)
208 r.branch = branch.name
209 }
210 }
211 r.order.sort((a, b) => a - b)
212 return r
213 }
214
215 private put(step: Step): void {
216 if (!this.steps.has(step.n)) this.order.push(step.n)
217 this.steps.set(step.n, step)
218 this.version += 1
219 }
220
221 private emit(step: Step): void {
222 this.out.push({ type: 'step', ...step })
223 this.version += 1
224 }
225}
226
227// Branch refs are files: on a case-insensitive disk `Main` and `main` are one ref.
228export function branchTaken(rec: Pick<Recorder, 'branches'>, name: string): boolean {
229 const lower = name.toLowerCase()
230 return [...rec.branches.keys()].some(b => b.toLowerCase() === lower)
231}
232hooks/core/tools.ts 131 lines1import { classifyCommand } from './classify.ts'
2import { contentHash } from './hash.ts'
3import { isSecretKey, redactSecrets } from './redact.ts'
4import type { StepResult } from './types.ts'
5
6export const MUTATING_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash'])
7export const READ_ONLY_TOOLS = new Set([
8 'Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task', 'Agent', 'ToolSearch', 'Skill',
9 'SendMessage', 'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode', 'BashOutput', 'KillShell',
10 'ListMcpResourcesTool', 'ReadMcpResourceTool', 'NotebookRead',
11])
12// Not snapshotted (whatever they change arrives as steps of their own), but running one again acts:
13// a subagent or skill edits files and runs commands, a message reaches an agent, a shell is killed.
14export const ACTING_TOOLS = new Set(['Task', 'Agent', 'Skill', 'SendMessage', 'KillShell'])
15export const INPUT_STRING_MAX = 4096
16export const RESULT_HEAD_MAX = 2048
17const ENGINE_KEYS = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
18
19const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
20
21// Unknown and MCP tools count as mutating: a snapshot that finds nothing changed costs one tree hash.
22// Every Bash counts unless asked otherwise: a "search" command can still write through a redirect.
23export function isMutating(tool: string, input: Record<string, unknown>, skipSearchBash = false): boolean {
24 if (tool === 'Bash') return !skipSearchBash || classifyCommand(str(input.command) ?? '') !== 'search'
25 if (MUTATING_TOOLS.has(tool)) return true
26 return !READ_ONLY_TOOLS.has(tool)
27}
28
29export function relPath(root: string, path: string): string {
30 const base = root.replace(/\/+$/, '')
31 return base !== '' && path.startsWith(`${base}/`) ? path.slice(base.length + 1) : path
32}
33
34// A free-text target: secrets masked, whitespace collapsed to one line, cut to 120 chars.
35const oneLine = (text: string | undefined): string | undefined => (text ? redactSecrets(text).replace(/\s+/g, ' ').trim().slice(0, 120) : undefined)
36
37export function targetOf(tool: string, input: Record<string, unknown>, root: string): string | undefined {
38 switch (tool) {
39 case 'Read':
40 case 'Edit':
41 case 'MultiEdit':
42 case 'Write': {
43 const path = str(input.file_path)
44 return path ? relPath(root, path) : undefined
45 }
46 case 'NotebookEdit': {
47 const path = str(input.notebook_path)
48 return path ? relPath(root, path) : undefined
49 }
50 case 'Bash': {
51 return oneLine(str(input.command))
52 }
53 case 'Grep':
54 case 'Glob':
55 return oneLine(str(input.pattern))
56 case 'WebFetch':
57 return oneLine(str(input.url))
58 case 'WebSearch':
59 return oneLine(str(input.query))
60 case 'Agent':
61 case 'Task':
62 return oneLine(str(input.subagent_type) ?? str(input.description))
63 default:
64 return undefined
65 }
66}
67
68// The input as stored: engine keys dropped, secrets masked, long strings cut (the snapshot holds the files).
69export function summarizeInput(input: Record<string, unknown>): { input: Record<string, unknown>; lossy: boolean } {
70 let lossy = false
71 const field = (key: string, value: unknown, depth: number): unknown => {
72 if (isSecretKey(key)) {
73 lossy = true
74 return '[REDACTED]'
75 }
76 return walk(value, depth)
77 }
78 const walk = (value: unknown, depth: number): unknown => {
79 if (typeof value === 'string') {
80 const masked = redactSecrets(value)
81 if (masked !== value) lossy = true
82 if (masked.length <= INPUT_STRING_MAX) return masked
83 lossy = true
84 return `${masked.slice(0, INPUT_STRING_MAX)}…(+${masked.length - INPUT_STRING_MAX} chars, #${contentHash(value)})`
85 }
86 if (Array.isArray(value)) {
87 if (depth > 4) {
88 lossy = true
89 return '[…]'
90 }
91 return value.map(v => walk(v, depth + 1))
92 }
93 if (value && typeof value === 'object') {
94 if (depth > 4) {
95 lossy = true
96 return '{…}'
97 }
98 return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, field(k, v, depth + 1)]))
99 }
100 return value
101 }
102 const out: Record<string, unknown> = {}
103 for (const [key, value] of Object.entries(input)) if (!ENGINE_KEYS.has(key)) out[key] = field(key, value, 0)
104 return { input: out, lossy }
105}
106
107// The input exactly as the tool got it, engine keys aside: what a rerun passes back.
108export function callInput(input: Record<string, unknown>): Record<string, unknown> {
109 return Object.fromEntries(Object.entries(input).filter(([key]) => !ENGINE_KEYS.has(key)))
110}
111
112export function resultText(answer: unknown): string {
113 const a = (answer ?? {}) as Record<string, unknown>
114 if (typeof a.text === 'string') return a.text
115 if (typeof a.deny === 'string') return a.deny
116 return ''
117}
118
119export function summarizeResult(answer: unknown): StepResult {
120 const a = (answer ?? {}) as Record<string, unknown>
121 const denied = typeof a.deny === 'string'
122 const text = resultText(answer)
123 return {
124 isError: denied || a.isError === true,
125 denied,
126 chars: text.length,
127 hash: contentHash(text),
128 head: redactSecrets(text).slice(0, RESULT_HEAD_MAX),
129 }
130}
131hooks/core/types.ts 83 lines1export type FileChange = { status: string; path: string }
2
3// Changed-file lists keep this many entries, plus a total.
4export const MAX_FILES = 200
5
6export type SnapshotRef = {
7 commit: string
8 // False when the tree matched the previous snapshot: `commit` is that snapshot.
9 changed: boolean
10 // The first 200 changed files; `filesTotal` counts all of them.
11 files: FileChange[]
12 filesTotal: number
13 ms: number
14 // Another mutating tool was still running when this snapshot was taken.
15 concurrent?: boolean
16}
17
18export type TestFailure = { name: string; file?: string; line?: number }
19
20export type TestRun = {
21 framework: string
22 // Missing in legacy logs or when the full invocation was not observed.
23 identity?: { commandHash: string; cwd: string }
24 ok: boolean
25 passed?: number
26 failed?: number
27 errors?: number
28 skipped?: number
29 failures: TestFailure[]
30 locations: { file: string; line: number }[]
31 // Path-like tokens anywhere in the whole output, for causality.
32 paths: string[]
33}
34
35export type StepKind = 'baseline' | 'tool' | 'external' | 'restore'
36
37export type StepResult = { isError: boolean; denied: boolean; chars: number; hash: string; head: string }
38
39export type Step = {
40 n: number
41 id: string
42 turn: number
43 agentId?: string
44 branch: string
45 parent: number
46 tool: string
47 kind: StepKind
48 input: Record<string, unknown>
49 // The stored input was cut or redacted, so it cannot be replayed from the log alone.
50 inputLossy?: boolean
51 target?: string
52 startedAt: number
53 endedAt?: number
54 result?: StepResult
55 snapshot?: SnapshotRef
56 test?: TestRun
57 rerunOf?: number
58}
59
60export type TurnRec = { n: number; at: number; prompt: { chars: number; hash: string; head?: string } }
61
62export type ModelRec = {
63 turn: number
64 index: number
65 agentId?: string
66 model: string
67 stopReason: string | null
68 usage: unknown
69 text: { chars: number; hash: string; body?: string }
70 toolUses: { id: string; tool: string }[]
71}
72
73export type BranchRec = { name: string; fromStep: number; at: number }
74
75export type SessionRec = { sessionId: string; root: string; startedAt: number; loadId: string }
76
77export type Rec =
78 | ({ type: 'session' } & SessionRec)
79 | ({ type: 'turn' } & TurnRec)
80 | ({ type: 'step' } & Step)
81 | ({ type: 'model' } & ModelRec)
82 | ({ type: 'branch' } & BranchRec)
83