SLOPSHOPPER

time-machine

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

newpaneguardcommandstatusprocess
v0.1.0no licenseupdated 2026-10-10YeonwooSung/my-claude-code-mods/time-machine
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · time-machine
│ ┃ Time Machine ✕ › fix the failing auth test and add an audit log call │ ┃ Session preview- · branch main · 9 step(s) · │ ┃ 0 snapshot… ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ ├── ⓪ baseline ⏺ Update(src/auth.ts) │ ┃ │ └── snapshot · 0 file(s) ⎿ Added 2 lines, removed 1 line │ ┃ ├── ① Read src/auth.ts ⏺ Bash(bun test) │ ┃ ├── ② Grep refresh\( ⎿ 3 pass, 1 fail │ ┃ ├── ③ Edit src/auth.ts │ ┃ ├── ④ Write src/audit.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ├── ⑤ Write src/cache.ts │ ┃ ├── ⑥ Bash bun test ✻ Worked for 42s · done 4:20 PM │ ┃ │ └── ✗ failed │ ┃ ├── ⑦ Bash git status --porcelain › /tm │ ┃ ├── ⑧ Bash rm -rf build && git push --force ⎿ time-machine: Session preview- · branch main · 9 step(s) · 0 sna │ ┃ origin main ⎿ time-machine: │ │ ┃ └── ⑨ Bash cat .env ⎿ time-machine: ├── ⓪ baseline │ ⎿ time-machine: │ └── snapshot · 0 file(s) │ ⎿ time-machine: ├── ① Read src/auth.ts │ ⎿ time-machine: ├── ② Grep refresh\( │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ time-machine: tm ⑨ · 0 snaps · 0ms

Draws

Pane · Time Machine
Session preview- · branch main · 9 step(s) · 0 snapshot… │ ├── ⓪ baseline │ └── snapshot · 0 file(s) ├── ① Read src/auth.ts ├── ② Grep refresh\( ├── ③ Edit src/auth.ts ├── ④ Write src/audit.ts ├── ⑤ Write src/cache.ts ├── ⑥ Bash bun test │ └── ✗ failed ├── ⑦ Bash git status --porcelain ├── ⑧ Bash rm -rf build && git push --force origin main └── ⑨ Bash cat .env
README

Time Machine — 에이전트 타임머신

에이전트가 여러 파일을 바꾸다 엉뚱한 결과를 냈을 때, 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: …)와 다른 하위 명령의 메시지에 나온다.

어떻게 기록하나

  • 섀도 git: <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는 제외).
  • 스냅샷 시점: 변경성 도구(Edit, MultiEdit, Write, NotebookEdit, Bash, 알 수 없는 도구·MCP 도구) 뒤에 찍는다. Bash는 기본으로 모두 찍는다 — cat > f <<EOF 같은 리디렉션도 파일을 쓰기 때문이다. 변경이 없으면 커밋하지 않는다. 설정 skipSearchBash를 켜면 검색으로 분류된 Bash(rg, grep, ls, cat …)는 건너뛴다. 읽기 전용 도구는 찍지 않는다.
  • 외부 변경 step: 변경성 도구 *전*에 현재 트리가 직전 스냅샷과 다르면(사람이 IDE에서 고친 것 등) 그 차이를 external step으로 기록해 에이전트 탓으로 돌리지 않는다.
  • 베이스라인: 첫 변경성 도구 직전(또는 git을 쓰는 첫 /tm 명령)에 찍는다. 세션 시작을 늦추지 않기 위해서다.
  • 동시 도구 호출: 다른 변경성 도구가 진행 중일 때 찍은 스냅샷은 concurrent로 표시한다. 외부 변경 검사는 다른 변경성 도구가 진행 중이면 건너뛴다(그 도구의 쓰기를 외부 변경으로 돌리지 않기 위해).
  • 보존·정리: 오래된 세션 정리는 시작 후 분리되어(detached) 하루 한 번 돌고, git gc --auto를 쓴다. 지운 세션의 인덱스 파일과, 중단된 --isolated 재실행이 남긴 하루 넘은 임시 폴더도 함께 지운다.
  • 저장되는 대상: Bash 명령, 패턴, URL, 에이전트 설명 같은 대상 문자열은 비밀값을 마스킹하고 120자에서 자른다.

관찰 가능성 등급

등급예
exact스냅샷 시점의 추적 파일 내용(섀도 git 커밋), 도구 입력, 도구 결과 텍스트(앞부분)와 해시, 테스트 명령의 종료 상태
parsed테스트 출력에서 읽은 통과/실패 수, 실패 테스트 이름, 실패 위치 file:line (파서가 아는 형식만: pytest, jest/vitest, go, cargo, node:test)
inferred인과 용의자 순위, "fixed by", 경로 비교의 분기점
not reproduced모델 응답, 외부 서비스, 시간, 난수

인과 (/tm why)

  • 기준: 같은 branch에서 실패한 실행 F 이전의 마지막 통과 테스트 실행(없으면 베이스라인). 기준 스냅샷부터 F 스냅샷 사이에 바뀐 파일마다 점수를 매긴다.
  • 점수 규칙: 실패 출력에 경로가 언급되면 +100, 파일명(basename)만 언급되면 +60, 실패 위치(file:line)가 가리키면 +50, 최근성(F에 가까울수록) 0–20, 그 파일을 바꾼 step 수 ×2, 최대 10. 상위 5개를 근거와 바꾼 step 번호와 함께 보인다.
  • fixed by: F 다음 첫 통과 실행 P가 있으면, F와 P 사이에 바뀐 파일과 step을 보인다. 실패 수가 줄면(예: 3 failed → 1 failed) "improved"로 표시한다.
  • 이것은 추론이지 증명이 아니다. 순위는 단서일 뿐이다.

복구와 분기

  • /tm restore <n>과 /tm branch <n> [name]은 --yes 없이 dry run이다: 바뀔 파일(A/M/D)과 개수만 보이고 끝난다.
  • --yes를 주면 먼저 현재 상태를 복구 전 스냅샷으로 남긴 뒤, 작업 트리를 대상 step의 상태로 맞춘다. 대상에 없는 추적 파일은 지워지고, 무시된 파일은 그대로 둔다.
  • 대상 step이 지금은 무시되는 경로를 담고 있으면(예: 예전에 추적되던 .env가 지금은 .gitignore에 있음, 또는 파일 out이 지금은 무시되는 디렉터리 out/) 복구는 아무것도 건드리지 않고 거부한다: 무시 파일은 어느 스냅샷에도 없어 덮어쓰면 되돌릴 수 없기 때문이다. 메시지에 나온 경로를 옮겨 두고 다시 실행하면 된다. 무시 파일만 남은 디렉터리(예: pkg/a.py를 복구하는데 pkg/ 안에 __pycache__/만 남은 경우)는 안의 파일 단위로 판단하므로, 실제로 겹치는 파일이 없으면 복구한다.
  • 복구와 분기는 대상의 트리를 가진 새 커밋(부모 = 복구 전 상태와 대상)으로 기록되므로 모든 스냅샷이 계속 도달 가능하고, 복구 자체도 되돌릴 수 있다. 안전 스냅샷과 복구 사이에 파일이 바뀌었으면 복구는 거부된다("the work tree changed since the last snapshot; run the restore again") — 다시 실행하면 된다.
  • 분기 복구 step은 /tm why의 변경 목록에 파일을 보태지 않는다: 그 트리는 step n과 같아서, 떠난 branch에서만 바뀐 파일이 새 branch의 실패 용의자로 나오지 않는다. 작업 트리가 어떻게 바뀌었는지는 타임라인(work tree: …)과 /tm show의 workTree에 보인다.
  • 분기 이름은 대소문자를 구분하지 않고 중복을 검사하고, git ref 이름으로 유효한지 적용 전에 확인한다.
  • 대화에는 모델이 읽는 노트가 추가된다("작업 트리를 step n으로 되돌렸다; 이 파일들에 대한 이전 도구 결과는 오래됐으니 편집 전에 다시 읽어라"). 대화 자체는 되감거나 포크하지 않는다 — 파일 상태와 타임라인만 움직인다.
  • --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으로 남긴다.
  • 저장된 입력이 잘렸거나 마스킹된 step은 이번 로드에서 기록한 원본 입력이 메모리에 있을 때만(최근 500 step, 합계 32 MiB 이내) 재실행할 수 있다. 다른 로드(리로드·재개)에서는 재실행할 수 없다.
  • 모델 응답은 재실행하지 않는다.

파일 복구 ≠ 결정적 재현

v1은 파일을 되돌리고 도구를 다시 실행할 뿐, 모델 응답·외부 서비스(WebFetch, MCP)·시간·난수는 재현하지 않는다. 모델 턴은 요약·해시만 기록한다.

v2 설계 요약(구현하지 않음): (1) turn.step 훅이 기록된 청크를 내보내는 모델 응답 카세트, 키 = 요청 메시지 해시 + 모델 + 인덱스. (2) tool.call 훅이 next 없이 결과를 답하는 도구 결과 카세트, 키 = (도구, 입력 해시, 직전 스냅샷 커밋). (3) WebFetch/WebSearch/MCP는 도구 결과 카세트로 덮고, 네트워크를 쓰는 Bash는 재현 불가로 표시. (4) 시간·난수 의존은 경고만. (5) 재현 실행 = 새 세션 + 베이스라인 복구 + 카세트 모드; 카세트 miss 지점이 "비결정적 분기점"이다.

설정

키기본값내용
storeDir"" (~/.claude/time-machine)스냅샷과 타임라인을 두는 곳. 프로젝트별 폴더. 절대 경로(또는 ~/…)여야 하고 프로젝트 밖이어야 한다(아니면 스냅샷이 꺼진다)
retentionDays14이보다 오래된 세션 기록을 삭제(하루 한 번, 시작 후 분리 실행). 0이면 모두 보존
recordModelOutputfalse모델 응답 본문과 프롬프트 앞 200자를 저장. 끄면 길이와 해시만
skipSearchBashfalse검색으로 분류된 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 입력으로 같은 프로세스에서

한계

  • git이 없거나, 루트가 HOME 또는 /이거나, storeDir이 상대 경로이거나 프로젝트 안이거나, 베이스라인 스냅샷이 5초를 넘으면 스냅샷이 꺼진다(타임라인은 계속 기록). 이후 스냅샷 하나가 실패하면(타임아웃, 다른 프로세스와의 경합) 그 step만 스냅샷 없이 남고, 3분 넘게 연속 3번 이상 실패해야 꺼진다. 죽은 git이 남긴 lock 파일은 2분이 지나면 지우고 다시 시도한다. 이유는 /tm 타임라인 머리와 다른 하위 명령의 메시지에 보인다(상태줄에는 snapshots off만).
  • 중첩된 git 저장소에 커밋이 없으면 git add -A가 실패해 스냅샷이 계속 실패하고, 결국 꺼진다. 중첩 저장소의 내용은 스냅샷되지 않는다(gitlink로만 남는다).
  • 큰 저장소에서는 변경성 도구마다 스냅샷 시간이 더해진다(상태줄에 평균).
  • 프로젝트 안에서 계속 바뀌는 로그 파일이 있으면 변경성 도구마다 앞에 external change step이 생긴다.
  • 백그라운드 Bash가 나중에 바꾼 파일은 다음 스냅샷에 붙는다. 동시 도구 호출의 스냅샷 귀속은 근사다.
  • --isolated는 작업 디렉터리만 바꾼다. 절대 경로를 쓰는 명령은 여전히 실제 트리를 건드린다.
  • 테스트 파서가 모르는 형식은 종료 상태만 쓴다. 매우 긴 테스트 출력의 중간(처음/마지막 128 KiB 밖)에 있는 위치는 인과에 쓰이지 않는다.
  • 무시 파일(빌드 산출물, node_modules, .env)은 스냅샷·복구 대상이 아니다.
  • Claude Code 내장 /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, 한 프로세스)로 확인한다.

Source 13 files
hooks/register.tsx 716 lines
1import 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}
716
hooks/core/causality.ts 86 lines
1import { 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}
86
hooks/core/classify.ts 191 lines
1export 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}
191
hooks/core/compare.ts 103 lines
1import { 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}
103
hooks/core/hash.ts 21 lines
1// 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}
21
hooks/core/render.ts 225 lines
1import 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}
225
hooks/core/store.ts 64 lines
1import { 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}
64
hooks/core/testparse.ts 144 lines
1import 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}
144
hooks/core/timeline.ts 232 lines
1import { 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}
232
hooks/core/tools.ts 124 lines
1import { 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}
124
hooks/core/types.ts 81 lines
1export 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)
81
hooks/git.ts 304 lines
1import { 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