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

에이전트가 여러 파일을 바꾸다 엉뚱한 결과를 냈을 때, Git diff는 "무엇이 바뀌었나"만 알려 준다. Time Machine은 어떤 순서로, 어떤 도구 호출이, 어떤 파일 상태에서, 어떤 결과를 냈는지를 기록하고, 임의의 step으로 돌아가 그 당시 파일과 도구 호출 내역을 볼 수 있게 한다. 파일 수정과 테스트 실패의 인과 추적, 시점 복구, 분기, 두 실행 경로 비교, 같은 입력 재실행도 지원한다.
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 install time-machine --marketplace YeonwooSung/my-claude-code-mods
Claude Code 2.1.287 이상(mods 기본 활성화)과 git이 필요하다.
모두 /tm <하위 명령> 하나로 쓴다.
| 명령 | 내용 |
|---|---|
/tm 또는 /tm timeline [--all] [--branch b] | 타임라인 트리(최근 30 step, --all은 전체). step마다 번호·도구·대상과 표식(테스트 결과, 거부, 오류, 스냅샷) |
/tm show <n> | step 상세: 입력(잘린 표시), 결과 head, 바뀐 파일, 테스트 결과, 스냅샷 커밋·시간 |
/tm diff <a> [b] | step a와 b(기본: 현재) 스냅샷 사이 diff --stat과 패치(최대 400줄; 그 뒤는 읽지 않는다) |
/tm why [n] | 테스트 실행 n(기본: 마지막 실패)의 용의자와 fixed-by. 통과한 실행은 용의자가 없다 |
/tm restore <n> [--yes] | step n의 파일 상태로 되돌린다. --yes 없이는 dry run |
/tm branch <n> [name] [--yes] | restore와 같되 이후 step을 새 branch에 기록 |
/tm compare <a> <b> | 두 경로(branch 또는 session:<id>[/<branch>]) 비교: 분기점, 각자의 step, 파일 diff 통계, 마지막 테스트 결과 |
/tm rerun <n> [--write] [--isolated [--yes]] | step n의 입력으로 도구를 다시 실행하고 결과를 비교 |
/tm sessions | 이 프로젝트의 기록된 세션 목록(세션 id, "(this session)" 표시, 시작 시각) |
/tm-pane | 실시간 타임라인 패널 |
상태줄: tm ⑫ · 5 snaps · 41ms (마지막 step 번호, 스냅샷 수, 평균 스냅샷 시간). 스냅샷이 꺼지면 상태줄에는 tm ⑫ · snapshots off만 보이고, 이유는 /tm 타임라인 머리(snapshots off: …)와 다른 하위 명령의 메시지에 나온다.
<storeDir>/<projectKey>/shadow.git(bare 저장소)에 스냅샷을 커밋한다. projectKey는 프로젝트 루트의 이름과 경로 해시 앞 8자. 세션마다 인덱스(index/<sessionId>)를 따로 쓰고, ref는 refs/tm/<sessionId>/<branch>. 섀도 저장소의 git 환경(GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY 등)은 고정되고 상속된 GIT_CONFIG_PARAMETERS는 비워져 있어 사용자의 .git에는 아무것도 쓰지 않는다. .gitignore를 따른다. 사용자의 .git/info/exclude는 세션에서 git을 처음 쓸 때(첫 변경성 도구 또는 git을 쓰는 첫 /tm 명령) 섀도 저장소로 복사되며 이후 갱신되지 않는다(.git이 파일인 linked worktree는 제외).cat > f <<EOF 같은 리디렉션도 파일을 쓰기 때문이다. 변경이 없으면 커밋하지 않는다. 설정 skipSearchBash를 켜면 검색으로 분류된 Bash(rg, grep, ls, cat …)는 건너뛴다. 읽기 전용 도구는 찍지 않는다.external step으로 기록해 에이전트 탓으로 돌리지 않는다./tm 명령)에 찍는다. 세션 시작을 늦추지 않기 위해서다.concurrent로 표시한다. 외부 변경 검사는 다른 변경성 도구가 진행 중이면 건너뛴다(그 도구의 쓰기를 외부 변경으로 돌리지 않기 위해).git gc --auto를 쓴다. 지운 세션의 인덱스 파일과, 중단된 --isolated 재실행이 남긴 하루 넘은 임시 폴더도 함께 지운다.| 등급 | 예 |
|---|---|
| exact | 스냅샷 시점의 추적 파일 내용(섀도 git 커밋), 도구 입력, 도구 결과 텍스트(앞부분)와 해시, 테스트 명령의 종료 상태 |
| parsed | 테스트 출력에서 읽은 통과/실패 수, 실패 테스트 이름, 실패 위치 file:line (파서가 아는 형식만: pytest, jest/vitest, go, cargo, node:test) |
| inferred | 인과 용의자 순위, "fixed by", 경로 비교의 분기점 |
| not reproduced | 모델 응답, 외부 서비스, 시간, 난수 |
/tm why)/tm restore <n>과 /tm branch <n> [name]은 --yes 없이 dry run이다: 바뀔 파일(A/M/D)과 개수만 보이고 끝난다.--yes를 주면 먼저 현재 상태를 복구 전 스냅샷으로 남긴 뒤, 작업 트리를 대상 step의 상태로 맞춘다. 대상에 없는 추적 파일은 지워지고, 무시된 파일은 그대로 둔다..env가 지금은 .gitignore에 있음, 또는 파일 out이 지금은 무시되는 디렉터리 out/) 복구는 아무것도 건드리지 않고 거부한다: 무시 파일은 어느 스냅샷에도 없어 덮어쓰면 되돌릴 수 없기 때문이다. 메시지에 나온 경로를 옮겨 두고 다시 실행하면 된다. 무시 파일만 남은 디렉터리(예: pkg/a.py를 복구하는데 pkg/ 안에 __pycache__/만 남은 경우)는 안의 파일 단위로 판단하므로, 실제로 겹치는 파일이 없으면 복구한다./tm why의 변경 목록에 파일을 보태지 않는다: 그 트리는 step n과 같아서, 떠난 branch에서만 바뀐 파일이 새 branch의 실패 용의자로 나오지 않는다. 작업 트리가 어떻게 바뀌었는지는 타임라인(work tree: …)과 /tm show의 workTree에 보인다.--yes가 붙은 복구·분기와 모든 재실행(--isolated dry run 제외)은 사용자가 직접 입력했을 때만 실행된다(origin이 composer/bridge/sdk). 그 외에는 origin을 밝히며 거부한다. dry run과 읽기 전용 하위 명령은 어디서든 동작한다. 모델이 부르는 도구로는 노출되지 않는다./tm rerun)Agent/Task, Skill, SendMessage, KillShell)는 --write가 필요하다(변경성 도구는 실행 뒤 스냅샷, 서브에이전트 등은 그 안의 호출이 각자 step으로 기록). 어느 경우든 사용자가 직접 입력했을 때만 실행된다. 결과가 해시로 같으면 "identical", 다르면 줄 diff 요약, 테스트면 통과/실패 변화를 보인다.--isolated(Bash만): 그 step 직전 스냅샷을 임시 디렉터리(<storeDir>/<projectKey>/tmp/…)에 풀어 거기서 실행한다. 무시 파일(의존성 등)이 없으므로 best effort이고, 권한 다이얼로그를 거치지 않는다. 그래서 --yes 없이는 dry run이다: 실행할 명령($ …), 쓸 스냅샷, 주의점만 보이고 아무것도 실행하지 않는다. /tm rerun <n> --isolated --yes도 사용자가 직접 입력했을 때만 실행되며, 명령이 프로젝트의 절대 경로를 담으면 격리되지 않으므로 거부한다. 실행 전후로 외부 변경을 검사해, 그래도 프로젝트가 바뀌면 external step으로 남긴다.v1은 파일을 되돌리고 도구를 다시 실행할 뿐, 모델 응답·외부 서비스(WebFetch, MCP)·시간·난수는 재현하지 않는다. 모델 턴은 요약·해시만 기록한다.
v2 설계 요약(구현하지 않음): (1) turn.step 훅이 기록된 청크를 내보내는 모델 응답 카세트, 키 = 요청 메시지 해시 + 모델 + 인덱스. (2) tool.call 훅이 next 없이 결과를 답하는 도구 결과 카세트, 키 = (도구, 입력 해시, 직전 스냅샷 커밋). (3) WebFetch/WebSearch/MCP는 도구 결과 카세트로 덮고, 네트워크를 쓰는 Bash는 재현 불가로 표시. (4) 시간·난수 의존은 경고만. (5) 재현 실행 = 새 세션 + 베이스라인 복구 + 카세트 모드; 카세트 miss 지점이 "비결정적 분기점"이다.
| 키 | 기본값 | 내용 |
|---|---|---|
storeDir | "" (~/.claude/time-machine) | 스냅샷과 타임라인을 두는 곳. 프로젝트별 폴더. 절대 경로(또는 ~/…)여야 하고 프로젝트 밖이어야 한다(아니면 스냅샷이 꺼진다) |
retentionDays | 14 | 이보다 오래된 세션 기록을 삭제(하루 한 번, 시작 후 분리 실행). 0이면 모두 보존 |
recordModelOutput | false | 모델 응답 본문과 프롬프트 앞 200자를 저장. 끄면 길이와 해시만 |
skipSearchBash | false | 검색으로 분류된 Bash 뒤 스냅샷을 건너뜀 |
<storeDir>/<projectKey>/
shadow.git/ # 섀도 저장소
index/<sessionId> # 세션별 git 인덱스
tmp/ # --isolated 재실행용 임시 작업 디렉터리
sessions/<sessionId>/meta.json # { sessionId, root, startedAt }
sessions/<sessionId>/<load>-<n>.jsonl
JSONL 레코드 종류: session, turn, step, model, branch. 청크는 256 KiB를 넘으면 다음 번호로 넘어간다. step의 도구 입력은 문자열 필드마다 4 KiB, 결과 head는 2 KiB에서 자르며(해시와 길이는 남김) 비밀값은 마스킹한다. Write의 content, Edit의 old/new_string 전체는 스냅샷에 있다.
| 환경 | 동작 |
|---|---|
| 터미널 | 모든 명령, 패널, 상태줄 |
| VS Code 채팅 패널 | 훅과 명령 텍스트만(패널·상태줄은 표시되지 않음) |
claude -p | 기록·스냅샷 동작. 명령은 stream-json 입력으로 같은 프로세스에서 |
/이거나, storeDir이 상대 경로이거나 프로젝트 안이거나, 베이스라인 스냅샷이 5초를 넘으면 스냅샷이 꺼진다(타임라인은 계속 기록). 이후 스냅샷 하나가 실패하면(타임아웃, 다른 프로세스와의 경합) 그 step만 스냅샷 없이 남고, 3분 넘게 연속 3번 이상 실패해야 꺼진다. 죽은 git이 남긴 lock 파일은 2분이 지나면 지우고 다시 시도한다. 이유는 /tm 타임라인 머리와 다른 하위 명령의 메시지에 보인다(상태줄에는 snapshots off만).git add -A가 실패해 스냅샷이 계속 실패하고, 결국 꺼진다. 중첩 저장소의 내용은 스냅샷되지 않는다(gitlink로만 남는다).external change step이 생긴다.--isolated는 작업 디렉터리만 바꾼다. 절대 경로를 쓰는 명령은 여전히 실제 트리를 건드린다.node_modules, .env)은 스냅샷·복구 대상이 아니다./rewind를 대체하지 않는다. Time Machine은 Bash가 바꾼 파일까지 잡고 타임라인·인과·비교를 더한다.CLAUDE292=~/.vscode/extensions/anthropic.claude-code-2.1.292-darwin-arm64/resources/native-binary/claude
"$CLAUDE292" plugin test time-machine # 저장소 루트에서
npx -y -p typescript@5 tsc -p time-machine --noEmit # types를 time-machine/.claude-plugin/types/에 복사한 뒤 (커밋하지 않음)
"$CLAUDE292" plugin validate time-machine
단위 테스트는 가짜 git 러너로 명령 순서·인자·환경·출력 파싱·오류 경로를 검증한다. 테스트 러너의 $에는 process가 없어 진짜 git은 E2E(-p stream-json, 한 프로세스)로 확인한다.
hooks/register.tsx 716 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, parseChunks, projectKey } from './core/store.ts'
21import { parseTestOutput } from './core/testparse.ts'
22import { branchTaken, Recorder } from './core/timeline.ts'
23import { ACTING_TOOLS, callInput, isMutating, resultText, summarizeInput, summarizeResult, targetOf } from './core/tools.ts'
24import type { SnapshotRef, Step } from './core/types.ts'
25import { isInside, ShadowGit, type Runner } from './git.ts'
26
27const PANE = 'time-machine'
28// Names this module load's timeline chunks, so a reload never overwrites an earlier load's.
29const LOAD_ID = Date.now().toString(36)
30const DAY = 86_400_000
31const TOO_SLOW_MS = 5_000
32const MAX_LIVE_INPUTS = 500
33// Raw inputs kept for /tm rerun stay under this in all; the oldest go first, and one larger is not kept.
34const MAX_LIVE_BYTES = 32 * 1024 * 1024
35const DIFF_LINES = 400
36// Snapshots turn off only after failing this many times in a row over at least this long: a stale lock
37// (cleared once it is old enough) or one slow moment skips a snapshot or two, not the rest of the session.
38const FAILS_BEFORE_OFF = 3
39const FAILING_MS_BEFORE_OFF = 180_000
40const USAGE =
41 '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]'
42
43type Ctx = {
44 rec: Recorder
45 root: string
46 base: string
47 dir: string
48 git?: ShadowGit
49 ready?: Promise<void>
50 off?: string
51 head?: string
52 writer: ChunkWriter
53 writing: Promise<void>
54 inflight: number
55 snapMs: number[]
56 failing?: { count: number; since: number; error: string }
57 // The external-change check in progress: parallel mutating calls wait for it before they run.
58 checking?: Promise<Step | undefined>
59 live: Map<number, Record<string, unknown>>
60 liveBytes: Map<number, number>
61 rerunOf?: number
62 ended?: boolean
63}
64
65let ctx: Ctx | undefined
66// Session starts in this module load: each gets its own chunk names, which still sort in start order.
67let starts = 0
68let currentOptions: PluginOptions = {}
69
70const message = (error: unknown): string => (error instanceof Error ? error.message : String(error))
71
72function runnerOf($: EngineInterface): Runner {
73 return async (argv, opts) => {
74 const r = await $.process.run(argv, opts)
75 return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }
76 }
77}
78
79const refOf = (c: Ctx): string => `refs/tm/${c.rec.session.sessionId}/${c.rec.branch}`
80
81async function storeDir($: EngineInterface, options: PluginOptions): Promise<string> {
82 const dir = (String(options.storeDir ?? '').trim() || '~/.claude/time-machine').replace(/\/+$/, '')
83 if (!/^~(?=\/|$)/.test(dir)) return dir
84 return dir.replace(/^~/, (await $.env.get('HOME')) ?? '.')
85}
86
87// Records written in order: every chunk a batch touched gets its final body.
88async function persist($: EngineInterface, c: Ctx): Promise<void> {
89 const recs = c.rec.drain()
90 if (recs.length === 0) return c.writing
91 const writes = new Map<string, string>()
92 for (const rec of recs) {
93 const { name, body } = c.writer.add(rec)
94 writes.set(name, body)
95 }
96 c.writing = c.writing
97 .then(async () => {
98 for (const [name, body] of writes) await $.fs.write(`${c.dir}/${name}`, body)
99 })
100 .catch(() => undefined)
101 return c.writing
102}
103
104function paint($: EngineInterface, c: Ctx): void {
105 try {
106 $.ui.status(statusLine(c.rec, c.snapMs, c.off))
107 $.ui.invalidate('ui.render')
108 } catch {
109 // no status line here
110 }
111}
112
113// A session id as a folder name: one path segment, never `.` or `..`.
114const SESSION_ID = /^(?!\.\.?$)[A-Za-z0-9._-]{1,128}$/
115
116// A recorded session's timeline from its folder, or undefined when there is none to read.
117async function loadRecorder($: EngineInterface, dir: string): Promise<Recorder | undefined> {
118 try {
119 const entries = await $.fs.list(dir)
120 const files = await Promise.all(
121 entries.filter(e => e.kind === 'file' && e.name.endsWith('.jsonl')).map(async e => ({ name: e.name, text: String(await $.fs.read(`${dir}/${e.name}`)) })),
122 )
123 return Recorder.fromRecords(parseChunks(files))
124 } catch {
125 return undefined
126 }
127}
128
129async function startSession($: EngineInterface): Promise<Ctx> {
130 const sessionId = await $.session.id()
131 const root = await $.session.root()
132 const base = `${await storeDir($, currentOptions)}/${projectKey(root)}`
133 const dir = `${base}/sessions/${sessionId}`
134 const rec = (await loadRecorder($, dir)) ?? new Recorder({ sessionId, root, startedAt: Date.now(), loadId: LOAD_ID })
135 starts += 1
136 // Four digits: chunk names sort in start order through 9999 starts in one module load.
137 const c: Ctx = {
138 rec,
139 root,
140 base,
141 dir,
142 writer: new ChunkWriter(`${LOAD_ID}${String(starts).padStart(4, '0')}`),
143 writing: Promise.resolve(),
144 inflight: 0,
145 snapMs: [],
146 live: new Map(),
147 liveBytes: new Map(),
148 }
149 const last = rec.lastSnapshot()
150 if (last?.snapshot) c.head = last.snapshot.commit
151 try {
152 await $.fs.write(`${dir}/meta.json`, JSON.stringify({ sessionId, root, startedAt: rec.session.startedAt }))
153 } catch {
154 // the timeline still lives in memory
155 }
156 await persist($, c)
157 return c
158}
159
160// The context for this session, started again after a /clear or a resume.
161async function live($: EngineInterface): Promise<Ctx | undefined> {
162 if (ctx && !ctx.ended) return ctx
163 try {
164 ctx = await startSession($)
165 } catch {
166 ctx = undefined
167 }
168 return ctx
169}
170
171// Git, ready to use from this hook, or undefined with `c.off` saying why.
172// The first caller creates git and its readiness synchronously, so concurrent first calls share them.
173async function ensureGit($: EngineInterface, c: Ctx): Promise<ShadowGit | undefined> {
174 if (c.off) return undefined
175 if (c.git) {
176 c.git.runner = runnerOf($)
177 await c.ready
178 return c.off ? undefined : c.git
179 }
180 const git = new ShadowGit(runnerOf($), `${c.base}/shadow.git`, c.root, `${c.base}/index/${c.rec.session.sessionId}`)
181 c.git = git
182 c.ready = (async () => {
183 const home = await $.env.get('HOME').catch(() => undefined)
184 if (c.root === '/' || (home !== undefined && c.root.replace(/\/+$/, '') === home.replace(/\/+$/, ''))) {
185 c.off = 'the session root is the home or the root directory'
186 } else if (!c.base.startsWith('/') || /(^|\/)\.\.?(\/|$)/.test(c.base)) {
187 c.off = 'storeDir must be an absolute path'
188 } else if (c.base === c.root || isInside(c.base, c.root)) {
189 // Its own files would be snapshotted, and every snapshot would change it.
190 c.off = 'the time-machine store is inside the project'
191 }
192 if (c.off) return
193 try {
194 await git.runner(['mkdir', '-p', `${c.base}/index`])
195 await git.init()
196 if (c.head === undefined) {
197 const t0 = Date.now()
198 const snap = await git.snapshot(undefined, `baseline ${c.rec.session.sessionId}`, refOf(c))
199 const ms = Date.now() - t0
200 c.head = snap.commit
201 if (!c.rec.get(0)) c.rec.baseline(Date.now(), { ...snap, ms })
202 if (ms > TOO_SLOW_MS) c.off = `the baseline snapshot took ${ms}ms; per-step snapshots would slow every tool`
203 }
204 } catch (error) {
205 c.off = `git unavailable: ${message(error)}`
206 }
207 if (c.off) return
208 // Off every tool's path: pruning and gc run detached, and their failures never turn snapshots off.
209 try {
210 $.clock.after(0, () => {
211 prune($, c, git).catch(() => undefined)
212 })
213 } catch {
214 // pruned another day
215 }
216 })()
217 await c.ready
218 if (!c.rec.get(0)) c.rec.baseline(Date.now())
219 await persist($, c)
220 return c.off ? undefined : git
221}
222
223// A call's raw input, kept for /tm rerun within MAX_LIVE_INPUTS and MAX_LIVE_BYTES, oldest dropped first.
224function keepLive(c: Ctx, n: number, input: Record<string, unknown>): void {
225 let size: number
226 try {
227 size = JSON.stringify(input).length
228 } catch {
229 return
230 }
231 if (size > MAX_LIVE_BYTES) return
232 c.live.set(n, input)
233 c.liveBytes.set(n, size)
234 let total = 0
235 for (const v of c.liveBytes.values()) total += v
236 while (c.live.size > MAX_LIVE_INPUTS || total > MAX_LIVE_BYTES) {
237 const oldest = c.live.keys().next().value!
238 total -= c.liveBytes.get(oldest) ?? 0
239 c.live.delete(oldest)
240 c.liveBytes.delete(oldest)
241 }
242}
243
244// `own`: the caller's own call, when it is counted in flight.
245async function takeSnapshot($: EngineInterface, c: Ctx, label: string, own = 0): Promise<SnapshotRef | undefined> {
246 const git = await ensureGit($, c)
247 if (!git) return undefined
248 const concurrent = c.inflight > own
249 const t0 = Date.now()
250 try {
251 const s = await git.snapshot(c.head, label, refOf(c))
252 const ms = Date.now() - t0
253 c.head = s.commit
254 c.failing = undefined
255 c.snapMs.push(ms)
256 if (c.snapMs.length > 1_000) c.snapMs.shift()
257 return { ...s, ms, ...(concurrent ? { concurrent: true } : {}) }
258 } catch (error) {
259 const now = Date.now()
260 const f = (c.failing ??= { count: 0, since: now, error: '' })
261 f.count += 1
262 f.error = message(error)
263 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}`
264 return undefined
265 }
266}
267
268// Changes made outside the agent since the last snapshot become an `external` step of their own.
269// Skipped while another mutating call is in flight: its changes are not external. A check already
270// running is waited for instead, so a parallel call's writes never land inside it. `own` is 1 when the
271// caller is a tool call already counted in flight.
272async function checkExternal($: EngineInterface, c: Ctx, own = 0): Promise<Step | undefined> {
273 if (c.checking) {
274 await c.checking.catch(() => undefined)
275 return undefined
276 }
277 if (c.inflight > own) return undefined
278 const check = (async () => {
279 const snap = await takeSnapshot($, c, 'external', own)
280 if (!snap?.changed) return undefined
281 const step = c.rec.begin({ id: `external-${Date.now()}`, tool: 'external', kind: 'external', at: Date.now() })
282 c.rec.finish(step, Date.now(), { snapshot: snap })
283 return step
284 })()
285 c.checking = check
286 try {
287 return await check
288 } finally {
289 if (c.checking === check) c.checking = undefined
290 }
291}
292
293async function prune($: EngineInterface, c: Ctx, git: ShadowGit): Promise<void> {
294 const key = `lastPrune:${projectKey(c.root)}`
295 const last = Number((await $.store.get(key)) ?? 0)
296 if (Date.now() - last < DAY) return
297 await $.store.set(key, Date.now())
298 const days = Number(currentOptions.retentionDays ?? 14)
299 const sessions = `${c.base}/sessions`
300 // retentionDays 0 keeps every session; leftovers below are still swept.
301 for (const entry of days > 0 ? await $.fs.list(sessions).catch(() => []) : []) {
302 if (entry.kind !== 'dir' || entry.name === c.rec.session.sessionId) continue
303 const target = `${sessions}/${entry.name}`
304 try {
305 const meta = JSON.parse(String(await $.fs.read(`${target}/meta.json`))) as { startedAt?: number }
306 if (Date.now() - Number(meta.startedAt ?? Date.now()) < days * DAY || !isInside(target, sessions)) continue
307 await runnerOf($)(['rm', '-rf', target])
308 if (SESSION_ID.test(entry.name)) await runnerOf($)(['rm', '-f', `${c.base}/index/${entry.name}`, `${c.base}/index/${entry.name}.lock`])
309 await git.deleteRefs(`refs/tm/${entry.name}/`)
310 } catch {
311 // leave it for next time
312 }
313 }
314 // Indexes whose session folder is gone (pruned before indexes were): the index is only that session's.
315 for (const entry of await $.fs.list(`${c.base}/index`).catch(() => [])) {
316 const id = entry.name.replace(/\.lock$/, '')
317 if (entry.kind !== 'file' || id === c.rec.session.sessionId || !SESSION_ID.test(id)) continue
318 if (await $.fs.exists(`${sessions}/${id}`).catch(() => true)) continue
319 await runnerOf($)(['rm', '-f', `${c.base}/index/${entry.name}`]).catch(() => undefined)
320 }
321 // Isolated reruns' copies a crash left behind: named `<load>-<step>-<base-36 time>`, removed after a day.
322 const tmpRoot = `${c.base}/tmp`
323 for (const entry of await $.fs.list(tmpRoot).catch(() => [])) {
324 const at = parseInt(/^[0-9a-z]+-\d+-([0-9a-z]+)(?:\.index)?$/.exec(entry.name)?.[1] ?? '', 36)
325 const target = `${tmpRoot}/${entry.name}`
326 if (!(Date.now() - at > DAY) || !isInside(target, tmpRoot)) continue
327 await runnerOf($)(['rm', '-rf', target]).catch(() => undefined)
328 }
329 await git.gc().catch(() => undefined)
330}
331
332// Finishes a begun step on every path: a recording error still ends it, with what it gathered.
333async function settle($: EngineInterface, c: Ctx, step: Step | undefined, counted: boolean, mutating: boolean, answer: unknown): Promise<void> {
334 if (counted) c.inflight = Math.max(0, c.inflight - 1)
335 if (!step) return
336 try {
337 const result = summarizeResult(answer)
338 const snapshot = mutating ? await takeSnapshot($, c, `step ${step.n} ${step.tool}`) : undefined
339 const command = String(c.live.get(step.n)?.command ?? step.input.command ?? '')
340 const test = step.tool === 'Bash' && classifyCommand(command) === 'test' ? parseTestOutput(resultText(answer), result.isError) : undefined
341 c.rec.finish(step, Date.now(), { result, ...(snapshot ? { snapshot } : {}), ...(test ? { test } : {}) })
342 await persist($, c)
343 paint($, c)
344 } catch {
345 // recording never breaks the call; the step is still finished
346 try {
347 if (step.endedAt === undefined) c.rec.finish(step, Date.now())
348 await persist($, c)
349 } catch {
350 // dropped
351 }
352 }
353}
354
355// Origins a person typed: the only ones allowed to change files or run commands through /tm.
356const TYPED = new Set(['composer', 'bridge', 'sdk'])
357
358// The subcommand, when `args` would change files or run commands: restore/branch --yes, and every rerun
359// but an --isolated dry run (a "read-only" step may be a subagent that edits files once run again).
360function changesFiles(args: string): string | undefined {
361 const words = args.trim().split(/\s+/).filter(w => w !== '')
362 const sub = words.find(w => !w.startsWith('--'))
363 const flags = new Set(words.filter(w => w.startsWith('--')))
364 if ((sub === 'restore' || sub === 'branch') && flags.has('--yes')) return sub
365 if (sub === 'rerun' && !(flags.has('--isolated') && !flags.has('--yes'))) return sub
366 return undefined
367}
368
369const num = (word: string | undefined): number | undefined => (word !== undefined && /^\d+$/.test(word) ? Number(word) : undefined)
370
371async function restore($: EngineInterface, c: Ctx, n: number, yes: boolean, branch?: string): Promise<string> {
372 if (!c.rec.get(n)) return `time-machine: no step ${n}`
373 const git = await ensureGit($, c)
374 if (!git) return `time-machine: snapshots are off (${c.off ?? 'not ready'}); there is nothing to restore`
375 const target = c.rec.snapshotAt(n)
376 if (!target?.snapshot) return `time-machine: no snapshot at or before step ${n}`
377 await checkExternal($, c)
378 const from = c.head!
379 const files = await git.nameStatus(from, target.snapshot.commit)
380 const name = branch === undefined ? undefined : branch || c.rec.nextBranchName()
381 const asked = c.rec.get(n)!
382 if (!yes) return renderRestorePlan(asked, target, files, false, name)
383 if (name !== undefined && branchTaken(c.rec, name)) return `time-machine: branch '${name}' already exists`
384 // A name git would refuse as a ref would fail only after the work tree was rewritten.
385 if (name !== undefined && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || name.includes('..') || name.endsWith('.lock') || name.endsWith('.')))
386 return `time-machine: '${name}' is not a usable branch name (letters, digits, '.', '_', '-')`
387 // Applied first: a refused restore (the work tree moved since `from`) leaves no fork and no restore step.
388 // The ref is the new branch's when branching, so the branch left behind keeps its history.
389 const ref = name !== undefined ? `refs/tm/${c.rec.session.sessionId}/${name}` : refOf(c)
390 const commit = await git.restore(target.snapshot.commit, ref, from, `restore to step ${n}${name !== undefined ? ` on branch ${name}` : ''}`, name !== undefined)
391 c.head = commit
392 c.rec.restoreStep({ at: Date.now(), to: n, from, commit, files, ...(name !== undefined ? { branch: name } : {}) })
393 await persist($, c)
394 paint($, c)
395 const list = files.slice(0, 20).map(f => `${f.status} ${f.path}`).join(', ')
396 const note =
397 `[time-machine] The user restored the working tree to step ${n} (${label(c.rec.get(n)!)}). ${files.length} file(s) changed${list ? `: ${list}` : ''}. ` +
398 'Earlier tool results about these files are out of date; re-read files before editing them.' +
399 (name !== undefined ? ` Work continues on branch '${name}'; the conversation itself was not rewound.` : '')
400 let told = true
401 try {
402 const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
403 if (typeof appended?.deny === 'string') told = false
404 } catch {
405 // the restore stands; the model was not told
406 told = false
407 }
408 return renderRestorePlan(asked, target, files, true, name, told)
409}
410
411// A branch name, or `session:<id>[/<branch>]` for another recorded session of this project.
412async function pathOf($: EngineInterface, c: Ctx, spec: string): Promise<{ steps: Step[]; head?: string } | string> {
413 let rec: Recorder | undefined = c.rec
414 let branch = spec
415 if (spec.startsWith('session:')) {
416 const [id = '', b] = spec.slice('session:'.length).split('/')
417 if (!SESSION_ID.test(id)) return `time-machine: '${id}' is not a session id`
418 rec = await loadRecorder($, `${c.base}/sessions/${id}`)
419 if (!rec) return `time-machine: no recorded session ${id}`
420 branch = b ?? 'main'
421 }
422 if (!rec.branches.has(branch)) return `time-machine: no branch '${branch}'`
423 const steps = rec.path(branch)
424 const head = rec.lastSnapshot(branch)?.snapshot?.commit
425 return head !== undefined ? { steps, head } : { steps }
426}
427
428async function rerun($: EngineInterface, c: Ctx, n: number, write: boolean, isolated: boolean, yes: boolean): Promise<string> {
429 const step = c.rec.get(n)
430 if (!step || step.kind !== 'tool') return `time-machine: no tool step ${n}`
431 const input = c.live.get(n) ?? (step.inputLossy ? undefined : step.input)
432 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`
433 if (isolated) {
434 if (step.tool !== 'Bash') return 'time-machine: --isolated replays Bash steps only'
435 const command = String(input.command ?? '')
436 const problem = isolationProblem(command, c.root)
437 if (yes && problem) return `$ ${command}\ntime-machine: ${problem}`
438 const git = await ensureGit($, c)
439 const before = git ? c.rec.snapshotAt(step.parent) : undefined
440 const blocked = !git ? `snapshots are off (${c.off ?? 'not ready'})` : !before?.snapshot ? `no snapshot before step ${n}` : problem
441 if (!yes) return renderIsolatedPlan(step, command, before, blocked)
442 if (!git || !before?.snapshot) return `$ ${command}\ntime-machine: ${blocked}`
443 const tmpRoot = `${c.base}/tmp`
444 const tmp = `${tmpRoot}/${LOAD_ID}-${n}-${Date.now().toString(36)}`
445 const run = runnerOf($)
446 // Changes made before it are not the command's; one it makes to the project anyway is recorded.
447 await checkExternal($, c)
448 let out: string
449 try {
450 await run(['mkdir', '-p', tmp])
451 await git.exportTo(before.snapshot.commit, tmp)
452 const r = await run(['sh', '-lc', command], { cwd: tmp, timeoutMs: 600_000 })
453 const text = [r.stdout, r.stderr].filter(s => s !== '').join('\n')
454 const now = step.test ? parseTestOutput(text, r.exitCode !== 0) : undefined
455 const where = `isolated, in a copy of ${circled(before.n)}'s snapshot; ignored files such as dependencies are not in the copy`
456 out = [`$ ${command}`, renderRerun(step, compareOutputs(step.result, text, step.test, now), where)].join('\n')
457 } finally {
458 if (isInside(tmp, tmpRoot)) await run(['rm', '-rf', tmp, `${tmp}.index`]).catch(() => undefined)
459 }
460 const stray = await checkExternal($, c)
461 if (stray) {
462 await persist($, c)
463 paint($, c)
464 out += `\nThe project changed while it ran: recorded as ${circled(stray.n)} ${label(stray)}.`
465 }
466 return out
467 }
468 if ((isMutating(step.tool, input, currentOptions.skipSearchBash === true) || ACTING_TOOLS.has(step.tool)) && !write)
469 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'})`
470 c.rerunOf = n
471 let answer: unknown
472 try {
473 answer = await $.tool.call({ tool: step.tool, ...input } as never)
474 } finally {
475 c.rerunOf = undefined
476 }
477 const text = resultText(answer)
478 const now = step.test ? parseTestOutput(text, summarizeResult(answer).isError) : undefined
479 return renderRerun(step, compareOutputs(step.result, text, step.test, now), 'here, against the current files')
480}
481
482async function runTm($: EngineInterface, c: Ctx, args: string): Promise<string> {
483 const words = args.trim().split(/\s+/).filter(w => w !== '')
484 const flags = new Set(words.filter(w => w.startsWith('--')))
485 const rest = words.filter(w => !w.startsWith('--'))
486 const sub = rest[0] ?? 'timeline'
487 switch (sub) {
488 case 'timeline': {
489 const branchAt = words.indexOf('--branch')
490 const branch = branchAt >= 0 ? words[branchAt + 1] : undefined
491 if (branch !== undefined && !c.rec.branches.has(branch)) return `time-machine: no branch '${branch}'`
492 return renderTimeline(c.rec, { all: flags.has('--all'), ...(branch ? { branch } : {}), ...(c.off ? { off: c.off } : {}), snapMs: c.snapMs })
493 }
494 case 'show': {
495 const n = num(rest[1])
496 const step = n === undefined ? undefined : c.rec.get(n)
497 return step ? renderStep(step) : `time-machine: no step ${rest[1] ?? ''}`.trim()
498 }
499 case 'diff': {
500 const a = num(rest[1])
501 if (a === undefined) return USAGE
502 const git = await ensureGit($, c)
503 if (!git) return `time-machine: snapshots are off (${c.off ?? 'not ready'})`
504 const from = c.rec.snapshotAt(a)?.snapshot?.commit
505 let to: string | undefined
506 const b = num(rest[2])
507 if (b !== undefined) to = c.rec.snapshotAt(b)?.snapshot?.commit
508 else {
509 await checkExternal($, c)
510 to = c.head
511 }
512 if (from === undefined || to === undefined) return 'time-machine: no snapshot for that step'
513 const { stat, patch, more } = await git.diffText(from, to, DIFF_LINES)
514 return [stat.trimEnd() || '(no difference)', '', patch, ...(more ? [`… (cut at ${DIFF_LINES} lines; the stat above lists every file)`] : [])].join('\n')
515 }
516 case 'why': {
517 const w = why(c.rec.path(), num(rest[1]))
518 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'
519 return renderWhy(w, n => {
520 const s = c.rec.get(n)
521 return s ? `${circled(n)} ${label(s)}` : undefined
522 })
523 }
524 case 'restore':
525 case 'branch': {
526 const n = num(rest[1])
527 if (n === undefined) return USAGE
528 return restore($, c, n, flags.has('--yes'), sub === 'branch' ? (rest[2] ?? '') : undefined)
529 }
530 case 'compare': {
531 if (rest.length < 3) return USAGE
532 const a = await pathOf($, c, rest[1]!)
533 const b = await pathOf($, c, rest[2]!)
534 if (typeof a === 'string') return a
535 if (typeof b === 'string') return b
536 let stat: string | undefined
537 if (a.head && b.head) {
538 const git = await ensureGit($, c)
539 if (git) stat = (await git.diffText(a.head, b.head).catch(() => ({ stat: '(snapshots no longer available)', patch: '' }))).stat
540 }
541 return renderCompare(rest[1]!, rest[2]!, compare(a.steps, b.steps), stat)
542 }
543 case 'rerun': {
544 const n = num(rest[1])
545 if (n === undefined) return USAGE
546 return rerun($, c, n, flags.has('--write'), flags.has('--isolated'), flags.has('--yes'))
547 }
548 case 'sessions': {
549 const sessions = `${c.base}/sessions`
550 const rows: { id: string; startedAt: number; current: boolean }[] = []
551 for (const entry of await $.fs.list(sessions).catch(() => [])) {
552 if (entry.kind !== 'dir') continue
553 let startedAt = 0
554 try {
555 startedAt = Number((JSON.parse(String(await $.fs.read(`${sessions}/${entry.name}/meta.json`))) as { startedAt?: number }).startedAt ?? 0)
556 } catch {
557 // unknown start
558 }
559 rows.push({ id: entry.name, startedAt, current: entry.name === c.rec.session.sessionId })
560 }
561 return renderSessions(rows)
562 }
563 default:
564 return USAGE
565 }
566}
567
568export const register: Register = (on, options) => {
569 currentOptions = options
570
571 on('session.start', async ($, e, next) => {
572 try {
573 ctx = await startSession($)
574 for (const command of [
575 { 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]' },
576 { name: 'tm-pane', description: 'Open the live Time Machine timeline pane' },
577 ]) {
578 try {
579 await $.command.register(command)
580 } catch {
581 // the command stays unavailable
582 }
583 }
584 paint($, ctx)
585 } catch {
586 ctx = undefined
587 }
588 return next(e)
589 })
590
591 on('session.end', async ($, e, next) => {
592 const result = await next(e)
593 if ((e.reason === 'clear' || e.reason === 'resume') && ctx) ctx.ended = true
594 return result
595 }).catch(($, e, next) => next(e))
596
597 on('turn.start', async ($, e, next) => {
598 try {
599 const c = await live($)
600 if (c) {
601 c.rec.startTurn(Date.now(), e.text, currentOptions.recordModelOutput === true)
602 await persist($, c)
603 }
604 } catch {
605 // not recorded
606 }
607 return next(e)
608 })
609
610 on('tool.call', async ($, e, next) => {
611 const c = await live($).catch(() => undefined)
612 if (!c) return next(e)
613 const raw = e as unknown as Record<string, unknown>
614 const tool = String(e.tool)
615 let step: Step | undefined
616 let mutating = false
617 let counted = false
618 try {
619 mutating = isMutating(tool, raw, currentOptions.skipSearchBash === true)
620 // Counted before the check, so a parallel call does not check (and blame this one's writes) too.
621 if (mutating) {
622 c.inflight += 1
623 counted = true
624 await checkExternal($, c, 1)
625 }
626 const { input, lossy } = summarizeInput(raw)
627 const target = targetOf(tool, raw, c.root)
628 step = c.rec.begin({
629 id: e.tool_use_id ?? `call-${Date.now()}`,
630 tool,
631 input,
632 inputLossy: lossy,
633 at: Date.now(),
634 ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
635 ...(target !== undefined ? { target } : {}),
636 ...(c.rerunOf !== undefined ? { rerunOf: c.rerunOf } : {}),
637 })
638 c.rerunOf = undefined
639 keepLive(c, step.n, callInput(raw))
640 } catch {
641 // recording never blocks the tool
642 }
643 try {
644 const answer = await next(e)
645 await settle($, c, step, counted, mutating, answer)
646 return answer
647 } catch (error) {
648 await settle($, c, step, counted, mutating, { isError: true, text: message(error) })
649 throw error
650 }
651 }).catch(($, e, next) => next(e))
652
653 on('turn.step', async function* ($, e, next) {
654 let text = ''
655 const tools: { id: string; tool: string }[] = []
656 for await (const chunk of next(e)) {
657 try {
658 if (chunk.kind === 'text') text += chunk.text
659 else if (chunk.kind === 'tool') tools.push({ id: chunk.id, tool: chunk.name })
660 else if (chunk.kind === 'stop' && ctx && !ctx.ended)
661 ctx.rec.addModel({
662 turn: ctx.rec.turn,
663 index: e.index,
664 ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
665 model: e.model,
666 stopReason: chunk.stopReason,
667 usage: chunk.usage,
668 text: { chars: text.length, hash: contentHash(text), ...(currentOptions.recordModelOutput === true ? { body: text } : {}) },
669 toolUses: tools,
670 })
671 } catch {
672 // not recorded
673 }
674 yield chunk
675 }
676 if (ctx) await persist($, ctx).catch(() => undefined)
677 })
678
679 on('command.run', { command: 'tm' }, async ($, e) => {
680 try {
681 const c = await live($)
682 if (!c) return { text: 'time-machine: not recording (the session could not be read)' }
683 const args = e.args ?? ''
684 const sub = changesFiles(args)
685 const kind = e.origin?.kind ?? 'unknown'
686 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})` }
687 return { text: await runTm($, c, args) }
688 } catch (error) {
689 return { text: `time-machine: ${message(error)}` }
690 }
691 })
692
693 on('command.run', { command: 'tm-pane' }, async $ => {
694 await $.ui.open({ id: PANE, title: 'Time Machine' })
695 return { text: 'Time Machine pane opened (shown in the terminal and the desktop app).' }
696 })
697
698 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
699 const { Box, Text } = $.ui.resolve(e)
700 let lines: string[]
701 try {
702 lines = ctx ? renderTimeline(ctx.rec, { ...(ctx.off ? { off: ctx.off } : {}), snapMs: ctx.snapMs }).split('\n') : ['Time Machine: not recording']
703 } catch (error) {
704 lines = [`time-machine: ${message(error)}`]
705 }
706 const width = e.props.bodyColumns ?? 80
707 return (
708 <Box flexDirection="column">
709 {lines.map(line => (
710 <Text>{(line.length > width ? `${line.slice(0, width - 1)}…` : line) || ' '}</Text>
711 ))}
712 </Box>
713 )
714 })
715}
716hooks/core/causality.ts 86 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
17// Files that snapshots after path[fromIdx] up to path[toIdx] changed, each with the steps that changed it.
18export function changesBetween(path: readonly Step[], fromIdx: number, toIdx: number): Map<string, number[]> {
19 const out = new Map<string, number[]>()
20 for (let i = Math.max(0, fromIdx + 1); i <= toIdx && i < path.length; i += 1) {
21 const step = path[i]!
22 if (!step.snapshot?.changed || step.kind === 'baseline') continue
23 for (const file of step.snapshot.files) {
24 const steps = out.get(file.path) ?? []
25 steps.push(step.n)
26 out.set(file.path, steps)
27 }
28 }
29 return out
30}
31
32// Inferred, not proof: a file the failure output names outranks one it does not; recent and repeated
33// changes rank higher among equals.
34function rank(path: readonly Step[], changes: Map<string, number[]>, test: TestRun, baseIdx: number, runIdx: number): Suspect[] {
35 const position = new Map(path.map((step, i) => [step.n, i]))
36 const span = Math.max(1, runIdx - baseIdx)
37 const suspects: Suspect[] = []
38 for (const [file, steps] of changes) {
39 const reasons: string[] = []
40 let score = 0
41 if (test.failures.some(f => f.file !== undefined && sameFile(f.file, file)) || test.paths.some(p => sameFile(p, file))) {
42 score += 100
43 reasons.push('named in the failure output')
44 } else if (test.paths.some(p => basename(p) === basename(file))) {
45 score += 60
46 reasons.push('its file name appears in the failure output')
47 }
48 if (test.locations.some(l => sameFile(l.file, file))) {
49 score += 50
50 reasons.push('a failure location points into it')
51 }
52 const last = steps[steps.length - 1]!
53 score += Math.round((20 * ((position.get(last) ?? baseIdx) - baseIdx)) / span)
54 reasons.push(`last changed at step ${last}`)
55 score += Math.min(10, 2 * steps.length)
56 if (steps.length > 1) reasons.push(`changed ${steps.length} times`)
57 suspects.push({ path: file, score, reasons, steps })
58 }
59 return suspects.sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : 1)).slice(0, MAX_SUSPECTS)
60}
61
62// For the test run `runN` (default: the last failing one on the path): what changed since the last
63// passing run, ranked; which later edits made it pass; whether a later run failed less.
64export function why(path: readonly Step[], runN?: number): Why | undefined {
65 const runs = path.flatMap((step, i) => (isRun(step) ? [{ step, i }] : []))
66 const target = runN === undefined ? [...runs].reverse().find(r => !r.step.test.ok) : runs.find(r => r.step.n === runN)
67 if (!target) return undefined
68 const { step: run, i: runIdx } = target
69 const okBefore = [...runs].reverse().find(r => r.i < runIdx && r.step.test.ok)
70 const baseIdx = okBefore ? okBefore.i : path[0]?.kind === 'baseline' ? 0 : -1
71 const out: Why = { run, suspects: [] }
72 const base = baseIdx >= 0 ? path[baseIdx] : undefined
73 if (base) out.base = base
74 if (!run.test.ok) out.suspects = rank(path, changesBetween(path, baseIdx, runIdx), run.test, baseIdx, runIdx)
75 const later = runs.filter(r => r.i > runIdx)
76 const fixed = later.find(r => r.step.test.ok)
77 if (!run.test.ok && fixed) {
78 const changes = changesBetween(path, runIdx, fixed.i)
79 out.fixedBy = { run: fixed.step, files: [...changes.keys()], steps: [...new Set([...changes.values()].flat())].sort((a, b) => a - b) }
80 }
81 const before = run.test.failed
82 const fewer = before === undefined ? undefined : later.find(r => r.step.test.failed !== undefined && r.step.test.failed < before && !r.step.test.ok)
83 if (fewer && before !== undefined) out.improved = { run: fewer.step, from: before, to: fewer.step.test.failed! }
84 return out
85}
86hooks/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 225 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 = 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[] }
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 (shown.every(s => s.kind === 'baseline')) return [...lines, '', 'No tool calls recorded yet.'].join('\n')
76 if (steps.length > shown.length) lines.push(`(${steps.length - shown.length} earlier step(s) hidden; /tm timeline --all)`)
77 lines.push('│')
78 let prevBranch = shown[0]!.branch
79 shown.forEach((step, i) => {
80 const last = i === shown.length - 1
81 if (!o.all && step.branch !== prevBranch) lines.push(`│ ⑂ branch ${step.branch} (from ${circled(step.parent)})`)
82 prevBranch = step.branch
83 const tag = o.all && step.branch !== 'main' ? ` [${step.branch}]` : ''
84 const agent = step.agentId ? ` (agent ${step.agentId.slice(0, 6)})` : ''
85 lines.push(`${last ? '└──' : '├──'} ${circled(step.n)} ${clip(label(step), 60)}${agent}${tag}`)
86 for (const note of notes(step)) lines.push(`${last ? ' ' : '│'} └── ${note}`)
87 })
88 return lines.join('\n')
89}
90
91export function renderStep(step: Step): string {
92 const took = step.endedAt !== undefined ? ` · took ${step.endedAt - step.startedAt}ms` : ''
93 const lines = [
94 `${circled(step.n)} ${label(step)}`,
95 `kind ${step.kind} · branch ${step.branch} · parent ${circled(step.parent)} · turn ${step.turn}${step.agentId ? ` · agent ${step.agentId}` : ''}${took}`,
96 ]
97 const keys = Object.keys(step.input)
98 if (keys.length > 0) {
99 lines.push('input:')
100 for (const key of keys) {
101 const value = step.input[key]
102 lines.push(` ${key}: ${clip(typeof value === 'string' ? value : JSON.stringify(value) ?? '', 300)}`)
103 }
104 if (step.inputLossy) lines.push(' (stored input was cut or redacted)')
105 }
106 if (step.result) {
107 lines.push(`result: ${step.result.denied ? 'denied' : step.result.isError ? 'error' : 'ok'} · ${step.result.chars} chars`)
108 for (const line of step.result.head.split('\n').slice(0, 40)) lines.push(` │ ${line}`)
109 if (step.result.chars > step.result.head.length) lines.push(' │ …')
110 }
111 if (step.snapshot) {
112 const s = step.snapshot
113 lines.push(`snapshot: ${s.commit.slice(0, 12)}${s.changed ? '' : ' (no change)'} · ${s.ms}ms${s.concurrent ? ' · concurrent' : ''}`)
114 for (const f of s.files) lines.push(` ${f.status} ${f.path}`)
115 if (s.filesTotal > s.files.length && step.kind !== 'baseline') lines.push(` … +${s.filesTotal - s.files.length} more`)
116 }
117 if (step.test) {
118 lines.push(`test (${step.test.framework}): ${testSummary(step.test)}`)
119 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}` : ''})` : ''}`)
120 }
121 return lines.join('\n')
122}
123
124// `labels(n)` names a step for the fixed-by line (`⑤ Edit middleware.py`); without it, the bare number.
125export function renderWhy(w: Why, labels?: (n: number) => string | undefined): string {
126 const t = w.run.test!
127 const lines = [`Why did ${circled(w.run.n)} ${label(w.run)} ${t.ok ? 'pass' : 'fail'}? ${testSummary(t)} (${t.framework})`]
128 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'}`)
129 if (!t.ok) {
130 if (w.suspects.length === 0) lines.push('Suspects: no file changed in between (look at the environment, test order, or external services)')
131 else {
132 lines.push('Suspects (files changed since then, ranked):')
133 w.suspects.forEach((s, i) => {
134 lines.push(` ${i + 1}. ${s.path} score ${s.score} — ${s.reasons.join('; ')}`)
135 lines.push(` changed by ${s.steps.map(circled).join(' ')}`)
136 })
137 }
138 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(', ')}`)
139 }
140 if (w.fixedBy) {
141 const run = `${circled(w.fixedBy.run.n)} ${label(w.fixedBy.run)} ${testSummary(w.fixedBy.run.test!)}`
142 if (w.fixedBy.steps.length === 0) lines.push(`Passed later at ${run} with no file changes in between (flaky, or the environment changed)`)
143 else if (labels) lines.push(`Fixed by: ${w.fixedBy.steps.map(n => labels(n) ?? circled(n)).join(', ')} → ${run}`)
144 else lines.push(`Fixed by: ${w.fixedBy.steps.map(circled).join(', ')} (${w.fixedBy.files.join(', ')}) → ${run}`)
145 }
146 if (w.improved) lines.push(`Improved: ${w.improved.from} → ${w.improved.to} failed at ${circled(w.improved.run.n)}`)
147 lines.push('(inferred from failure-output mentions, recency and change counts — not proof)')
148 return lines.join('\n')
149}
150
151export function renderCompare(nameA: string, nameB: string, c: Compared, stat?: string): string {
152 const step = (s: Step | undefined): string => (s ? `${circled(s.n)} ${label(s)}` : '(end)')
153 const lines = [`Compare ${nameA} ↔ ${nameB}`, `Shared start: ${c.common} step(s)`, `Diverged at: ${nameA} ${step(c.divergeA)} · ${nameB} ${step(c.divergeB)}`]
154 lines.push(`Only on ${nameA} (${c.onlyA.length}): ${c.onlyA.slice(0, 20).map(step).join(', ') || '—'}`)
155 lines.push(`Only on ${nameB} (${c.onlyB.length}): ${c.onlyB.slice(0, 20).map(step).join(', ') || '—'}`)
156 lines.push(`Matched after the divergence: ${c.matched}`)
157 const test = (s: Step | undefined): string => (s?.test ? testSummary(s.test) : 'no test run')
158 lines.push(`Last test: ${nameA} ${test(c.lastTestA)} · ${nameB} ${test(c.lastTestB)}`)
159 if (stat !== undefined) lines.push(`Files (${nameA} head → ${nameB} head):`, stat.trimEnd() || ' (identical)')
160 return lines.join('\n')
161}
162
163export function renderRerun(step: Step, c: OutputComparison, where: string): string {
164 const lines = [`Rerun of ${circled(step.n)} ${label(step)} (${where})`]
165 if (c.identical) lines.push('Result: identical')
166 else {
167 lines.push(`Result: changed — +${c.addedCount} −${c.removedCount} line(s) (first 2 KiB compared)`)
168 for (const line of c.removed) lines.push(` − ${clip(line, 160)}`)
169 for (const line of c.added) lines.push(` + ${clip(line, 160)}`)
170 }
171 if (c.was && c.now) lines.push(`Test: ${testSummary(c.was)} → ${testSummary(c.now)}`)
172 return lines.join('\n')
173}
174
175// `/tm rerun n --isolated` without `--yes`: the command first, then where it would run and why it may not.
176// `before` is the step whose snapshot the copy would hold; `blocked` says why it cannot run now.
177export function renderIsolatedPlan(step: Step, command: string, before: Step | undefined, blocked?: string): string {
178 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'
179 const lines = [
180 `$ ${command}`,
181 `Dry run of ${circled(step.n)} ${label(step)}: nothing ran. With --yes this runs through \`sh -lc\` in ${copy}, without a permission prompt.`,
182 'Caveats: ignored files such as dependencies are not in the copy; the command still reaches any absolute path it names and your home directory; a change it makes to the project is recorded as an external step.',
183 ]
184 lines.push(blocked !== undefined ? `It would not run now: ${blocked}` : `Run /tm rerun ${step.n} --isolated --yes to run it.`)
185 return lines.join('\n')
186}
187
188// `step` is the step asked for; `target` the step whose snapshot is used (the same, or the nearest earlier one).
189export function renderRestorePlan(step: Step, target: Step, files: readonly FileChange[], applied: boolean, branch?: string, told = true): string {
190 const n = step.n
191 const what = `${circled(n)} ${label(step)}`
192 const head = `snapshot ${target.snapshot?.commit.slice(0, 7) ?? '?'}${target.n !== n ? ` from ${circled(target.n)}` : ''}`
193 const lines = applied
194 ? [`Restored to ${what} (${head}): ${files.length} file(s) changed.`]
195 : [`Restore to ${what} (${head}): ${files.length} file(s) would change.`]
196 for (const f of files.slice(0, 50)) lines.push(` ${f.status} ${f.path}`)
197 if (files.length > 50) lines.push(` … +${files.length - 50} more`)
198 if (applied) {
199 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.'}`)
200 if (branch !== undefined) lines.push(`New steps go to branch '${branch}'. The conversation itself was not rewound.`)
201 } else {
202 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).`)
203 }
204 return lines.join('\n')
205}
206
207export function renderSessions(rows: readonly { id: string; startedAt: number; current: boolean }[]): string {
208 if (rows.length === 0) return 'No recorded sessions for this project.'
209 const sorted = [...rows].sort((a, b) => b.startedAt - a.startedAt)
210 const started = (at: number): string => {
211 const date = new Date(at)
212 return Number.isFinite(at) && Number.isFinite(date.getTime()) ? date.toISOString() : '?'
213 }
214 return ['Recorded sessions (newest first):', ...sorted.map(r => ` ${r.id}${r.current ? ' (this session)' : ''} · started ${started(r.startedAt)}`)].join('\n')
215}
216
217export function statusLine(rec: Recorder, snapMs: readonly number[], off?: string): string {
218 const steps = rec.all().filter(s => s.kind !== 'baseline')
219 const last = steps.at(-1)?.n ?? 0
220 if (off) return `tm ${circled(last)} · snapshots off`
221 const snaps = steps.filter(s => s.snapshot?.changed).length
222 const mean = avg(snapMs)
223 return `tm ${circled(last)} · ${snaps} snaps${mean !== undefined ? ` · ${mean}ms` : ''}`
224}
225hooks/core/store.ts 64 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
47// A session's chunks in write order (load id, then chunk number); lines that are not records are skipped.
48export function parseChunks(files: readonly { name: string; text: string }[]): Rec[] {
49 const out: Rec[] = []
50 const chunks = files.filter(f => f.name.endsWith('.jsonl')).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
51 for (const file of chunks) {
52 for (const line of file.text.split('\n')) {
53 if (line.trim() === '') continue
54 try {
55 const rec = JSON.parse(line) as Rec
56 if (rec && typeof rec === 'object' && typeof rec.type === 'string') out.push(rec)
57 } catch {
58 // a torn or foreign line
59 }
60 }
61 }
62 return out
63}
64hooks/core/testparse.ts 144 lines1import type { TestFailure, TestRun } from './types.ts'
2
3const MAX_FAILURES = 50
4const MAX_LOCATIONS = 100
5const MAX_PATHS = 200
6const SCAN_MAX = 128 * 1024
7const EXT = String.raw`(?:py|[cm]?[jt]sx?|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|scala|exs?)`
8const FILE = String.raw`(?<![\w.@/-])(?:\.{0,2}\/)?(?:[\w.@-]+\/)*[\w.@-]+\.${EXT}`
9const PATH_TOKEN = new RegExp(String.raw`${FILE}\b`, 'g')
10const LOCATION = new RegExp(String.raw`(${FILE})(?::|", line )(\d+)`, 'g')
11const VENDOR = /node_modules|site-packages|dist-packages|\/lib\/python|<frozen|^internal\/|\/rustc\//
12
13type Counts = Pick<TestRun, 'passed' | 'failed' | 'errors' | 'skipped'>
14type Found = Counts & { framework: string; failures: TestFailure[] }
15
16function count(text: string, re: RegExp): Counts {
17 const c: Counts = {}
18 for (const m of text.matchAll(re)) {
19 const n = Number(m[1])
20 const word = (m[2] ?? '').toLowerCase()
21 if (word.startsWith('pass')) c.passed = (c.passed ?? 0) + n
22 else if (word.startsWith('fail')) c.failed = (c.failed ?? 0) + n
23 else if (word.startsWith('error')) c.errors = (c.errors ?? 0) + n
24 else if (word.startsWith('skip') || word.startsWith('ignored')) c.skipped = (c.skipped ?? 0) + n
25 }
26 return c
27}
28
29function parseCargo(text: string): Found | undefined {
30 const results = [...text.matchAll(/test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed/g)]
31 if (results.length === 0) return undefined
32 return {
33 framework: 'cargo',
34 passed: results.reduce((s, m) => s + Number(m[1]), 0),
35 failed: results.reduce((s, m) => s + Number(m[2]), 0),
36 failures: [...text.matchAll(/^---- (\S+) stdout ----$/gm)].map(m => ({ name: m[1]! })),
37 }
38}
39
40function parsePytest(text: string): Found | undefined {
41 const summary = /(?:^|=+ )((?:\d+ (?:failed|passed|skipped|errors?|xfailed|xpassed|deselected|warnings?|rerun)(?:, )?)+) in [\d.]+s/m.exec(text)
42 const lines = [...text.matchAll(/^(?:FAILED|ERROR) (\S+?)(?:::(\S+))?(?: - .*)?$/gm)]
43 if (!summary && lines.length === 0) return undefined
44 return {
45 framework: 'pytest',
46 ...count(summary?.[1] ?? '', /(\d+) (passed|failed|errors?|skipped)\b/g),
47 failures: lines.map(m => ({ name: m[2] ? `${m[1]}::${m[2]}` : m[1]!, file: m[1]! })),
48 }
49}
50
51function parseJest(text: string): Found | undefined {
52 const jest = /^Tests:\s+(.+)$/m.exec(text)
53 const vitest = jest ? undefined : /^[ \t]*Tests\s+(.+?)\s*(?:\(\d+\))?\s*$/m.exec(text)
54 const line = jest?.[1] ?? vitest?.[1]
55 if (line === undefined) return undefined
56 const failures: TestFailure[] = []
57 for (const m of text.matchAll(/^[ \t]*● (.+?)\s*$/gm)) if (!/^Console\b/.test(m[1]!)) failures.push({ name: m[1]! })
58 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]! })
59 return { framework: jest ? 'jest' : 'vitest', ...count(line, /(\d+) (passed|failed|skipped)\b/g), failures }
60}
61
62function parseGo(text: string): Found | undefined {
63 const fails = [...text.matchAll(/^[ \t]*--- FAIL: (\S+)/gm)]
64 const passes = [...text.matchAll(/^[ \t]*--- PASS: /gm)]
65 if (fails.length === 0 && passes.length === 0 && !/^(?:ok|FAIL)\s+\S+\s+[\d.]+s/m.test(text)) return undefined
66 return {
67 framework: 'go',
68 failed: fails.length,
69 ...(passes.length > 0 ? { passed: passes.length } : {}),
70 failures: fails.map(m => ({ name: m[1]! })),
71 }
72}
73
74function parseNodeTest(text: string): Found | undefined {
75 const pass = /^# pass (\d+)$/m.exec(text)
76 const fail = /^# fail (\d+)$/m.exec(text)
77 if (!pass && !fail) return undefined
78 return {
79 framework: 'node:test',
80 ...(pass ? { passed: Number(pass[1]) } : {}),
81 ...(fail ? { failed: Number(fail[1]) } : {}),
82 failures: [...text.matchAll(/^not ok \d+ - (.+)$/gm)].map(m => ({ name: m[1]!.trim() })),
83 }
84}
85
86export function sameFile(a: string, b: string): boolean {
87 const x = a.replace(/^\.\//, '')
88 const y = b.replace(/^\.\//, '')
89 return x === y || x.endsWith(`/${y}`) || y.endsWith(`/${x}`)
90}
91
92// Locations and paths come from the first and last 128 KiB only: a hook must not stall on a huge dump.
93function scanWindow(text: string): string {
94 return text.length <= 2 * SCAN_MAX ? text : `${text.slice(0, SCAN_MAX)}\n${text.slice(-SCAN_MAX)}`
95}
96
97function locationsOf(text: string): { file: string; line: number }[] {
98 const out: { file: string; line: number }[] = []
99 const seen = new Set<string>()
100 for (const m of text.matchAll(LOCATION)) {
101 const file = m[1]!
102 const key = `${file}:${m[2]}`
103 if (VENDOR.test(file) || seen.has(key)) continue
104 seen.add(key)
105 out.push({ file, line: Number(m[2]) })
106 if (out.length >= MAX_LOCATIONS) break
107 }
108 return out
109}
110
111function pathsOf(text: string): string[] {
112 const out = new Set<string>()
113 for (const m of text.matchAll(PATH_TOKEN)) {
114 if (VENDOR.test(m[0])) continue
115 out.add(m[0])
116 if (out.size >= MAX_PATHS) break
117 }
118 return [...out]
119}
120
121// What a test command's output says: framework, counts, failing tests and where they point.
122export function parseTestOutput(text: string, isError: boolean): TestRun {
123 const found = parseCargo(text) ?? parsePytest(text) ?? parseJest(text) ?? parseGo(text) ?? parseNodeTest(text)
124 const window = scanWindow(text)
125 const locations = locationsOf(window)
126 const failures = (found?.failures ?? []).slice(0, MAX_FAILURES).map(f => ({ ...f }))
127 for (const loc of locations) {
128 const failure = failures.find(f => f.file !== undefined && f.line === undefined && sameFile(f.file, loc.file))
129 if (failure) failure.line = loc.line
130 }
131 const run: TestRun = {
132 framework: found?.framework ?? 'unknown',
133 ok: !isError && (found?.failed ?? 0) === 0 && (found?.errors ?? 0) === 0,
134 failures,
135 locations,
136 paths: pathsOf(window),
137 }
138 if (found?.passed !== undefined) run.passed = found.passed
139 if (found?.failed !== undefined) run.failed = found.failed
140 if (found?.errors !== undefined) run.errors = found.errors
141 if (found?.skipped !== undefined) run.skipped = found.skipped
142 return run
143}
144hooks/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 124 lines1import { classifyCommand } from './classify.ts'
2import { contentHash } from './hash.ts'
3import { 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 walk = (value: unknown, depth: number): unknown => {
72 if (typeof value === 'string') {
73 const masked = redactSecrets(value)
74 if (masked !== value) lossy = true
75 if (masked.length <= INPUT_STRING_MAX) return masked
76 lossy = true
77 return `${masked.slice(0, INPUT_STRING_MAX)}…(+${masked.length - INPUT_STRING_MAX} chars, #${contentHash(value)})`
78 }
79 if (Array.isArray(value)) {
80 if (depth > 4) {
81 lossy = true
82 return '[…]'
83 }
84 return value.map(v => walk(v, depth + 1))
85 }
86 if (value && typeof value === 'object') {
87 if (depth > 4) {
88 lossy = true
89 return '{…}'
90 }
91 return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, walk(v, depth + 1)]))
92 }
93 return value
94 }
95 const out: Record<string, unknown> = {}
96 for (const [key, value] of Object.entries(input)) if (!ENGINE_KEYS.has(key)) out[key] = walk(value, 0)
97 return { input: out, lossy }
98}
99
100// The input exactly as the tool got it, engine keys aside: what a rerun passes back.
101export function callInput(input: Record<string, unknown>): Record<string, unknown> {
102 return Object.fromEntries(Object.entries(input).filter(([key]) => !ENGINE_KEYS.has(key)))
103}
104
105export function resultText(answer: unknown): string {
106 const a = (answer ?? {}) as Record<string, unknown>
107 if (typeof a.text === 'string') return a.text
108 if (typeof a.deny === 'string') return a.deny
109 return ''
110}
111
112export function summarizeResult(answer: unknown): StepResult {
113 const a = (answer ?? {}) as Record<string, unknown>
114 const denied = typeof a.deny === 'string'
115 const text = resultText(answer)
116 return {
117 isError: denied || a.isError === true,
118 denied,
119 chars: text.length,
120 hash: contentHash(text),
121 head: redactSecrets(text.slice(0, RESULT_HEAD_MAX)),
122 }
123}
124hooks/core/types.ts 81 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 ok: boolean
23 passed?: number
24 failed?: number
25 errors?: number
26 skipped?: number
27 failures: TestFailure[]
28 locations: { file: string; line: number }[]
29 // Path-like tokens anywhere in the whole output, for causality.
30 paths: string[]
31}
32
33export type StepKind = 'baseline' | 'tool' | 'external' | 'restore'
34
35export type StepResult = { isError: boolean; denied: boolean; chars: number; hash: string; head: string }
36
37export type Step = {
38 n: number
39 id: string
40 turn: number
41 agentId?: string
42 branch: string
43 parent: number
44 tool: string
45 kind: StepKind
46 input: Record<string, unknown>
47 // The stored input was cut or redacted, so it cannot be replayed from the log alone.
48 inputLossy?: boolean
49 target?: string
50 startedAt: number
51 endedAt?: number
52 result?: StepResult
53 snapshot?: SnapshotRef
54 test?: TestRun
55 rerunOf?: number
56}
57
58export type TurnRec = { n: number; at: number; prompt: { chars: number; hash: string; head?: string } }
59
60export type ModelRec = {
61 turn: number
62 index: number
63 agentId?: string
64 model: string
65 stopReason: string | null
66 usage: unknown
67 text: { chars: number; hash: string; body?: string }
68 toolUses: { id: string; tool: string }[]
69}
70
71export type BranchRec = { name: string; fromStep: number; at: number }
72
73export type SessionRec = { sessionId: string; root: string; startedAt: number; loadId: string }
74
75export type Rec =
76 | ({ type: 'session' } & SessionRec)
77 | ({ type: 'turn' } & TurnRec)
78 | ({ type: 'step' } & Step)
79 | ({ type: 'model' } & ModelRec)
80 | ({ type: 'branch' } & BranchRec)
81hooks/git.ts 304 lines1import { type FileChange, MAX_FILES } from './core/types.ts'
2
3export { MAX_FILES }
4
5export type RunResult = { exitCode: number; stdout: string; stderr: string }
6export type RunOptions = { env?: Record<string, string>; cwd?: string; timeoutMs?: number }
7export type Runner = (argv: readonly string[], opts?: RunOptions) => Promise<RunResult>
8export type SnapResult = { commit: string; changed: boolean; files: FileChange[]; filesTotal: number }
9
10type GitOptions = { index?: string; workTree?: string; timeoutMs?: number; runner?: Runner }
11
12const TIMEOUT_MS = 30_000
13const STALE_LOCK_MS = 120_000
14const DIFF_BYTES = 1024 * 1024
15const IDENTITY = {
16 GIT_AUTHOR_NAME: 'time-machine',
17 GIT_AUTHOR_EMAIL: 'tm@local',
18 GIT_COMMITTER_NAME: 'time-machine',
19 GIT_COMMITTER_EMAIL: 'tm@local',
20}
21
22export class GitError extends Error {
23 constructor(
24 readonly args: readonly string[],
25 readonly result: RunResult,
26 ) {
27 const lines = result.stderr.split('\n').map(l => l.trim()).filter(l => l !== '')
28 const line = lines.find(l => l.startsWith('fatal:') || l.startsWith('error:')) ?? lines[0] ?? ''
29 super(`git ${args[0] ?? ''} failed (${result.exitCode}): ${line}`)
30 }
31}
32
33// `--name-status` lines: `M\tpath`, `R100\told\tnew`, `C75\tfrom\tto`; the path kept is the new one.
34export function parseNameStatus(out: string): FileChange[] {
35 const files: FileChange[] = []
36 for (const line of out.split('\n')) {
37 if (line.trim() === '') continue
38 const parts = line.split('\t')
39 if (parts.length < 2) continue
40 files.push({ status: parts[0]!.charAt(0), path: parts[parts.length - 1]! })
41 }
42 return files
43}
44
45// A path strictly below `parent`, spelled without empty, `.` or `..` segments; guards `rm -rf`.
46export function isInside(child: string, parent: string): boolean {
47 const base = parent.replace(/\/+$/, '')
48 if (base === '') return false
49 const c = child.replace(/\/+$/, '')
50 if (!c.startsWith(`${base}/`) || c.length === base.length + 1) return false
51 return !c.split('/').slice(1).some(seg => seg === '' || seg === '.' || seg === '..')
52}
53
54// Ignored, untracked paths (`ls-files --directory`: a wholly ignored directory as `dir/`) that a tree's
55// paths would overwrite or delete: an equal path, or one a directory prefix of the other.
56export function ignoredConflicts(ignored: readonly string[], target: readonly string[]): string[] {
57 const ign = new Set(ignored.map(p => p.replace(/\/+$/, '')).filter(p => p !== ''))
58 const tgt = new Set(target.filter(p => p !== ''))
59 const dirsOf = (path: string): string[] => {
60 const parts = path.split('/')
61 return parts.slice(1).map((_, i) => parts.slice(0, i + 1).join('/'))
62 }
63 const out = new Set<string>()
64 for (const t of tgt) for (const p of [t, ...dirsOf(t)]) if (ign.has(p)) out.add(p)
65 for (const i of ign) if (dirsOf(i).some(d => tgt.has(d))) out.add(i)
66 return [...out].sort()
67}
68
69// Wholly ignored directories (`dir/`) that only hold a target path, without being one: whether they
70// conflict depends on the ignored files inside them, so they are listed file by file before judging.
71// A leftover `pkg/__pycache__/` beside a restored `pkg/a.py` is not a conflict.
72export function ignoredDirsToExpand(ignored: readonly string[], target: readonly string[]): string[] {
73 const tgt = new Set(target.filter(p => p !== ''))
74 const holders = new Set<string>()
75 for (const t of tgt) {
76 const parts = t.split('/')
77 for (let i = 1; i < parts.length; i += 1) holders.add(parts.slice(0, i).join('/'))
78 }
79 return ignored.filter(p => {
80 if (!p.endsWith('/')) return false
81 const dir = p.replace(/\/+$/, '')
82 return holders.has(dir) && !tgt.has(dir)
83 })
84}
85
86const MAX_NAMED = 10
87
88// A bare repository beside the project: its own index per session, the project as work tree, so the
89// user's .git is never read or written. The host's `.gitignore` files apply.
90export class ShadowGit {
91 private queue: Promise<unknown> = Promise.resolve()
92
93 constructor(
94 // Any live hook's `$.process.run`; set again by each hook that uses git.
95 public runner: Runner,
96 readonly gitDir: string,
97 readonly workTree: string,
98 readonly indexFile: string,
99 ) {}
100
101 // One operation at a time: parallel tool calls share the index. The runner is the one set when the
102 // operation was asked for, not whichever hook set it last by the time the operation runs.
103 serial<T>(fn: (run: Runner) => Promise<T>): Promise<T> {
104 const runner = this.runner
105 const go = (): Promise<T> => fn(runner)
106 const run = this.queue.then(go, go)
107 this.queue = run.catch(() => undefined)
108 return run
109 }
110
111 async git(args: readonly string[], opts: GitOptions = {}): Promise<string> {
112 const result = await this.exec(args, opts)
113 if (result.exitCode !== 0) throw new GitError(args, result)
114 return result.stdout
115 }
116
117 // The raw result, a nonzero exit included.
118 exec(args: readonly string[], opts: GitOptions = {}): Promise<RunResult> {
119 const { argv, options } = this.invocation(args, opts)
120 return (opts.runner ?? this.runner)(argv, options)
121 }
122
123 private invocation(args: readonly string[], opts: GitOptions): { argv: string[]; options: RunOptions } {
124 const workTree = opts.workTree ?? this.workTree
125 const argv = ['git', `--git-dir=${this.gitDir}`, `--work-tree=${workTree}`, '-c', 'core.quotepath=off', ...args]
126 return {
127 argv,
128 options: {
129 cwd: workTree,
130 env: {
131 ...IDENTITY,
132 GIT_DIR: this.gitDir,
133 GIT_WORK_TREE: workTree,
134 GIT_COMMON_DIR: this.gitDir,
135 GIT_OBJECT_DIRECTORY: `${this.gitDir}/objects`,
136 GIT_ALTERNATE_OBJECT_DIRECTORIES: '',
137 GIT_NAMESPACE: '',
138 // Empty clears `-c` settings inherited from a parent git; empty GIT_COMMON_DIR would break git.
139 GIT_CONFIG_PARAMETERS: '',
140 GIT_INDEX_FILE: opts.index ?? this.indexFile,
141 },
142 timeoutMs: opts.timeoutMs ?? TIMEOUT_MS,
143 },
144 }
145 }
146
147 // Idempotent: re-running init on an existing repository leaves it as it is.
148 async init(): Promise<void> {
149 const args = ['init', '--bare', '--quiet', this.gitDir]
150 const result = await this.runner(['git', ...args], {
151 env: { GIT_COMMON_DIR: this.gitDir, GIT_OBJECT_DIRECTORY: `${this.gitDir}/objects`, GIT_ALTERNATE_OBJECT_DIRECTORIES: '', GIT_NAMESPACE: '', GIT_CONFIG_PARAMETERS: '' },
152 timeoutMs: TIMEOUT_MS,
153 })
154 if (result.exitCode !== 0) throw new GitError(args, result)
155 await this.syncExcludes()
156 }
157
158 // The user's local `.git/info/exclude` applies to the shadow repository too.
159 async syncExcludes(): Promise<void> {
160 const src = `${this.workTree}/.git/info/exclude`
161 const dst = `${this.gitDir}/info/exclude`
162 const args = ['sh', '-c', 'if [ -f "$1" ]; then mkdir -p "$(dirname "$2")" && cp "$1" "$2"; fi', 'tm-sync', src, dst]
163 const result = await this.runner(args, { timeoutMs: TIMEOUT_MS })
164 if (result.exitCode !== 0) throw new GitError(['sync-excludes'], result)
165 }
166
167 // Stage the work tree; commit only when its tree differs from the parent's. The parent is the ref's
168 // commit when it is read, in turn, so parallel snapshots chain; `parent` is used only while the ref
169 // does not exist yet (undefined for the baseline).
170 snapshot(parent: string | undefined, message: string, ref: string): Promise<SnapResult> {
171 return this.serial(async runner => {
172 try {
173 return await this.snapshotNow(parent, message, ref, runner)
174 } catch (error) {
175 if (!(await this.clearStaleLock(error, runner))) throw error
176 return this.snapshotNow(parent, message, ref, runner)
177 }
178 })
179 }
180
181 // A lock git left behind (a git killed by its timeout, a crashed process) blocks every later
182 // snapshot. Removed only when it is this repository's or this session's index's, and older than
183 // any live git would hold it: every call here times out well within STALE_LOCK_MS.
184 private async clearStaleLock(error: unknown, runner: Runner): Promise<boolean> {
185 if (!(error instanceof GitError)) return false
186 const lock = /Unable to create '([^']+\.lock)': File exists/.exec(error.result.stderr)?.[1]
187 if (lock === undefined || !(lock === `${this.indexFile}.lock` || isInside(lock, this.gitDir))) return false
188 const mins = String(Math.ceil(STALE_LOCK_MS / 60_000))
189 const r = await runner(['find', lock, '-maxdepth', '0', '-type', 'f', '-mmin', `+${mins}`, '-delete', '-print'], { timeoutMs: TIMEOUT_MS })
190 return r.exitCode === 0 && r.stdout.trim() !== ''
191 }
192
193 private async snapshotNow(parent: string | undefined, message: string, ref: string, runner: Runner): Promise<SnapResult> {
194 const at = await this.exec(['rev-parse', '--verify', '-q', ref], { runner })
195 const base = at.exitCode === 0 && at.stdout.trim() !== '' ? at.stdout.trim() : parent
196 await this.git(['add', '-A'], { runner })
197 const tree = (await this.git(['write-tree'], { runner })).trim()
198 if (base !== undefined) {
199 const baseTree = (await this.git(['rev-parse', `${base}^{tree}`], { runner })).trim()
200 if (baseTree === tree) return { commit: base, changed: false, files: [], filesTotal: 0 }
201 }
202 const commit = (await this.git(['commit-tree', tree, ...(base !== undefined ? ['-p', base] : []), '-m', message], { runner })).trim()
203 await this.git(['update-ref', ref, commit], { runner })
204 if (base === undefined) {
205 const listed = (await this.git(['ls-tree', '-r', '--name-only', commit], { runner })).split('\n').filter(l => l !== '')
206 return { commit, changed: true, files: [], filesTotal: listed.length }
207 }
208 const files = parseNameStatus(await this.git(['diff', '--name-status', '-M', base, commit], { runner }))
209 return { commit, changed: true, files: files.slice(0, MAX_FILES), filesTotal: files.length }
210 }
211
212 nameStatus(a: string, b: string): Promise<FileChange[]> {
213 return this.serial(async runner => parseNameStatus(await this.git(['diff', '--name-status', '-M', a, b], { runner })))
214 }
215
216 // The patch's first `maxLines` lines and whether there were more: `head` ends git early, so a huge
217 // diff (a lockfile, a generated bundle) is neither read whole nor left to run into the timeout.
218 diffText(a: string, b: string, maxLines = Number.POSITIVE_INFINITY): Promise<{ stat: string; patch: string; more: boolean }> {
219 return this.serial(async runner => {
220 const stat = await this.git(['diff', '--stat', a, b], { runner })
221 if (!Number.isFinite(maxLines)) return { stat, patch: await this.git(['diff', a, b], { runner }), more: false }
222 const { argv, options } = this.invocation(['diff', a, b], {})
223 const r = await runner(['sh', '-c', 'if (set -o pipefail) 2>/dev/null; then set -o pipefail; fi; "$@" | head -n "$TM_LINES" | head -c "$TM_BYTES"; s=$?; [ "$s" -eq 141 ] && s=0; exit $s', 'tm-diff', ...argv], {
224 ...options,
225 // A minified bundle's single line can be megabytes: bytes are bounded too.
226 env: { ...options.env, TM_LINES: String(maxLines + 1), TM_BYTES: String(DIFF_BYTES) },
227 })
228 if (r.exitCode !== 0) throw new GitError(['diff', a, b], r)
229 const lines = r.stdout.replace(/\n$/, '').split('\n')
230 const more = lines.length > maxLines || r.stdout.length >= DIFF_BYTES
231 return { stat, patch: (more ? lines.slice(0, maxLines) : lines).join('\n'), more }
232 })
233 }
234
235 // The work tree made to match `target`: tracked files rewritten, files `target` lacks removed, ignored
236 // files untouched. `from` is the commit the caller just snapshotted; if the work tree no longer
237 // matches it, nothing is touched, so unsnapshotted work is never lost. The result is a new commit with
238 // `target`'s tree and both `from` and `target` as parents, so nothing recorded becomes unreachable;
239 // `ref` points at it and it is returned. With `createRef`, `ref` is a new branch's and must not exist
240 // yet (refs are files: on a case-insensitive disk `Main` is `main`). The commit is made before the
241 // files are touched, so only moving the ref can fail after them. Ignored files are never snapshotted:
242 // one `target` would overwrite or delete makes the restore refuse, touching nothing.
243 restore(target: string, ref: string, from: string, message: string, createRef = false): Promise<string> {
244 return this.serial(async runner => {
245 if (createRef && (await this.exec(['rev-parse', '--verify', '-q', ref], { runner })).exitCode === 0) throw new Error('branch ref already exists')
246 await this.git(['add', '-A'], { runner })
247 const tree = (await this.git(['write-tree'], { runner })).trim()
248 const fromTree = (await this.git(['rev-parse', `${from}^{tree}`], { runner })).trim()
249 if (tree !== fromTree) throw new Error('the work tree changed since the last snapshot; run the restore again')
250 const nul = (out: string): string[] => out.split('\0').filter(p => p !== '')
251 // Directory names are paths, never globs: `--literal-pathspecs` keeps `[`, `*` or `:` in them literal.
252 const listIgnored = async (more: string[]): Promise<string[]> =>
253 nul(await this.git([...(more.includes('--') ? ['--literal-pathspecs'] : []), 'ls-files', '-z', '--others', '--ignored', '--exclude-standard', ...more], { runner }))
254 let ignored = await listIgnored(['--directory'])
255 const targetPaths = nul(await this.git(['ls-tree', '-r', '-z', '--name-only', target], { runner }))
256 const expand = ignoredDirsToExpand(ignored, targetPaths)
257 if (expand.length > 0) {
258 const drop = new Set(expand)
259 ignored = [...ignored.filter(p => !drop.has(p)), ...(await listIgnored(['--', ...expand]))]
260 }
261 const conflicts = ignoredConflicts(ignored, targetPaths)
262 if (conflicts.length > 0) {
263 const more = conflicts.length > MAX_NAMED ? ` (and ${conflicts.length - MAX_NAMED} more)` : ''
264 throw new Error(`restore would overwrite ignored files that no snapshot holds: ${conflicts.slice(0, MAX_NAMED).join(', ')}${more}; move them aside and run the restore again`)
265 }
266 const commit = (await this.git(['commit-tree', `${target}^{tree}`, '-p', from, '-p', target, '-m', message], { runner })).trim()
267 await this.git(['read-tree', '-u', '--reset', target], { runner })
268 try {
269 await this.git(['update-ref', ref, commit], { runner })
270 } catch (error) {
271 throw new Error(`files were restored but the restore could not be recorded: ${error instanceof Error ? error.message : String(error)}`)
272 }
273 return commit
274 })
275 }
276
277 updateRef(ref: string, commit: string): Promise<void> {
278 return this.serial(async runner => {
279 await this.git(['update-ref', ref, commit], { runner })
280 })
281 }
282
283 // A snapshot's files written into `dir` (existing and empty) through a throwaway index beside it.
284 exportTo(commit: string, dir: string): Promise<void> {
285 return this.serial(async runner => {
286 await this.git(['read-tree', '-u', '--reset', commit], { index: `${dir}.index`, workTree: dir, runner })
287 })
288 }
289
290 deleteRefs(prefix: string): Promise<void> {
291 return this.serial(async runner => {
292 const refs = (await this.git(['for-each-ref', '--format=%(refname)', prefix], { runner })).split('\n').filter(l => l !== '')
293 for (const ref of refs) await this.git(['update-ref', '-d', ref], { runner })
294 })
295 }
296
297 // Only when git judges it due; never prunes recent loose objects.
298 gc(): Promise<void> {
299 return this.serial(async runner => {
300 await this.git(['gc', '--auto', '--quiet'], { timeoutMs: 120_000, runner })
301 })
302 }
303}
304