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

newpaneguardcommandtoaststatus
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) │ ┃ storage: recording ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ ├── ⓪ baseline ⎿ Added 2 lines, removed 1 line │ ┃ │ └── snapshot · 0 file(s) ⏺ Bash(bun test) │ ┃ ├── ① Read src/auth.ts ⎿ 3 pass, 1 fail │ ┃ ├── ② Grep refresh\( │ ┃ ├── ③ Edit src/auth.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ├── ④ Write src/audit.ts │ ┃ ├── ⑤ Write src/cache.ts ✻ Worked for 42s · done 4:20 PM │ ┃ ├── ⑥ Bash bun test │ ┃ │ └── ✗ failed › /tm │ ┃ ├── ⑦ Bash git status --porcelain ⎿ time-machine: Session preview- · branch main · 9 step(s) · 0 sna │ ┃ ├── ⑧ Bash rm -rf build && git push --force ⎿ time-machine: storage: recording │ ┃ origin main ⎿ time-machine: │ │ ┃ └── ⑨ Bash cat .env ⎿ time-machine: ├── ⓪ baseline │ ⎿ time-machine: │ └── snapshot · 0 file(s) │ ⎿ time-machine: ├── ① Read src/auth.ts │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ time-machine: tm ⑨ · 0 snaps · 0ms · recording

Draws

Pane · Time Machine
Session preview- · branch main · 9 step(s) · 0 snapshot… storage: recording │ ├── ⓪ 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 — a time machine for agents

English | 한국어

When an agent changes several files and ends up somewhere wrong, a Git diff only tells you "what changed". Time Machine records in what order, which tool call, on which file state, produced which result, and lets you go back to any step to see the files and tool calls as they were then. It also supports tracing causality between file edits and test failures, restoring a point in time, branching, comparing two runs, and re-running a step with the same input.

Session #184
├── ① Read auth.py
├── ② Grep JWT
├── ③ Edit auth.py            └── snapshot A
├── ④ Bash pytest             └── 3 failed
├── ⑤ Edit middleware.py      └── snapshot B
└── ⑥ Bash pytest             └── 12 passed   (fixed by ⑤)

Install

/plugin marketplace add YeonwooSung/my-claude-code-mods
/plugin install time-machine@my-claude-code-mods

Requires Claude Code 2.1.287 or later (mods enabled by default) and git.

Commands

Everything goes through a single /tm <subcommand>.

CommandWhat it does
/tm or /tm timeline [--all] [--branch b]Timeline tree (last 30 steps; --all for everything). Each step shows its number, tool, target and markers (test result, denied, error, snapshot)
/tm show <n>Step details: input (truncated display), result head, changed files, test result, snapshot commit and time
/tm diff <a> [b]diff --stat and patch between the snapshots of steps a and b (default: current); at most 400 lines, nothing past that is read
/tm why [n]Suspects and fixed-by for test run n (default: the last failure). A passing run has no suspects
/tm restore <n> [--yes]Restores the file state of step n. Dry run without --yes
/tm branch <n> [name] [--yes]Like restore, but records the following steps on a new branch
/tm compare <a> <b>Compares two paths (a branch or session:<id>[/<branch>]): fork point, each side's steps, file diff stats, last test result
/tm rerun <n> [--write] [--isolated [--yes]]Runs the tool again with step n's input and compares the results
/tm sessionsRecorded sessions for this project (session id, "(this session)" marker, start time)
/tm-paneLive timeline pane

Status line: tm ⑫ · 5 snaps · 41ms (last step number, snapshot count, average snapshot time). When snapshots are off, the status line only shows tm ⑫ · snapshots off; the reason appears at the head of the /tm timeline (snapshots off: …) and in the messages of other subcommands.

How it records

  • Shadow git: snapshots are committed to <storeDir>/<projectKey>/shadow.git (a bare repository). projectKey is the project root's name plus the first 8 characters of its path hash. Each session uses its own index (index/<sessionId>), and refs are refs/tm/<sessionId>/<branch>. The shadow repository's git environment (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY and so on) is pinned and any inherited GIT_CONFIG_PARAMETERS is cleared, so nothing is written to your .git. Current .gitignore rules are respected even for files an earlier shadow snapshot tracked: those files are removed from the shadow index before staging, without deleting working files or past snapshots. Your .git/info/exclude is copied into the shadow repository the first time the session uses git (the first mutating tool, or the first /tm command that uses git) and is not updated afterwards (except for linked worktrees, where .git is a file).
  • When snapshots are taken: after mutating tools (Edit, MultiEdit, Write, NotebookEdit, Bash, unknown tools and MCP tools). Every Bash call is snapshotted by default, because redirections like cat > f <<EOF also write files. Nothing is committed when nothing changed. With the skipSearchBash setting on, Bash calls classified as search (rg, grep, ls, cat …) are skipped. Read-only tools are never snapshotted.
  • External-change steps: if, before a mutating tool, the current tree differs from the previous snapshot (say, a human edited it in the IDE), the difference is recorded as an external step so it is not blamed on the agent.
  • Baseline: taken right before the first mutating tool (or the first /tm command that uses git), so session start is not slowed down.
  • Concurrent tool calls: a snapshot taken while another mutating tool is in flight is marked concurrent. The external-change check is skipped while another mutating tool is in flight (so that tool's writes are not counted as external changes).
  • Retention and cleanup: old sessions are pruned once a day in a process detached after startup, using git gc --auto. The index files of deleted sessions, and temp folders older than a day left behind by interrupted --isolated reruns, are removed too.
  • Stored targets: target strings such as Bash commands, patterns, URLs and agent descriptions have secrets masked and are cut at 120 characters.

Observability grades

GradeExamples
exactTracked file contents at snapshot time (shadow git commits), tool inputs, tool result text (head) and its hash, exit status of test commands
parsedPass/fail counts, failing test names and failure locations file:line read from test output (only formats the parser knows: pytest, jest/vitest, go, cargo, node:test)
inferredCausal suspect ranking, "fixed by", fork point of a path comparison
not reproducedModel responses, external services, time, randomness

Causality (/tm why)

  • Reference point: the last passing test run before the failing run F on the same branch with the same framework, normalized full command (including suite/options), invocation cwd and agent (or the baseline if there is none). Every file changed between the reference snapshot and F's snapshot gets a score.
  • Scoring: +100 if the failure output mentions the path, +60 if it mentions only the file name (basename), +50 if a failure location (file:line) points at it, 0–20 for recency (closer to F scores higher), and ×2 per step that changed the file, up to 10. The top 5 are shown with their evidence and the step numbers that changed them.
  • fixed by: if there is a first passing run P after F, the files and steps changed between F and P are shown. When the failure count drops (e.g. 3 failed → 1 failed) it is marked "improved".
  • This is inference, not proof. The ranking is only a clue.

Restore and branch

  • /tm restore <n> and /tm branch <n> [name] are dry runs without --yes: they only show the files that would change (A/M/D) and their count.
  • With --yes, the current state is first saved as a pre-restore snapshot, then the work tree is set to the target step's state. Tracked files absent from the target are deleted; ignored files are left alone.
  • If the target step contains a path that is now ignored (e.g. a .env that used to be tracked is now in .gitignore, or a file out is now the ignored directory out/), the restore refuses and touches nothing: ignored files are in no snapshot, so overwriting them could not be undone. Move the paths named in the message aside and run it again. A directory holding only ignored files (e.g. restoring pkg/a.py when only __pycache__/ is left in pkg/) is judged by the files inside it, so the restore goes ahead if no file actually collides.
  • Restores and branches are recorded as a new commit holding the target's tree (parents = the pre-restore state and the target), so every snapshot stays reachable and the restore itself can be undone. If files change between the safety snapshot and the restore, the restore refuses ("the work tree changed since the last snapshot; run the restore again") — just run it again.
  • A branch-restore step adds no files to the change list of /tm why: its tree equals step n's, so files changed only on the branch you left do not show up as failure suspects on the new branch. How the work tree changed is shown in the timeline (work tree: …) and in workTree of /tm show.
  • Branch names are checked for duplicates case-insensitively and validated as git ref names before anything is applied.
  • A note for the model is added to the conversation ("the work tree was restored to step n; earlier tool results for these files are stale, re-read them before editing"). The conversation itself is not rewound or forked — only the file state and the timeline move.
  • Restores and branches with --yes, and every rerun (except the --isolated dry run), run only when the user typed them directly (origin composer/bridge/sdk). Otherwise they refuse and name the origin. Dry runs and read-only subcommands work from anywhere. None of this is exposed as a tool the model can call.

Rerun (/tm rerun)

  • Default: calls the tool again with the same input (going through the permission check). Only read-only tools are allowed; mutating tools and tools that do work when run again (Agent/Task, Skill, SendMessage, KillShell) need --write (mutating tools are snapshotted afterwards; for subagents and the like, the calls inside are recorded as their own steps). Either way it runs only when the user typed it directly. If the results hash the same it reports "identical"; otherwise a line diff summary, and for tests the pass/fail change.
  • --isolated (Bash only; snapshot copy, not a sandbox): extracts the snapshot right before that step into a temp directory (<storeDir>/<projectKey>/tmp/…) and runs there. It is best effort, since ignored files (dependencies and so on) are missing, and it does not go through the permission dialog. So without --yes it is a dry run: it only shows the command to run ($ …), the snapshot it would use and caveats, and runs nothing. /tm rerun <n> --isolated --yes also runs only when the user typed it directly, and refuses commands that contain an absolute path into the project, as a guard against obvious writes to the original tree. Environment variables, home configuration and external services remain accessible. External changes are checked before and after the run, and if the project changes anyway it is recorded as an external step.
  • Steps whose stored input was truncated or masked can be rerun only while the original input recorded in this load is still in memory (the last 500 steps, 32 MiB total). They cannot be rerun from another load (reload, resume).
  • Model responses are not rerun.

Restoring files ≠ deterministic replay

v1 only restores files and runs tools again; it does not replay model responses, external services (WebFetch, MCP), time or randomness. Model turns are recorded only as summaries and hashes.

v2 design sketch (not implemented): (1) a model-response cassette where a turn.step hook emits recorded chunks, keyed by request message hash + model + index. (2) A tool-result cassette where a tool.call hook answers with the result without calling next, keyed by (tool, input hash, previous snapshot commit). (3) WebFetch/WebSearch/MCP covered by the tool-result cassette; Bash that uses the network marked as not reproducible. (4) Time and randomness dependencies only warned about. (5) Replay run = new session + baseline restore + cassette mode; a cassette miss marks a "nondeterministic fork point".

Settings

KeyDefaultMeaning
storeDir"" (~/.claude/time-machine)Where snapshots and timelines live, one folder per project. Must be absolute (or ~/…) and outside the project (otherwise snapshots are turned off)
retentionDays14Deletes session records older than this (once a day, detached after startup). 0 keeps everything
recordModelOutputfalseStores model response bodies and the first 200 characters of prompts. When off, only lengths and hashes
skipSearchBashfalseSkips the snapshot after Bash classified as search

Storage layout

<storeDir>/<projectKey>/
  shadow.git/                       # shadow repository
  index/<sessionId>                 # per-session git index
  tmp/                              # temp work directories for --isolated reruns
  sessions/<sessionId>/meta.json    # { sessionId, root, startedAt }
  sessions/<sessionId>/<load>-<n>.jsonl

JSONL record kinds: session, turn, step, model, branch. A chunk rolls over to the next number past 256 KiB. A step's tool input is cut at 4 KiB per string field and the result head at 2 KiB (hash and length are kept), with secrets masked. Parsed failure names are also masked before being stored; test counts and invocation hashes are unchanged. The full content of Write and old/new_string of Edit are in the snapshot.

Environments

EnvironmentBehavior
TerminalAll commands, pane, status line
VS Code chat panelHooks and command text only (pane and status line are not shown)
claude -pRecording and snapshots work. Commands run in the same process via stream-json input

Limitations

  • Snapshots are turned off when git is missing, the root is HOME or /, storeDir is relative or inside the project, or the baseline snapshot takes more than 5 seconds (the timeline keeps recording). After that, a single failed snapshot (timeout, contention with another process) leaves only that step without a snapshot; snapshots are turned off only after 3 or more consecutive failures spanning more than 3 minutes. Lock files left behind by a dead git are removed after 2 minutes and the snapshot is retried. The reason appears at the head of the /tm timeline and in other subcommands' messages (the status line only shows snapshots off).
  • If a nested git repository has no commits, git add -A fails, snapshots keep failing and are eventually turned off. Nested repositories' contents are not snapshotted (they are kept only as gitlinks).
  • In large repositories every mutating tool pays the snapshot time (the average is on the status line).
  • A log file inside the project that keeps changing produces an external change step before every mutating tool.
  • Files changed later by a background Bash are attached to the next snapshot. Snapshot attribution for concurrent tool calls is approximate.
  • --isolated only changes the working directory; it does not isolate the environment, home directory, network or external services. Commands can still touch the real tree indirectly.
  • Test output in formats the parser does not know uses only the exit status. Locations in the middle of very long test output (outside the first/last 128 KiB) are not used for causality.
  • Ignored files (build outputs, node_modules, .env) are not snapshotted or restored.
  • It does not replace Claude Code's built-in /rewind. Time Machine also catches files changed by Bash and adds the timeline, causality and comparison.

Test run identity is a hash of the complete command (whitespace outside quotes is normalized for simple commands; commands with newlines or shell expansions are preserved) plus cwd. Earlier logs without an identity remain readable but are not linked as a baseline, fix or improvement. A same-command pass is still an inference, not proof of causation.

Storage state appears in the timeline, pane and status line: recording, degraded (some timeline data was saved but a write failed), or not persisted (no timeline write has succeeded in this load). Failed file bodies, including metadata and rolled-over chunks, stay in memory across clear/resume and retry on subsequent recording events and session end. A same-session resume reuses the in-memory recorder so unsaved steps keep their numbers. A continuing failure followed by process termination still loses unsaved memory. Secret keys such as password, authorization, api_key and access_token mask their entire values recursively; masked/truncated disk inputs are marked lossy. This supplements string masking and does not sanitize snapshot file contents.

Secret masking is best-effort and covers credential flags, sensitive headers, URL passwords, curl/mysql credentials and common credential fields in JSON strings. It does not sanitize snapshot file contents or retroactively rewrite saved files. Review files before sharing.

Reports and panels mask supported credential formats before truncation. Time Machine rerun previews mask displayed commands while replay uses the original in-memory input. Existing logs and snapshot file contents are not rewritten.

Development

CLAUDE292=~/.vscode/extensions/anthropic.claude-code-2.1.292-darwin-arm64/resources/native-binary/claude
"$CLAUDE292" plugin test time-machine                 # from the repo root
npx -y -p typescript@5 tsc -p time-machine --noEmit   # after copying types into time-machine/.claude-plugin/types/ (do not commit)
"$CLAUDE292" plugin validate time-machine

Unit tests use a fake git runner to check command order, arguments, environment, output parsing and error paths. The test runner's $ has no process, so real git is checked end to end (-p stream-json, one process).

Source 13 files
hooks/register.tsx 738 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, DurableWriter, parseChunks, projectKey, type RecordingStatus } from './core/store.ts'
21import { redactSecrets } from './core/redact.ts'
22import { parseTestOutput } from './core/testparse.ts'
23import { branchTaken, Recorder } from './core/timeline.ts'
24import { ACTING_TOOLS, callInput, isMutating, resultText, summarizeInput, summarizeResult, targetOf } from './core/tools.ts'
25import type { SnapshotRef, Step } from './core/types.ts'
26import { isInside, ShadowGit, type Runner } from './git.ts'
27
28const PANE = 'time-machine'
29// Names this module load's timeline chunks, so a reload never overwrites an earlier load's.
30const LOAD_ID = Date.now().toString(36)
31const DAY = 86_400_000
32const TOO_SLOW_MS = 5_000
33const MAX_LIVE_INPUTS = 500
34// Raw inputs kept for /tm rerun stay under this in all; the oldest go first, and one larger is not kept.
35const MAX_LIVE_BYTES = 32 * 1024 * 1024
36const DIFF_LINES = 400
37// Snapshots turn off only after failing this many times in a row over at least this long: a stale lock
38// (cleared once it is old enough) or one slow moment skips a snapshot or two, not the rest of the session.
39const FAILS_BEFORE_OFF = 3
40const FAILING_MS_BEFORE_OFF = 180_000
41const USAGE =
42  'usage: /tm [timeline [--all] [--branch b] | show <n> | diff <a> [b] | why [n] | restore <n> [--yes] | branch <n> [name] [--yes] | compare <a> <b> | rerun <n> [--write] [--isolated [--yes]] | sessions]'
43
44type Ctx = {
45  rec: Recorder
46  root: string
47  base: string
48  dir: string
49  git?: ShadowGit
50  ready?: Promise<void>
51  off?: string
52  head?: string
53  writer: DurableWriter
54  warnedStore?: boolean
55  inflight: number
56  snapMs: number[]
57  failing?: { count: number; since: number; error: string }
58  // The external-change check in progress: parallel mutating calls wait for it before they run.
59  checking?: Promise<Step | undefined>
60  live: Map<number, Record<string, unknown>>
61  liveBytes: Map<number, number>
62  rerunOf?: number
63  ended?: boolean
64}
65
66let ctx: Ctx | undefined
67// /clear and /resume must not discard dirty file bodies while the module remains alive.
68const retired = new Set<Ctx>()
69// Session starts in this module load: each gets its own chunk names, which still sort in start order.
70let starts = 0
71let currentOptions: PluginOptions = {}
72
73const message = (error: unknown): string => (error instanceof Error ? error.message : String(error))
74
75function runnerOf($: EngineInterface): Runner {
76  return async (argv, opts) => {
77    const r = await $.process.run(argv, opts)
78    return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }
79  }
80}
81
82const refOf = (c: Ctx): string => `refs/tm/${c.rec.session.sessionId}/${c.rec.branch}`
83
84async function storeDir($: EngineInterface, options: PluginOptions): Promise<string> {
85  const dir = (String(options.storeDir ?? '').trim() || '~/.claude/time-machine').replace(/\/+$/, '')
86  if (!/^~(?=\/|$)/.test(dir)) return dir
87  return dir.replace(/^~/, (await $.env.get('HOME')) ?? '.')
88}
89
90// Records written in order: every chunk a batch touched gets its final body.
91async function persistOne($: EngineInterface, c: Ctx): Promise<void> {
92  c.writer.add(c.rec.drain())
93  await c.writer.flush(async (name, body) => { await $.fs.write(`${c.dir}/${name}`, body) })
94  const status = c.writer.status
95  if (status.error !== undefined && !c.warnedStore) {
96    c.warnedStore = true
97    try {
98      $.ui.toast(`time-machine: ${status.state}; unsaved records retained for retry (${redactSecrets(status.error).slice(0, 200)})`)
99    } catch { /* recording must not break the session */ }
100  }
101  if (status.error === undefined) c.warnedStore = false
102}
103
104async function persist($: EngineInterface, c: Ctx): Promise<void> {
105  await persistOne($, c)
106  for (const previous of retired) {
107    if (previous !== c) await persistOne($, previous)
108    if (previous.writer.status.pending === 0) retired.delete(previous)
109  }
110  paint($, c)
111}
112
113function recordingStatus(c: Ctx): RecordingStatus {
114  const own = c.writer.status
115  const pending = [...retired].filter(previous => previous !== c).reduce((sum, previous) => sum + previous.writer.status.pending, 0)
116  return pending > 0 ? { state: own.state === 'not persisted' ? own.state : 'degraded', pending: own.pending + pending, error: 'earlier session records await retry' } : own
117}
118
119function paint($: EngineInterface, c: Ctx): void {
120  try {
121    $.ui.status(statusLine(c.rec, c.snapMs, c.off, recordingStatus(c)))
122    $.ui.invalidate('ui.render')
123  } catch {
124    // no status line here
125  }
126}
127
128// A session id as a folder name: one path segment, never `.` or `..`.
129const SESSION_ID = /^(?!\.\.?$)[A-Za-z0-9._-]{1,128}$/
130
131// A recorded session's timeline from its folder, or undefined when there is none to read.
132async function loadRecorder($: EngineInterface, dir: string): Promise<Recorder | undefined> {
133  try {
134    const entries = await $.fs.list(dir)
135    const files = await Promise.all(
136      entries.filter(e => e.kind === 'file' && e.name.endsWith('.jsonl')).map(async e => ({ name: e.name, text: String(await $.fs.read(`${dir}/${e.name}`)) })),
137    )
138    return Recorder.fromRecords(parseChunks(files))
139  } catch {
140    return undefined
141  }
142}
143
144async function startSession($: EngineInterface): Promise<Ctx> {
145  if (ctx) {
146    await persist($, ctx)
147    if (ctx.writer.status.pending > 0) retired.add(ctx)
148  }
149  const sessionId = await $.session.id()
150  const root = await $.session.root()
151  const base = `${await storeDir($, currentOptions)}/${projectKey(root)}`
152  const dir = `${base}/sessions/${sessionId}`
153  // Disk may be missing the newest steps after a failed flush. Resume that in-memory history
154  // before allocating step numbers, or a reload would collapse old/new steps with the same n.
155  const remembered = [...retired].reverse().find(previous => previous.dir === dir && previous.root === root)
156  const rec = remembered?.rec ?? (await loadRecorder($, dir)) ?? new Recorder({ sessionId, root, startedAt: Date.now(), loadId: LOAD_ID })
157  starts += 1
158  // Four digits: chunk names sort in start order through 9999 starts in one module load.
159  const c: Ctx = {
160    rec,
161    root,
162    base,
163    dir,
164    writer: new DurableWriter(new ChunkWriter(`${LOAD_ID}${String(starts).padStart(4, '0')}`)),
165    inflight: 0,
166    snapMs: [],
167    live: new Map(),
168    liveBytes: new Map(),
169  }
170  const last = rec.lastSnapshot()
171  if (last?.snapshot) c.head = last.snapshot.commit
172  c.writer.queueFile('meta.json', JSON.stringify({ sessionId, root, startedAt: rec.session.startedAt }))
173  await persist($, c)
174  return c
175}
176
177// The context for this session, started again after a /clear or a resume.
178async function live($: EngineInterface): Promise<Ctx | undefined> {
179  if (ctx && !ctx.ended) return ctx
180  try {
181    ctx = await startSession($)
182  } catch {
183    ctx = undefined
184  }
185  return ctx
186}
187
188// Git, ready to use from this hook, or undefined with `c.off` saying why.
189// The first caller creates git and its readiness synchronously, so concurrent first calls share them.
190async function ensureGit($: EngineInterface, c: Ctx): Promise<ShadowGit | undefined> {
191  if (c.off) return undefined
192  if (c.git) {
193    c.git.runner = runnerOf($)
194    await c.ready
195    return c.off ? undefined : c.git
196  }
197  const git = new ShadowGit(runnerOf($), `${c.base}/shadow.git`, c.root, `${c.base}/index/${c.rec.session.sessionId}`)
198  c.git = git
199  c.ready = (async () => {
200    const home = await $.env.get('HOME').catch(() => undefined)
201    if (c.root === '/' || (home !== undefined && c.root.replace(/\/+$/, '') === home.replace(/\/+$/, ''))) {
202      c.off = 'the session root is the home or the root directory'
203    } else if (!c.base.startsWith('/') || /(^|\/)\.\.?(\/|$)/.test(c.base)) {
204      c.off = 'storeDir must be an absolute path'
205    } else if (c.base === c.root || isInside(c.base, c.root)) {
206      // Its own files would be snapshotted, and every snapshot would change it.
207      c.off = 'the time-machine store is inside the project'
208    }
209    if (c.off) return
210    try {
211      await git.runner(['mkdir', '-p', `${c.base}/index`])
212      await git.init()
213      if (c.head === undefined) {
214        const t0 = Date.now()
215        const snap = await git.snapshot(undefined, `baseline ${c.rec.session.sessionId}`, refOf(c))
216        const ms = Date.now() - t0
217        c.head = snap.commit
218        if (!c.rec.get(0)) c.rec.baseline(Date.now(), { ...snap, ms })
219        if (ms > TOO_SLOW_MS) c.off = `the baseline snapshot took ${ms}ms; per-step snapshots would slow every tool`
220      }
221    } catch (error) {
222      c.off = `git unavailable: ${message(error)}`
223    }
224    if (c.off) return
225    // Off every tool's path: pruning and gc run detached, and their failures never turn snapshots off.
226    try {
227      $.clock.after(0, () => {
228        prune($, c, git).catch(() => undefined)
229      })
230    } catch {
231      // pruned another day
232    }
233  })()
234  await c.ready
235  if (!c.rec.get(0)) c.rec.baseline(Date.now())
236  await persist($, c)
237  return c.off ? undefined : git
238}
239
240// A call's raw input, kept for /tm rerun within MAX_LIVE_INPUTS and MAX_LIVE_BYTES, oldest dropped first.
241function keepLive(c: Ctx, n: number, input: Record<string, unknown>): void {
242  let size: number
243  try {
244    size = JSON.stringify(input).length
245  } catch {
246    return
247  }
248  if (size > MAX_LIVE_BYTES) return
249  c.live.set(n, input)
250  c.liveBytes.set(n, size)
251  let total = 0
252  for (const v of c.liveBytes.values()) total += v
253  while (c.live.size > MAX_LIVE_INPUTS || total > MAX_LIVE_BYTES) {
254    const oldest = c.live.keys().next().value!
255    total -= c.liveBytes.get(oldest) ?? 0
256    c.live.delete(oldest)
257    c.liveBytes.delete(oldest)
258  }
259}
260
261// `own`: the caller's own call, when it is counted in flight.
262async function takeSnapshot($: EngineInterface, c: Ctx, label: string, own = 0): Promise<SnapshotRef | undefined> {
263  const git = await ensureGit($, c)
264  if (!git) return undefined
265  const concurrent = c.inflight > own
266  const t0 = Date.now()
267  try {
268    const s = await git.snapshot(c.head, label, refOf(c))
269    const ms = Date.now() - t0
270    c.head = s.commit
271    c.failing = undefined
272    c.snapMs.push(ms)
273    if (c.snapMs.length > 1_000) c.snapMs.shift()
274    return { ...s, ms, ...(concurrent ? { concurrent: true } : {}) }
275  } catch (error) {
276    const now = Date.now()
277    const f = (c.failing ??= { count: 0, since: now, error: '' })
278    f.count += 1
279    f.error = message(error)
280    if (f.count >= FAILS_BEFORE_OFF && now - f.since >= FAILING_MS_BEFORE_OFF) c.off = `snapshot failed ${f.count} times in a row: ${f.error}`
281    return undefined
282  }
283}
284
285// Changes made outside the agent since the last snapshot become an `external` step of their own.
286// Skipped while another mutating call is in flight: its changes are not external. A check already
287// running is waited for instead, so a parallel call's writes never land inside it. `own` is 1 when the
288// caller is a tool call already counted in flight.
289async function checkExternal($: EngineInterface, c: Ctx, own = 0): Promise<Step | undefined> {
290  if (c.checking) {
291    await c.checking.catch(() => undefined)
292    return undefined
293  }
294  if (c.inflight > own) return undefined
295  const check = (async () => {
296    const snap = await takeSnapshot($, c, 'external', own)
297    if (!snap?.changed) return undefined
298    const step = c.rec.begin({ id: `external-${Date.now()}`, tool: 'external', kind: 'external', at: Date.now() })
299    c.rec.finish(step, Date.now(), { snapshot: snap })
300    return step
301  })()
302  c.checking = check
303  try {
304    return await check
305  } finally {
306    if (c.checking === check) c.checking = undefined
307  }
308}
309
310async function prune($: EngineInterface, c: Ctx, git: ShadowGit): Promise<void> {
311  const key = `lastPrune:${projectKey(c.root)}`
312  const last = Number((await $.store.get(key)) ?? 0)
313  if (Date.now() - last < DAY) return
314  await $.store.set(key, Date.now())
315  const days = Number(currentOptions.retentionDays ?? 14)
316  const sessions = `${c.base}/sessions`
317  // retentionDays 0 keeps every session; leftovers below are still swept.
318  for (const entry of days > 0 ? await $.fs.list(sessions).catch(() => []) : []) {
319    if (entry.kind !== 'dir' || entry.name === c.rec.session.sessionId) continue
320    const target = `${sessions}/${entry.name}`
321    try {
322      const meta = JSON.parse(String(await $.fs.read(`${target}/meta.json`))) as { startedAt?: number }
323      if (Date.now() - Number(meta.startedAt ?? Date.now()) < days * DAY || !isInside(target, sessions)) continue
324      await runnerOf($)(['rm', '-rf', target])
325      if (SESSION_ID.test(entry.name)) await runnerOf($)(['rm', '-f', `${c.base}/index/${entry.name}`, `${c.base}/index/${entry.name}.lock`])
326      await git.deleteRefs(`refs/tm/${entry.name}/`)
327    } catch {
328      // leave it for next time
329    }
330  }
331  // Indexes whose session folder is gone (pruned before indexes were): the index is only that session's.
332  for (const entry of await $.fs.list(`${c.base}/index`).catch(() => [])) {
333    const id = entry.name.replace(/\.lock$/, '')
334    if (entry.kind !== 'file' || id === c.rec.session.sessionId || !SESSION_ID.test(id)) continue
335    if (await $.fs.exists(`${sessions}/${id}`).catch(() => true)) continue
336    await runnerOf($)(['rm', '-f', `${c.base}/index/${entry.name}`]).catch(() => undefined)
337  }
338  // Isolated reruns' copies a crash left behind: named `<load>-<step>-<base-36 time>`, removed after a day.
339  const tmpRoot = `${c.base}/tmp`
340  for (const entry of await $.fs.list(tmpRoot).catch(() => [])) {
341    const at = parseInt(/^[0-9a-z]+-\d+-([0-9a-z]+)(?:\.index)?$/.exec(entry.name)?.[1] ?? '', 36)
342    const target = `${tmpRoot}/${entry.name}`
343    if (!(Date.now() - at > DAY) || !isInside(target, tmpRoot)) continue
344    await runnerOf($)(['rm', '-rf', target]).catch(() => undefined)
345  }
346  await git.gc().catch(() => undefined)
347}
348
349// Finishes a begun step on every path: a recording error still ends it, with what it gathered.
350async function settle($: EngineInterface, c: Ctx, step: Step | undefined, counted: boolean, mutating: boolean, answer: unknown, cwd?: string): Promise<void> {
351  if (counted) c.inflight = Math.max(0, c.inflight - 1)
352  if (!step) return
353  try {
354    const result = summarizeResult(answer)
355    const snapshot = mutating ? await takeSnapshot($, c, `step ${step.n} ${step.tool}`) : undefined
356    const liveInput = c.live.get(step.n)
357    const command = String(liveInput?.command ?? step.input.command ?? '')
358    const invocation = cwd !== undefined && (liveInput || !step.inputLossy) ? { command, cwd } : undefined
359    const test = step.tool === 'Bash' && classifyCommand(command) === 'test' ? parseTestOutput(resultText(answer), result.isError, invocation) : undefined
360    c.rec.finish(step, Date.now(), { result, ...(snapshot ? { snapshot } : {}), ...(test ? { test } : {}) })
361    await persist($, c)
362    paint($, c)
363  } catch {
364    // recording never breaks the call; the step is still finished
365    try {
366      if (step.endedAt === undefined) c.rec.finish(step, Date.now())
367      await persist($, c)
368    } catch {
369      // dropped
370    }
371  }
372}
373
374// Origins a person typed: the only ones allowed to change files or run commands through /tm.
375const TYPED = new Set(['composer', 'bridge', 'sdk'])
376
377// The subcommand, when `args` would change files or run commands: restore/branch --yes, and every rerun
378// but an --isolated dry run (a "read-only" step may be a subagent that edits files once run again).
379function changesFiles(args: string): string | undefined {
380  const words = args.trim().split(/\s+/).filter(w => w !== '')
381  const sub = words.find(w => !w.startsWith('--'))
382  const flags = new Set(words.filter(w => w.startsWith('--')))
383  if ((sub === 'restore' || sub === 'branch') && flags.has('--yes')) return sub
384  if (sub === 'rerun' && !(flags.has('--isolated') && !flags.has('--yes'))) return sub
385  return undefined
386}
387
388const num = (word: string | undefined): number | undefined => (word !== undefined && /^\d+$/.test(word) ? Number(word) : undefined)
389
390async function restore($: EngineInterface, c: Ctx, n: number, yes: boolean, branch?: string): Promise<string> {
391  if (!c.rec.get(n)) return `time-machine: no step ${n}`
392  const git = await ensureGit($, c)
393  if (!git) return `time-machine: snapshots are off (${c.off ?? 'not ready'}); there is nothing to restore`
394  const target = c.rec.snapshotAt(n)
395  if (!target?.snapshot) return `time-machine: no snapshot at or before step ${n}`
396  await checkExternal($, c)
397  const from = c.head!
398  const files = await git.nameStatus(from, target.snapshot.commit)
399  const name = branch === undefined ? undefined : branch || c.rec.nextBranchName()
400  const asked = c.rec.get(n)!
401  if (!yes) return renderRestorePlan(asked, target, files, false, name)
402  if (name !== undefined && branchTaken(c.rec, name)) return `time-machine: branch '${name}' already exists`
403  // A name git would refuse as a ref would fail only after the work tree was rewritten.
404  if (name !== undefined && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || name.includes('..') || name.endsWith('.lock') || name.endsWith('.')))
405    return `time-machine: '${name}' is not a usable branch name (letters, digits, '.', '_', '-')`
406  // Applied first: a refused restore (the work tree moved since `from`) leaves no fork and no restore step.
407  // The ref is the new branch's when branching, so the branch left behind keeps its history.
408  const ref = name !== undefined ? `refs/tm/${c.rec.session.sessionId}/${name}` : refOf(c)
409  const commit = await git.restore(target.snapshot.commit, ref, from, `restore to step ${n}${name !== undefined ? ` on branch ${name}` : ''}`, name !== undefined)
410  c.head = commit
411  c.rec.restoreStep({ at: Date.now(), to: n, from, commit, files, ...(name !== undefined ? { branch: name } : {}) })
412  await persist($, c)
413  paint($, c)
414  const list = files.slice(0, 20).map(f => `${f.status} ${f.path}`).join(', ')
415  const note =
416    `[time-machine] The user restored the working tree to step ${n} (${label(c.rec.get(n)!)}). ${files.length} file(s) changed${list ? `: ${list}` : ''}. ` +
417    'Earlier tool results about these files are out of date; re-read files before editing them.' +
418    (name !== undefined ? ` Work continues on branch '${name}'; the conversation itself was not rewound.` : '')
419  let told = true
420  try {
421    const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
422    if (typeof appended?.deny === 'string') told = false
423  } catch {
424    // the restore stands; the model was not told
425    told = false
426  }
427  return renderRestorePlan(asked, target, files, true, name, told)
428}
429
430// A branch name, or `session:<id>[/<branch>]` for another recorded session of this project.
431async function pathOf($: EngineInterface, c: Ctx, spec: string): Promise<{ steps: Step[]; head?: string } | string> {
432  let rec: Recorder | undefined = c.rec
433  let branch = spec
434  if (spec.startsWith('session:')) {
435    const [id = '', b] = spec.slice('session:'.length).split('/')
436    if (!SESSION_ID.test(id)) return `time-machine: '${id}' is not a session id`
437    rec = await loadRecorder($, `${c.base}/sessions/${id}`)
438    if (!rec) return `time-machine: no recorded session ${id}`
439    branch = b ?? 'main'
440  }
441  if (!rec.branches.has(branch)) return `time-machine: no branch '${branch}'`
442  const steps = rec.path(branch)
443  const head = rec.lastSnapshot(branch)?.snapshot?.commit
444  return head !== undefined ? { steps, head } : { steps }
445}
446
447async function rerun($: EngineInterface, c: Ctx, n: number, write: boolean, isolated: boolean, yes: boolean): Promise<string> {
448  const step = c.rec.get(n)
449  if (!step || step.kind !== 'tool') return `time-machine: no tool step ${n}`
450  const input = c.live.get(n) ?? (step.inputLossy ? undefined : step.input)
451  if (!input) return `time-machine: step ${n}'s input was cut or redacted when stored and this load did not record it live, so it cannot be replayed`
452  if (isolated) {
453    if (step.tool !== 'Bash') return 'time-machine: --isolated replays Bash steps only'
454    const command = String(input.command ?? '')
455    const problem = isolationProblem(command, c.root)
456    if (yes && problem) return `$ ${redactSecrets(command)}\ntime-machine: ${problem}`
457    const git = await ensureGit($, c)
458    const before = git ? c.rec.snapshotAt(step.parent) : undefined
459    const blocked = !git ? `snapshots are off (${c.off ?? 'not ready'})` : !before?.snapshot ? `no snapshot before step ${n}` : problem
460    if (!yes) return renderIsolatedPlan(step, command, before, blocked)
461    if (!git || !before?.snapshot) return `$ ${redactSecrets(command)}\ntime-machine: ${blocked}`
462    const tmpRoot = `${c.base}/tmp`
463    const tmp = `${tmpRoot}/${LOAD_ID}-${n}-${Date.now().toString(36)}`
464    const run = runnerOf($)
465    // Changes made before it are not the command's; one it makes to the project anyway is recorded.
466    await checkExternal($, c)
467    let out: string
468    try {
469      await run(['mkdir', '-p', tmp])
470      await git.exportTo(before.snapshot.commit, tmp)
471      const r = await run(['sh', '-lc', command], { cwd: tmp, timeoutMs: 600_000 })
472      const text = [r.stdout, r.stderr].filter(s => s !== '').join('\n')
473      const now = step.test ? parseTestOutput(text, r.exitCode !== 0) : undefined
474      const where = `snapshot copy (not a sandbox) of ${circled(before.n)}'s snapshot; ignored files such as dependencies are not in the copy`
475      out = [`$ ${redactSecrets(command)}`, renderRerun(step, compareOutputs(step.result, text, step.test, now), where)].join('\n')
476    } finally {
477      if (isInside(tmp, tmpRoot)) await run(['rm', '-rf', tmp, `${tmp}.index`]).catch(() => undefined)
478    }
479    const stray = await checkExternal($, c)
480    if (stray) {
481      await persist($, c)
482      paint($, c)
483      out += `\nThe project changed while it ran: recorded as ${circled(stray.n)} ${label(stray)}.`
484    }
485    return out
486  }
487  if ((isMutating(step.tool, input, currentOptions.skipSearchBash === true) || ACTING_TOOLS.has(step.tool)) && !write)
488    return `time-machine: step ${n} (${step.tool}) can change files; add --write to run it again here (${ACTING_TOOLS.has(step.tool) ? 'the calls it makes are recorded as steps of their own' : 'a snapshot is taken after it'})`
489  c.rerunOf = n
490  let answer: unknown
491  try {
492    answer = await $.tool.call({ tool: step.tool, ...input } as never)
493  } finally {
494    c.rerunOf = undefined
495  }
496  const text = resultText(answer)
497  const now = step.test ? parseTestOutput(text, summarizeResult(answer).isError) : undefined
498  return renderRerun(step, compareOutputs(step.result, text, step.test, now), 'here, against the current files')
499}
500
501async function runTm($: EngineInterface, c: Ctx, args: string): Promise<string> {
502  const words = args.trim().split(/\s+/).filter(w => w !== '')
503  const flags = new Set(words.filter(w => w.startsWith('--')))
504  const rest = words.filter(w => !w.startsWith('--'))
505  const sub = rest[0] ?? 'timeline'
506  switch (sub) {
507    case 'timeline': {
508      const branchAt = words.indexOf('--branch')
509      const branch = branchAt >= 0 ? words[branchAt + 1] : undefined
510      if (branch !== undefined && !c.rec.branches.has(branch)) return `time-machine: no branch '${branch}'`
511      return renderTimeline(c.rec, { all: flags.has('--all'), ...(branch ? { branch } : {}), ...(c.off ? { off: c.off } : {}), snapMs: c.snapMs, recording: recordingStatus(c) })
512    }
513    case 'show': {
514      const n = num(rest[1])
515      const step = n === undefined ? undefined : c.rec.get(n)
516      return step ? renderStep(step) : `time-machine: no step ${rest[1] ?? ''}`.trim()
517    }
518    case 'diff': {
519      const a = num(rest[1])
520      if (a === undefined) return USAGE
521      const git = await ensureGit($, c)
522      if (!git) return `time-machine: snapshots are off (${c.off ?? 'not ready'})`
523      const from = c.rec.snapshotAt(a)?.snapshot?.commit
524      let to: string | undefined
525      const b = num(rest[2])
526      if (b !== undefined) to = c.rec.snapshotAt(b)?.snapshot?.commit
527      else {
528        await checkExternal($, c)
529        to = c.head
530      }
531      if (from === undefined || to === undefined) return 'time-machine: no snapshot for that step'
532      const { stat, patch, more } = await git.diffText(from, to, DIFF_LINES)
533      return [stat.trimEnd() || '(no difference)', '', patch, ...(more ? [`… (cut at ${DIFF_LINES} lines; the stat above lists every file)`] : [])].join('\n')
534    }
535    case 'why': {
536      const w = why(c.rec.path(), num(rest[1]))
537      if (!w) return rest[1] ? `time-machine: step ${rest[1]} is not a test run on this branch` : 'time-machine: no failing test run on this branch'
538      return renderWhy(w, n => {
539        const s = c.rec.get(n)
540        return s ? `${circled(n)} ${label(s)}` : undefined
541      })
542    }
543    case 'restore':
544    case 'branch': {
545      const n = num(rest[1])
546      if (n === undefined) return USAGE
547      return restore($, c, n, flags.has('--yes'), sub === 'branch' ? (rest[2] ?? '') : undefined)
548    }
549    case 'compare': {
550      if (rest.length < 3) return USAGE
551      const a = await pathOf($, c, rest[1]!)
552      const b = await pathOf($, c, rest[2]!)
553      if (typeof a === 'string') return a
554      if (typeof b === 'string') return b
555      let stat: string | undefined
556      if (a.head && b.head) {
557        const git = await ensureGit($, c)
558        if (git) stat = (await git.diffText(a.head, b.head).catch(() => ({ stat: '(snapshots no longer available)', patch: '' }))).stat
559      }
560      return renderCompare(rest[1]!, rest[2]!, compare(a.steps, b.steps), stat)
561    }
562    case 'rerun': {
563      const n = num(rest[1])
564      if (n === undefined) return USAGE
565      return rerun($, c, n, flags.has('--write'), flags.has('--isolated'), flags.has('--yes'))
566    }
567    case 'sessions': {
568      const sessions = `${c.base}/sessions`
569      const rows: { id: string; startedAt: number; current: boolean }[] = []
570      for (const entry of await $.fs.list(sessions).catch(() => [])) {
571        if (entry.kind !== 'dir') continue
572        let startedAt = 0
573        try {
574          startedAt = Number((JSON.parse(String(await $.fs.read(`${sessions}/${entry.name}/meta.json`))) as { startedAt?: number }).startedAt ?? 0)
575        } catch {
576          // unknown start
577        }
578        rows.push({ id: entry.name, startedAt, current: entry.name === c.rec.session.sessionId })
579      }
580      return renderSessions(rows)
581    }
582    default:
583      return USAGE
584  }
585}
586
587export const register: Register = (on, options) => {
588  currentOptions = options
589
590  on('session.start', async ($, e, next) => {
591    try {
592      ctx = await startSession($)
593      for (const command of [
594        { name: 'tm', description: 'Agent Time Machine: timeline, show, diff, why, restore, branch, compare, rerun, sessions', argumentHint: '[timeline|show n|diff a [b]|why [n]|restore n|branch n|compare a b|rerun n|sessions]' },
595        { name: 'tm-pane', description: 'Open the live Time Machine timeline pane' },
596      ]) {
597        try {
598          await $.command.register(command)
599        } catch {
600          // the command stays unavailable
601        }
602      }
603      paint($, ctx)
604    } catch {
605      ctx = undefined
606    }
607    return next(e)
608  })
609
610  on('session.end', async ($, e, next) => {
611    const result = await next(e)
612    if (ctx) await persist($, ctx)
613    if ((e.reason === 'clear' || e.reason === 'resume') && ctx) ctx.ended = true
614    return result
615  }).catch(($, e, next) => next(e))
616
617  on('turn.start', async ($, e, next) => {
618    try {
619      const c = await live($)
620      if (c) {
621        c.rec.startTurn(Date.now(), e.text, currentOptions.recordModelOutput === true)
622        await persist($, c)
623      }
624    } catch {
625      // not recorded
626    }
627    return next(e)
628  })
629
630  on('tool.call', async ($, e, next) => {
631    const c = await live($).catch(() => undefined)
632    if (!c) return next(e)
633    const raw = e as unknown as Record<string, unknown>
634    const tool = String(e.tool)
635    let step: Step | undefined
636    let mutating = false
637    let counted = false
638    let cwd: string | undefined
639    try {
640      if (tool === 'Bash') cwd = await $.session.cwd().catch(() => undefined)
641      mutating = isMutating(tool, raw, currentOptions.skipSearchBash === true)
642      // Counted before the check, so a parallel call does not check (and blame this one's writes) too.
643      if (mutating) {
644        c.inflight += 1
645        counted = true
646        await checkExternal($, c, 1)
647      }
648      const { input, lossy } = summarizeInput(raw)
649      const target = targetOf(tool, raw, c.root)
650      step = c.rec.begin({
651        id: e.tool_use_id ?? `call-${Date.now()}`,
652        tool,
653        input,
654        inputLossy: lossy,
655        at: Date.now(),
656        ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
657        ...(target !== undefined ? { target } : {}),
658        ...(c.rerunOf !== undefined ? { rerunOf: c.rerunOf } : {}),
659      })
660      c.rerunOf = undefined
661      keepLive(c, step.n, callInput(raw))
662    } catch {
663      // recording never blocks the tool
664    }
665    try {
666      const answer = await next(e)
667      await settle($, c, step, counted, mutating, answer, cwd)
668      return answer
669    } catch (error) {
670      await settle($, c, step, counted, mutating, { isError: true, text: message(error) }, cwd)
671      throw error
672    }
673  }).catch(($, e, next) => next(e))
674
675  on('turn.step', async function* ($, e, next) {
676    let text = ''
677    const tools: { id: string; tool: string }[] = []
678    for await (const chunk of next(e)) {
679      try {
680        if (chunk.kind === 'text') text += chunk.text
681        else if (chunk.kind === 'tool') tools.push({ id: chunk.id, tool: chunk.name })
682        else if (chunk.kind === 'stop' && ctx && !ctx.ended)
683          ctx.rec.addModel({
684            turn: ctx.rec.turn,
685            index: e.index,
686            ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
687            model: e.model,
688            stopReason: chunk.stopReason,
689            usage: chunk.usage,
690            text: { chars: text.length, hash: contentHash(text), ...(currentOptions.recordModelOutput === true ? { body: text } : {}) },
691            toolUses: tools,
692          })
693      } catch {
694        // not recorded
695      }
696      yield chunk
697    }
698    if (ctx) await persist($, ctx).catch(() => undefined)
699  })
700
701  on('command.run', { command: 'tm' }, async ($, e) => {
702    try {
703      const c = await live($)
704      if (!c) return { text: 'time-machine: not recording (the session could not be read)' }
705      const args = e.args ?? ''
706      const sub = changesFiles(args)
707      const kind = e.origin?.kind ?? 'unknown'
708      if (sub !== undefined && !TYPED.has(kind)) return { text: `time-machine: ${sub} changes files or runs commands and only runs when you type it (this came from ${kind})` }
709      return { text: redactSecrets(await runTm($, c, args)) }
710    } catch (error) {
711      return { text: `time-machine: ${redactSecrets(message(error))}` }
712    }
713  })
714
715  on('command.run', { command: 'tm-pane' }, async $ => {
716    await $.ui.open({ id: PANE, title: 'Time Machine' })
717    return { text: 'Time Machine pane opened (shown in the terminal and the desktop app).' }
718  })
719
720  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
721    const { Box, Text } = $.ui.resolve(e)
722    let lines: string[]
723    try {
724      lines = ctx ? renderTimeline(ctx.rec, { ...(ctx.off ? { off: ctx.off } : {}), snapMs: ctx.snapMs, recording: recordingStatus(ctx) }).split('\n') : ['Time Machine: not recording']
725    } catch (error) {
726      lines = [`time-machine: ${redactSecrets(message(error))}`]
727    }
728    const width = e.props.bodyColumns ?? 80
729    return (
730      <Box flexDirection="column">
731        {lines.map(line => (
732          <Text>{(line.length > width ? `${line.slice(0, width - 1)}…` : line) || ' '}</Text>
733        ))}
734      </Box>
735    )
736  })
737}
738
hooks/core/causality.ts 92 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
17function comparable(a: Step & { test: TestRun }, b: Step & { test: TestRun }): boolean {
18  const x = a.test.identity
19  const y = b.test.identity
20  return x !== undefined && y !== undefined && a.agentId === b.agentId && a.test.framework === b.test.framework && x.cwd === y.cwd && x.commandHash === y.commandHash
21}
22
23// Files that snapshots after path[fromIdx] up to path[toIdx] changed, each with the steps that changed it.
24export function changesBetween(path: readonly Step[], fromIdx: number, toIdx: number): Map<string, number[]> {
25  const out = new Map<string, number[]>()
26  for (let i = Math.max(0, fromIdx + 1); i <= toIdx && i < path.length; i += 1) {
27    const step = path[i]!
28    if (!step.snapshot?.changed || step.kind === 'baseline') continue
29    for (const file of step.snapshot.files) {
30      const steps = out.get(file.path) ?? []
31      steps.push(step.n)
32      out.set(file.path, steps)
33    }
34  }
35  return out
36}
37
38// Inferred, not proof: a file the failure output names outranks one it does not; recent and repeated
39// changes rank higher among equals.
40function rank(path: readonly Step[], changes: Map<string, number[]>, test: TestRun, baseIdx: number, runIdx: number): Suspect[] {
41  const position = new Map(path.map((step, i) => [step.n, i]))
42  const span = Math.max(1, runIdx - baseIdx)
43  const suspects: Suspect[] = []
44  for (const [file, steps] of changes) {
45    const reasons: string[] = []
46    let score = 0
47    if (test.failures.some(f => f.file !== undefined && sameFile(f.file, file)) || test.paths.some(p => sameFile(p, file))) {
48      score += 100
49      reasons.push('named in the failure output')
50    } else if (test.paths.some(p => basename(p) === basename(file))) {
51      score += 60
52      reasons.push('its file name appears in the failure output')
53    }
54    if (test.locations.some(l => sameFile(l.file, file))) {
55      score += 50
56      reasons.push('a failure location points into it')
57    }
58    const last = steps[steps.length - 1]!
59    score += Math.round((20 * ((position.get(last) ?? baseIdx) - baseIdx)) / span)
60    reasons.push(`last changed at step ${last}`)
61    score += Math.min(10, 2 * steps.length)
62    if (steps.length > 1) reasons.push(`changed ${steps.length} times`)
63    suspects.push({ path: file, score, reasons, steps })
64  }
65  return suspects.sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : 1)).slice(0, MAX_SUSPECTS)
66}
67
68// For the test run `runN` (default: the last failing one on the path): what changed since the last
69// passing run, ranked; which later edits made it pass; whether a later run failed less.
70export function why(path: readonly Step[], runN?: number): Why | undefined {
71  const runs = path.flatMap((step, i) => (isRun(step) ? [{ step, i }] : []))
72  const target = runN === undefined ? [...runs].reverse().find(r => !r.step.test.ok) : runs.find(r => r.step.n === runN)
73  if (!target) return undefined
74  const { step: run, i: runIdx } = target
75  const okBefore = [...runs].reverse().find(r => r.i < runIdx && r.step.test.ok && comparable(run, r.step))
76  const baseIdx = okBefore ? okBefore.i : path[0]?.kind === 'baseline' ? 0 : -1
77  const out: Why = { run, suspects: [] }
78  const base = baseIdx >= 0 ? path[baseIdx] : undefined
79  if (base) out.base = base
80  if (!run.test.ok) out.suspects = rank(path, changesBetween(path, baseIdx, runIdx), run.test, baseIdx, runIdx)
81  const later = runs.filter(r => r.i > runIdx && comparable(run, r.step))
82  const fixed = later.find(r => r.step.test.ok)
83  if (!run.test.ok && fixed) {
84    const changes = changesBetween(path, runIdx, fixed.i)
85    out.fixedBy = { run: fixed.step, files: [...changes.keys()], steps: [...new Set([...changes.values()].flat())].sort((a, b) => a - b) }
86  }
87  const before = run.test.failed
88  const fewer = before === undefined ? undefined : later.find(r => r.step.test.failed !== undefined && r.step.test.failed < before && !r.step.test.ok)
89  if (fewer && before !== undefined) out.improved = { run: fewer.step, from: before, to: fewer.step.test.failed! }
90  return out
91}
92
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 227 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 = redactSecrets(text).replace(/[\r\n\t]+/g, ' ')
9  return line.length <= n ? line : `${line.slice(0, Math.max(0, n - 1))}…`
10}
11
12export function circled(n: number): string {
13  if (n === 0) return '⓪'
14  if (n >= 1 && n <= 20) return String.fromCharCode(0x2460 + n - 1)
15  return `(${n})`
16}
17
18export function testSummary(t: TestRun): string {
19  const parts: string[] = []
20  if (t.failed) parts.push(`${t.failed} failed`)
21  if (t.errors) parts.push(`${t.errors} error(s)`)
22  if (t.passed !== undefined) parts.push(`${t.passed} passed`)
23  if (parts.length === 0) parts.push(t.ok ? 'passed' : 'failed')
24  return `${t.ok ? '✓' : '✗'} ${parts.join(', ')}`
25}
26
27export function filesSummary(files: readonly FileChange[], total: number, n = 3): string {
28  const head = files.slice(0, n).map(f => `${f.status} ${f.path}`).join(', ')
29  return total > n ? `${head} +${total - n}` : head
30}
31
32export function label(step: Step): string {
33  return clip(rawLabel(step), 80)
34}
35
36function rawLabel(step: Step): string {
37  if (step.kind === 'baseline') return 'baseline'
38  if (step.kind === 'external') return 'external change'
39  if (step.kind === 'restore') return `restore → ${circled(Number(step.input.to))}`
40  return keyOf(step)
41}
42
43// The sub-lines under a step: test outcome or error, the snapshot it took, a rerun marker.
44export function notes(step: Step): string[] {
45  const out: string[] = []
46  if (step.test) out.push(testSummary(step.test))
47  else if (step.result?.denied) out.push('⊘ denied')
48  else if (step.result?.isError) out.push('✗ error')
49  if (step.kind === 'baseline') out.push(step.snapshot ? `snapshot ${step.snapshot.commit.slice(0, 7)} · ${step.snapshot.filesTotal} file(s)` : 'no snapshot')
50  else if (step.snapshot?.changed) {
51    // A branch restore changed nothing along its path; how the work tree moved is in its input.
52    const workTree = step.kind === 'restore' && Array.isArray(step.input.workTree) ? (step.input.workTree as FileChange[]) : undefined
53    const files = workTree ? `work tree: ${filesSummary(workTree, workTree.length)}` : filesSummary(step.snapshot.files, step.snapshot.filesTotal)
54    out.push(`snapshot ${step.snapshot.commit.slice(0, 7)} · ${files}${step.snapshot.concurrent ? ' (concurrent)' : ''}`)
55  }
56  if (step.rerunOf !== undefined) out.push(`rerun of ${circled(step.rerunOf)}`)
57  return out
58}
59
60export function avg(xs: readonly number[]): number | undefined {
61  return xs.length > 0 ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : undefined
62}
63
64export type TimelineOptions = { all?: boolean; branch?: string; limit?: number; off?: string; snapMs?: readonly number[]; recording?: import('./store.ts').RecordingStatus }
65
66export function renderTimeline(rec: Recorder, o: TimelineOptions = {}): string {
67  const branch = o.branch ?? rec.branch
68  const steps = o.all ? rec.all() : rec.path(branch)
69  const shown = o.all ? steps : steps.slice(-(o.limit ?? 30))
70  const count = rec.all().filter(s => s.kind !== 'baseline').length
71  const snaps = rec.all().filter(s => s.kind !== 'baseline' && s.snapshot?.changed).length
72  const mean = avg(o.snapMs ?? [])
73  const lines = [`Session ${rec.session.sessionId.slice(0, 8)} · branch ${branch} · ${count} step(s) · ${snaps} snapshot(s)${mean !== undefined ? `, avg ${mean}ms` : ''}`]
74  if (o.off) lines.push(`snapshots off: ${o.off}`)
75  if (o.recording) lines.push(`storage: ${o.recording.state}${o.recording.error !== undefined ? ` · ${o.recording.pending} file(s) awaiting retry; records retained in memory` : ''}`)
76  if (shown.every(s => s.kind === 'baseline')) return [...lines, '', 'No tool calls recorded yet.'].join('\n')
77  if (steps.length > shown.length) lines.push(`(${steps.length - shown.length} earlier step(s) hidden; /tm timeline --all)`)
78  lines.push('│')
79  let prevBranch = shown[0]!.branch
80  shown.forEach((step, i) => {
81    const last = i === shown.length - 1
82    if (!o.all && step.branch !== prevBranch) lines.push(`│  ⑂ branch ${step.branch} (from ${circled(step.parent)})`)
83    prevBranch = step.branch
84    const tag = o.all && step.branch !== 'main' ? ` [${step.branch}]` : ''
85    const agent = step.agentId ? ` (agent ${step.agentId.slice(0, 6)})` : ''
86    lines.push(`${last ? '└──' : '├──'} ${circled(step.n)} ${clip(label(step), 60)}${agent}${tag}`)
87    for (const note of notes(step)) lines.push(`${last ? ' ' : '│'}       └── ${note}`)
88  })
89  return lines.map(redactSecrets).join('\n')
90}
91
92export function renderStep(step: Step): string {
93  const took = step.endedAt !== undefined ? ` · took ${step.endedAt - step.startedAt}ms` : ''
94  const lines = [
95    `${circled(step.n)} ${label(step)}`,
96    `kind ${step.kind} · branch ${step.branch} · parent ${circled(step.parent)} · turn ${step.turn}${step.agentId ? ` · agent ${step.agentId}` : ''}${took}`,
97  ]
98  const keys = Object.keys(step.input)
99  if (keys.length > 0) {
100    lines.push('input:')
101    for (const key of keys) {
102      const value = step.input[key]
103      lines.push(`  ${key}: ${clip(typeof value === 'string' ? value : JSON.stringify(value) ?? '', 300)}`)
104    }
105    if (step.inputLossy) lines.push('  (stored input was cut or redacted)')
106  }
107  if (step.result) {
108    lines.push(`result: ${step.result.denied ? 'denied' : step.result.isError ? 'error' : 'ok'} · ${step.result.chars} chars`)
109    for (const line of step.result.head.split('\n').slice(0, 40)) lines.push(`  │ ${line}`)
110    if (step.result.chars > step.result.head.length) lines.push('  │ …')
111  }
112  if (step.snapshot) {
113    const s = step.snapshot
114    lines.push(`snapshot: ${s.commit.slice(0, 12)}${s.changed ? '' : ' (no change)'} · ${s.ms}ms${s.concurrent ? ' · concurrent' : ''}`)
115    for (const f of s.files) lines.push(`  ${f.status} ${f.path}`)
116    if (s.filesTotal > s.files.length && step.kind !== 'baseline') lines.push(`  … +${s.filesTotal - s.files.length} more`)
117  }
118  if (step.test) {
119    lines.push(`test (${step.test.framework}): ${testSummary(step.test)}`)
120    for (const f of step.test.failures.slice(0, 10)) lines.push(`  ✗ ${clip(redactSecrets(f.name), 200)}${f.file ? ` (${f.file}${f.line !== undefined ? `:${f.line}` : ''})` : ''}`)
121  }
122  return lines.map(redactSecrets).join('\n')
123}
124
125// `labels(n)` names a step for the fixed-by line (`⑤ Edit middleware.py`); without it, the bare number.
126export function renderWhy(w: Why, labels?: (n: number) => string | undefined): string {
127  const t = w.run.test!
128  const lines = [`Why did ${circled(w.run.n)} ${label(w.run)} ${t.ok ? 'pass' : 'fail'}? ${testSummary(t)} (${t.framework})`]
129  lines.push(`Compared with: ${w.base ? `${circled(w.base.n)} ${label(w.base)}${w.base.test ? ' (last passing run)' : ' (no passing run before it)'}` : 'the start of the session'}`)
130  if (!t.ok) {
131    if (w.suspects.length === 0) lines.push('Suspects: no file changed in between (look at the environment, test order, or external services)')
132    else {
133      lines.push('Suspects (files changed since then, ranked):')
134      w.suspects.forEach((s, i) => {
135        lines.push(`  ${i + 1}. ${s.path}  score ${s.score} — ${s.reasons.join('; ')}`)
136        lines.push(`     changed by ${s.steps.map(circled).join(' ')}`)
137      })
138    }
139    if (t.failures.length > 0) lines.push(`Failures: ${t.failures.slice(0, 5).map(f => `${clip(redactSecrets(f.name), 200)}${f.line !== undefined ? ` (${f.file}:${f.line})` : ''}`).join(', ')}`)
140  }
141  if (w.fixedBy) {
142    const run = `${circled(w.fixedBy.run.n)} ${label(w.fixedBy.run)} ${testSummary(w.fixedBy.run.test!)}`
143    if (w.fixedBy.steps.length === 0) lines.push(`Passed later at ${run} with no file changes in between (flaky, or the environment changed)`)
144    else if (labels) lines.push(`Fixed by: ${w.fixedBy.steps.map(n => labels(n) ?? circled(n)).join(', ')} → ${run}`)
145    else lines.push(`Fixed by: ${w.fixedBy.steps.map(circled).join(', ')} (${w.fixedBy.files.join(', ')}) → ${run}`)
146  }
147  if (w.improved) lines.push(`Improved: ${w.improved.from} → ${w.improved.to} failed at ${circled(w.improved.run.n)}`)
148  lines.push('(inferred from failure-output mentions, recency and change counts — not proof)')
149  return lines.map(redactSecrets).join('\n')
150}
151
152export function renderCompare(nameA: string, nameB: string, c: Compared, stat?: string): string {
153  const step = (s: Step | undefined): string => (s ? `${circled(s.n)} ${label(s)}` : '(end)')
154  const lines = [`Compare ${nameA} ↔ ${nameB}`, `Shared start: ${c.common} step(s)`, `Diverged at: ${nameA} ${step(c.divergeA)} · ${nameB} ${step(c.divergeB)}`]
155  lines.push(`Only on ${nameA} (${c.onlyA.length}): ${c.onlyA.slice(0, 20).map(step).join(', ') || '—'}`)
156  lines.push(`Only on ${nameB} (${c.onlyB.length}): ${c.onlyB.slice(0, 20).map(step).join(', ') || '—'}`)
157  lines.push(`Matched after the divergence: ${c.matched}`)
158  const test = (s: Step | undefined): string => (s?.test ? testSummary(s.test) : 'no test run')
159  lines.push(`Last test: ${nameA} ${test(c.lastTestA)} · ${nameB} ${test(c.lastTestB)}`)
160  if (stat !== undefined) lines.push(`Files (${nameA} head → ${nameB} head):`, stat.trimEnd() || '  (identical)')
161  return lines.map(redactSecrets).join('\n')
162}
163
164export function renderRerun(step: Step, c: OutputComparison, where: string): string {
165  const lines = [`Rerun of ${circled(step.n)} ${label(step)} (${where})`]
166  if (c.identical) lines.push('Result: identical')
167  else {
168    lines.push(`Result: changed — +${c.addedCount} −${c.removedCount} line(s) (first 2 KiB compared)`)
169    for (const line of c.removed) lines.push(`  − ${clip(line, 160)}`)
170    for (const line of c.added) lines.push(`  + ${clip(line, 160)}`)
171  }
172  if (c.was && c.now) lines.push(`Test: ${testSummary(c.was)} → ${testSummary(c.now)}`)
173  return lines.map(redactSecrets).join('\n')
174}
175
176// `/tm rerun n --isolated` without `--yes`: the command first, then where it would run and why it may not.
177// `before` is the step whose snapshot the copy would hold; `blocked` says why it cannot run now.
178export function renderIsolatedPlan(step: Step, command: string, before: Step | undefined, blocked?: string): string {
179  const copy = before?.snapshot ? `a throwaway copy of ${circled(before.n)}'s snapshot (${before.snapshot.commit.slice(0, 7)})` : 'a throwaway copy of the snapshot before it'
180  const lines = [
181    `$ ${redactSecrets(command)}`,
182    `Dry run of ${circled(step.n)} ${label(step)}: nothing ran. With --yes this runs through \`sh -lc\` in ${copy}, without a permission prompt.`,
183    'Snapshot copy only, not a sandbox: environment variables, your home directory, absolute paths and external services remain accessible. Ignored files such as dependencies are not in the copy; project changes are recorded as external steps.',
184  ]
185  lines.push(blocked !== undefined ? `It would not run now: ${blocked}` : `Run /tm rerun ${step.n} --isolated --yes to run it.`)
186  return lines.map(redactSecrets).join('\n')
187}
188
189// `step` is the step asked for; `target` the step whose snapshot is used (the same, or the nearest earlier one).
190export function renderRestorePlan(step: Step, target: Step, files: readonly FileChange[], applied: boolean, branch?: string, told = true): string {
191  const n = step.n
192  const what = `${circled(n)} ${label(step)}`
193  const head = `snapshot ${target.snapshot?.commit.slice(0, 7) ?? '?'}${target.n !== n ? ` from ${circled(target.n)}` : ''}`
194  const lines = applied
195    ? [`Restored to ${what} (${head}): ${files.length} file(s) changed.`]
196    : [`Restore to ${what} (${head}): ${files.length} file(s) would change.`]
197  for (const f of files.slice(0, 50)) lines.push(`  ${f.status} ${f.path}`)
198  if (files.length > 50) lines.push(`  … +${files.length - 50} more`)
199  if (applied) {
200    lines.push(`The state before the restore was snapshotted, so this can be undone. ${told ? 'The model was told the files changed.' : 'The note to the model could not be added; tell it the files changed before it edits them.'}`)
201    if (branch !== undefined) lines.push(`New steps go to branch '${branch}'. The conversation itself was not rewound.`)
202  } else {
203    lines.push(branch !== undefined ? `Run /tm branch ${n} ${branch} --yes to apply.` : `Run /tm restore ${n} --yes to apply (the current state is snapshotted first, so it can be undone).`)
204  }
205  return lines.map(redactSecrets).join('\n')
206}
207
208export function renderSessions(rows: readonly { id: string; startedAt: number; current: boolean }[]): string {
209  if (rows.length === 0) return 'No recorded sessions for this project.'
210  const sorted = [...rows].sort((a, b) => b.startedAt - a.startedAt)
211  const started = (at: number): string => {
212    const date = new Date(at)
213    return Number.isFinite(at) && Number.isFinite(date.getTime()) ? date.toISOString() : '?'
214  }
215  return ['Recorded sessions (newest first):', ...sorted.map(r => `  ${r.id}${r.current ? ' (this session)' : ''} · started ${started(r.startedAt)}`)].join('\n')
216}
217
218export function statusLine(rec: Recorder, snapMs: readonly number[], off?: string, recording?: import('./store.ts').RecordingStatus): string {
219  const steps = rec.all().filter(s => s.kind !== 'baseline')
220  const last = steps.at(-1)?.n ?? 0
221  const storage = recording ? ` · ${recording.state}` : ''
222  if (off) return `tm ${circled(last)} · snapshots off${storage}`
223  const snaps = steps.filter(s => s.snapshot?.changed).length
224  const mean = avg(snapMs)
225  return `tm ${circled(last)} · ${snaps} snaps${mean !== undefined ? ` · ${mean}ms` : ''}${storage}`
226}
227
hooks/core/store.ts 112 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
47export type RecordingStatus = { state: 'recording' | 'degraded' | 'not persisted'; pending: number; error?: string }
48
49// Keep the latest full body of every dirty file, including rolled-over chunks. Failed writes
50// remain dirty until acknowledged; concurrent flushes cannot overwrite a newer body with an older one.
51export class DurableWriter {
52  private readonly pending = new Map<string, string>()
53  private writing = Promise.resolve()
54  private saved = false
55  private error: string | undefined
56
57  constructor(private readonly chunks: ChunkWriter) {}
58
59  queueFile(name: string, body: string): void {
60    this.pending.set(name, body)
61  }
62
63  add(records: readonly Rec[]): void {
64    for (const rec of records) {
65      const { name, body } = this.chunks.add(rec)
66      this.queueFile(name, body)
67    }
68  }
69
70  get status(): RecordingStatus {
71    return {
72      state: this.error === undefined ? 'recording' : this.saved ? 'degraded' : 'not persisted',
73      pending: this.pending.size,
74      ...(this.error !== undefined ? { error: this.error } : {}),
75    }
76  }
77
78  flush(write: (name: string, body: string) => Promise<void>): Promise<void> {
79    this.writing = this.writing.then(async () => {
80      try {
81        for (const [name, body] of this.pending) {
82          await write(name, body)
83          if (name.endsWith('.jsonl')) this.saved = true
84          if (this.pending.get(name) === body) this.pending.delete(name)
85        }
86        this.error = undefined
87      } catch (error) {
88        this.error = error instanceof Error ? error.message : String(error)
89      }
90    })
91    return this.writing
92  }
93}
94
95// A session's chunks in write order (load id, then chunk number); lines that are not records are skipped.
96export function parseChunks(files: readonly { name: string; text: string }[]): Rec[] {
97  const out: Rec[] = []
98  const chunks = files.filter(f => f.name.endsWith('.jsonl')).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
99  for (const file of chunks) {
100    for (const line of file.text.split('\n')) {
101      if (line.trim() === '') continue
102      try {
103        const rec = JSON.parse(line) as Rec
104        if (rec && typeof rec === 'object' && typeof rec.type === 'string') out.push(rec)
105      } catch {
106        // a torn or foreign line
107      }
108    }
109  }
110  return out
111}
112
hooks/core/redact.ts 31 lines
1// Canonical source; run node scripts/sync-redactors.mjs after editing.
2// Best-effort removal of obvious secrets from command lines before they are exported.
3const VALUE = String.raw`("(?:\\[\s\S]|[^"\\])*"|'[^']*'|(?:\\[\s\S]|[^\s'"\\])+)`
4const HEADER = String.raw`(?:authorization|[a-z0-9][a-z0-9-]*-(?:key|token|secret))`
5// Only match user:password arguments, leaving username-only options (e.g. mysql -u) alone.
6const CREDENTIAL = String.raw`("(?:\\[\s\S]|[^":\\])*:(?:\\[\s\S]|[^"\\])*"|'[^':]*:[^']*'|[^\s'":]*:[^\s'"]*)`
7const RULES: [RegExp, string][] = [
8  // Shell-quoted headers must be handled before key/value rules: an escaped quote is
9  // part of the secret, while the opposite quote is ordinary data inside this shell word.
10  [new RegExp(String.raw`("` + HEADER + String.raw`\s*:\s*)(?:\\[\s\S]|[^"\\])*"`, 'gi'), '$1[REDACTED]"'],
11  [new RegExp(String.raw`('` + HEADER + String.raw`\s*:\s*)[^']*'`, 'gi'), "$1[REDACTED]'"],
12  [new RegExp(String.raw`(\b` + HEADER + String.raw`\s*:\s*)(?:\\[\s\S]|[^'"\r\n\\])+`, 'gi'), '$1[REDACTED]'],
13  [new RegExp(String.raw`((?:["']?)(?:password|passwd|pwd|token|(?:access|refresh|id|session|auth)[_-]?token|(?:api|access|secret|private)[_-]?key|client[_-]?secret|secret|authorization|credentials?|cookies?)(?:["']?)\s*:\s*)` + VALUE, 'gi'), '$1[REDACTED]'],
14  [/(\bbearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[REDACTED]'],
15  [new RegExp(String.raw`(--(?:password|token|api-key|secret)(?:=|\s+))` + VALUE, 'gi'), '$1[REDACTED]'],
16  [/([a-z][a-z0-9+.-]*:\/\/[^\s/:@'"]*:)[^\s/@'"]*@/gi, '$1[REDACTED]@'],
17  [new RegExp(String.raw`(\s(?:--user(?:=|\s+)|-u\s*))` + CREDENTIAL, 'g'), '$1[REDACTED]'],
18  [/(\bmysql\w*\b[^\n;&|]*?\s-p)(?=\S)("[^"]*"|'[^']*'|\S+)/g, '$1[REDACTED]'],
19  [new RegExp(String.raw`\b([A-Za-z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)[A-Za-z0-9_]*)=` + VALUE, 'gi'), '$1=[REDACTED]'],
20]
21
22export function redactSecrets(text: string): string {
23  return RULES.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text)
24}
25
26// Match complete key names, including common camelCase/underscore/hyphen spellings.
27// Do not match ordinary names such as token_count or keyboard.
28export function isSecretKey(key: string): boolean {
29  return /^(?:password|passwd|pwd|authorization|proxyauthorization|apikey|accesskey|secretkey|privatekey|clientsecret|secret|token|accesstoken|refreshtoken|idtoken|sessiontoken|authtoken|credential|credentials|cookie|setcookie)$/i.test(key.replace(/[_\s-]/g, '')) || /^[a-z0-9][a-z0-9-]*-(?:key|token|secret)$/i.test(key)
30}
31
hooks/core/testparse.ts 170 lines
1import type { TestFailure, TestRun } from './types.ts'
2import { contentHash } from './hash.ts'
3import { redactSecrets } from './redact.ts'
4
5export function testIdentity(command: string, cwd: string): NonNullable<TestRun['identity']> {
6  let normalized = ''
7  let quote = ''
8  // Multiline shell/heredoc whitespace can be data, so preserve it exactly.
9  if (command.includes('\n') || command.includes('$') || command.includes('`')) return { commandHash: contentHash(command), cwd }
10  const text = command.trim()
11  for (let i = 0; i < text.length; i += 1) {
12    const ch = text[i]!
13    if (ch === '\\' && quote !== "'") {
14      normalized += ch + (text[++i] ?? '')
15    } else if (quote) {
16      normalized += ch
17      if (ch === quote) quote = ''
18    } else if (ch === "'" || ch === '"') {
19      quote = ch
20      normalized += ch
21    } else if (ch === ' ' || ch === '\t') {
22      if (!normalized.endsWith(' ')) normalized += ' '
23    } else normalized += ch
24  }
25  return { commandHash: contentHash(normalized), cwd }
26}
27
28const MAX_FAILURES = 50
29const MAX_LOCATIONS = 100
30const MAX_PATHS = 200
31const SCAN_MAX = 128 * 1024
32const EXT = String.raw`(?:py|[cm]?[jt]sx?|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|scala|exs?)`
33const FILE = String.raw`(?<![\w.@/-])(?:\.{0,2}\/)?(?:[\w.@-]+\/)*[\w.@-]+\.${EXT}`
34const PATH_TOKEN = new RegExp(String.raw`${FILE}\b`, 'g')
35const LOCATION = new RegExp(String.raw`(${FILE})(?::|", line )(\d+)`, 'g')
36const VENDOR = /node_modules|site-packages|dist-packages|\/lib\/python|<frozen|^internal\/|\/rustc\//
37
38type Counts = Pick<TestRun, 'passed' | 'failed' | 'errors' | 'skipped'>
39type Found = Counts & { framework: string; failures: TestFailure[] }
40
41function count(text: string, re: RegExp): Counts {
42  const c: Counts = {}
43  for (const m of text.matchAll(re)) {
44    const n = Number(m[1])
45    const word = (m[2] ?? '').toLowerCase()
46    if (word.startsWith('pass')) c.passed = (c.passed ?? 0) + n
47    else if (word.startsWith('fail')) c.failed = (c.failed ?? 0) + n
48    else if (word.startsWith('error')) c.errors = (c.errors ?? 0) + n
49    else if (word.startsWith('skip') || word.startsWith('ignored')) c.skipped = (c.skipped ?? 0) + n
50  }
51  return c
52}
53
54function parseCargo(text: string): Found | undefined {
55  const results = [...text.matchAll(/test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed/g)]
56  if (results.length === 0) return undefined
57  return {
58    framework: 'cargo',
59    passed: results.reduce((s, m) => s + Number(m[1]), 0),
60    failed: results.reduce((s, m) => s + Number(m[2]), 0),
61    failures: [...text.matchAll(/^---- (\S+) stdout ----$/gm)].map(m => ({ name: m[1]! })),
62  }
63}
64
65function parsePytest(text: string): Found | undefined {
66  const summary = /(?:^|=+ )((?:\d+ (?:failed|passed|skipped|errors?|xfailed|xpassed|deselected|warnings?|rerun)(?:, )?)+) in [\d.]+s/m.exec(text)
67  const lines = [...text.matchAll(/^(?:FAILED|ERROR) (\S+?)(?:::(\S+))?(?: - .*)?$/gm)]
68  if (!summary && lines.length === 0) return undefined
69  return {
70    framework: 'pytest',
71    ...count(summary?.[1] ?? '', /(\d+) (passed|failed|errors?|skipped)\b/g),
72    failures: lines.map(m => ({ name: m[2] ? `${m[1]}::${m[2]}` : m[1]!, file: m[1]! })),
73  }
74}
75
76function parseJest(text: string): Found | undefined {
77  const jest = /^Tests:\s+(.+)$/m.exec(text)
78  const vitest = jest ? undefined : /^[ \t]*Tests\s+(.+?)\s*(?:\(\d+\))?\s*$/m.exec(text)
79  const line = jest?.[1] ?? vitest?.[1]
80  if (line === undefined) return undefined
81  const failures: TestFailure[] = []
82  for (const m of text.matchAll(/^[ \t]*● (.+?)\s*$/gm)) if (!/^Console\b/.test(m[1]!)) failures.push({ name: m[1]! })
83  for (const m of text.matchAll(/^[ \t]*(?:FAIL|×|✗)\s+(\S+\.[cm]?[jt]sx?)\s+>\s+(.+?)\s*$/gm)) failures.push({ name: `${m[1]} > ${m[2]}`, file: m[1]! })
84  return { framework: jest ? 'jest' : 'vitest', ...count(line, /(\d+) (passed|failed|skipped)\b/g), failures }
85}
86
87function parseGo(text: string): Found | undefined {
88  const fails = [...text.matchAll(/^[ \t]*--- FAIL: (\S+)/gm)]
89  const passes = [...text.matchAll(/^[ \t]*--- PASS: /gm)]
90  if (fails.length === 0 && passes.length === 0 && !/^(?:ok|FAIL)\s+\S+\s+[\d.]+s/m.test(text)) return undefined
91  return {
92    framework: 'go',
93    failed: fails.length,
94    ...(passes.length > 0 ? { passed: passes.length } : {}),
95    failures: fails.map(m => ({ name: m[1]! })),
96  }
97}
98
99function parseNodeTest(text: string): Found | undefined {
100  const pass = /^# pass (\d+)$/m.exec(text)
101  const fail = /^# fail (\d+)$/m.exec(text)
102  if (!pass && !fail) return undefined
103  return {
104    framework: 'node:test',
105    ...(pass ? { passed: Number(pass[1]) } : {}),
106    ...(fail ? { failed: Number(fail[1]) } : {}),
107    failures: [...text.matchAll(/^not ok \d+ - (.+)$/gm)].map(m => ({ name: m[1]!.trim() })),
108  }
109}
110
111export function sameFile(a: string, b: string): boolean {
112  const x = a.replace(/^\.\//, '')
113  const y = b.replace(/^\.\//, '')
114  return x === y || x.endsWith(`/${y}`) || y.endsWith(`/${x}`)
115}
116
117// Locations and paths come from the first and last 128 KiB only: a hook must not stall on a huge dump.
118function scanWindow(text: string): string {
119  return text.length <= 2 * SCAN_MAX ? text : `${text.slice(0, SCAN_MAX)}\n${text.slice(-SCAN_MAX)}`
120}
121
122function locationsOf(text: string): { file: string; line: number }[] {
123  const out: { file: string; line: number }[] = []
124  const seen = new Set<string>()
125  for (const m of text.matchAll(LOCATION)) {
126    const file = m[1]!
127    const key = `${file}:${m[2]}`
128    if (VENDOR.test(file) || seen.has(key)) continue
129    seen.add(key)
130    out.push({ file, line: Number(m[2]) })
131    if (out.length >= MAX_LOCATIONS) break
132  }
133  return out
134}
135
136function pathsOf(text: string): string[] {
137  const out = new Set<string>()
138  for (const m of text.matchAll(PATH_TOKEN)) {
139    if (VENDOR.test(m[0])) continue
140    out.add(m[0])
141    if (out.size >= MAX_PATHS) break
142  }
143  return [...out]
144}
145
146// What a test command's output says: framework, counts, failing tests and where they point.
147export function parseTestOutput(text: string, isError: boolean, invocation?: { command: string; cwd: string }): TestRun {
148  const found = parseCargo(text) ?? parsePytest(text) ?? parseJest(text) ?? parseGo(text) ?? parseNodeTest(text)
149  const window = scanWindow(text)
150  const locations = locationsOf(window)
151  const failures = (found?.failures ?? []).slice(0, MAX_FAILURES).map(f => ({ ...f, name: redactSecrets(f.name) }))
152  for (const loc of locations) {
153    const failure = failures.find(f => f.file !== undefined && f.line === undefined && sameFile(f.file, loc.file))
154    if (failure) failure.line = loc.line
155  }
156  const run: TestRun = {
157    framework: found?.framework ?? 'unknown',
158    ...(invocation ? { identity: testIdentity(invocation.command, invocation.cwd) } : {}),
159    ok: !isError && (found?.failed ?? 0) === 0 && (found?.errors ?? 0) === 0,
160    failures,
161    locations,
162    paths: pathsOf(window),
163  }
164  if (found?.passed !== undefined) run.passed = found.passed
165  if (found?.failed !== undefined) run.failed = found.failed
166  if (found?.errors !== undefined) run.errors = found.errors
167  if (found?.skipped !== undefined) run.skipped = found.skipped
168  return run
169}
170
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 131 lines
1import { classifyCommand } from './classify.ts'
2import { contentHash } from './hash.ts'
3import { isSecretKey, redactSecrets } from './redact.ts'
4import type { StepResult } from './types.ts'
5
6export const MUTATING_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Bash'])
7export const READ_ONLY_TOOLS = new Set([
8  'Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'TodoWrite', 'Task', 'Agent', 'ToolSearch', 'Skill',
9  'SendMessage', 'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode', 'BashOutput', 'KillShell',
10  'ListMcpResourcesTool', 'ReadMcpResourceTool', 'NotebookRead',
11])
12// Not snapshotted (whatever they change arrives as steps of their own), but running one again acts:
13// a subagent or skill edits files and runs commands, a message reaches an agent, a shell is killed.
14export const ACTING_TOOLS = new Set(['Task', 'Agent', 'Skill', 'SendMessage', 'KillShell'])
15export const INPUT_STRING_MAX = 4096
16export const RESULT_HEAD_MAX = 2048
17const ENGINE_KEYS = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
18
19const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
20
21// Unknown and MCP tools count as mutating: a snapshot that finds nothing changed costs one tree hash.
22// Every Bash counts unless asked otherwise: a "search" command can still write through a redirect.
23export function isMutating(tool: string, input: Record<string, unknown>, skipSearchBash = false): boolean {
24  if (tool === 'Bash') return !skipSearchBash || classifyCommand(str(input.command) ?? '') !== 'search'
25  if (MUTATING_TOOLS.has(tool)) return true
26  return !READ_ONLY_TOOLS.has(tool)
27}
28
29export function relPath(root: string, path: string): string {
30  const base = root.replace(/\/+$/, '')
31  return base !== '' && path.startsWith(`${base}/`) ? path.slice(base.length + 1) : path
32}
33
34// A free-text target: secrets masked, whitespace collapsed to one line, cut to 120 chars.
35const oneLine = (text: string | undefined): string | undefined => (text ? redactSecrets(text).replace(/\s+/g, ' ').trim().slice(0, 120) : undefined)
36
37export function targetOf(tool: string, input: Record<string, unknown>, root: string): string | undefined {
38  switch (tool) {
39    case 'Read':
40    case 'Edit':
41    case 'MultiEdit':
42    case 'Write': {
43      const path = str(input.file_path)
44      return path ? relPath(root, path) : undefined
45    }
46    case 'NotebookEdit': {
47      const path = str(input.notebook_path)
48      return path ? relPath(root, path) : undefined
49    }
50    case 'Bash': {
51      return oneLine(str(input.command))
52    }
53    case 'Grep':
54    case 'Glob':
55      return oneLine(str(input.pattern))
56    case 'WebFetch':
57      return oneLine(str(input.url))
58    case 'WebSearch':
59      return oneLine(str(input.query))
60    case 'Agent':
61    case 'Task':
62      return oneLine(str(input.subagent_type) ?? str(input.description))
63    default:
64      return undefined
65  }
66}
67
68// The input as stored: engine keys dropped, secrets masked, long strings cut (the snapshot holds the files).
69export function summarizeInput(input: Record<string, unknown>): { input: Record<string, unknown>; lossy: boolean } {
70  let lossy = false
71  const field = (key: string, value: unknown, depth: number): unknown => {
72    if (isSecretKey(key)) {
73      lossy = true
74      return '[REDACTED]'
75    }
76    return walk(value, depth)
77  }
78  const walk = (value: unknown, depth: number): unknown => {
79    if (typeof value === 'string') {
80      const masked = redactSecrets(value)
81      if (masked !== value) lossy = true
82      if (masked.length <= INPUT_STRING_MAX) return masked
83      lossy = true
84      return `${masked.slice(0, INPUT_STRING_MAX)}…(+${masked.length - INPUT_STRING_MAX} chars, #${contentHash(value)})`
85    }
86    if (Array.isArray(value)) {
87      if (depth > 4) {
88        lossy = true
89        return '[…]'
90      }
91      return value.map(v => walk(v, depth + 1))
92    }
93    if (value && typeof value === 'object') {
94      if (depth > 4) {
95        lossy = true
96        return '{…}'
97      }
98      return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, field(k, v, depth + 1)]))
99    }
100    return value
101  }
102  const out: Record<string, unknown> = {}
103  for (const [key, value] of Object.entries(input)) if (!ENGINE_KEYS.has(key)) out[key] = field(key, value, 0)
104  return { input: out, lossy }
105}
106
107// The input exactly as the tool got it, engine keys aside: what a rerun passes back.
108export function callInput(input: Record<string, unknown>): Record<string, unknown> {
109  return Object.fromEntries(Object.entries(input).filter(([key]) => !ENGINE_KEYS.has(key)))
110}
111
112export function resultText(answer: unknown): string {
113  const a = (answer ?? {}) as Record<string, unknown>
114  if (typeof a.text === 'string') return a.text
115  if (typeof a.deny === 'string') return a.deny
116  return ''
117}
118
119export function summarizeResult(answer: unknown): StepResult {
120  const a = (answer ?? {}) as Record<string, unknown>
121  const denied = typeof a.deny === 'string'
122  const text = resultText(answer)
123  return {
124    isError: denied || a.isError === true,
125    denied,
126    chars: text.length,
127    hash: contentHash(text),
128    head: redactSecrets(text).slice(0, RESULT_HEAD_MAX),
129  }
130}
131
hooks/core/types.ts 83 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  // Missing in legacy logs or when the full invocation was not observed.
23  identity?: { commandHash: string; cwd: string }
24  ok: boolean
25  passed?: number
26  failed?: number
27  errors?: number
28  skipped?: number
29  failures: TestFailure[]
30  locations: { file: string; line: number }[]
31  // Path-like tokens anywhere in the whole output, for causality.
32  paths: string[]
33}
34
35export type StepKind = 'baseline' | 'tool' | 'external' | 'restore'
36
37export type StepResult = { isError: boolean; denied: boolean; chars: number; hash: string; head: string }
38
39export type Step = {
40  n: number
41  id: string
42  turn: number
43  agentId?: string
44  branch: string
45  parent: number
46  tool: string
47  kind: StepKind
48  input: Record<string, unknown>
49  // The stored input was cut or redacted, so it cannot be replayed from the log alone.
50  inputLossy?: boolean
51  target?: string
52  startedAt: number
53  endedAt?: number
54  result?: StepResult
55  snapshot?: SnapshotRef
56  test?: TestRun
57  rerunOf?: number
58}
59
60export type TurnRec = { n: number; at: number; prompt: { chars: number; hash: string; head?: string } }
61
62export type ModelRec = {
63  turn: number
64  index: number
65  agentId?: string
66  model: string
67  stopReason: string | null
68  usage: unknown
69  text: { chars: number; hash: string; body?: string }
70  toolUses: { id: string; tool: string }[]
71}
72
73export type BranchRec = { name: string; fromStep: number; at: number }
74
75export type SessionRec = { sessionId: string; root: string; startedAt: number; loadId: string }
76
77export type Rec =
78  | ({ type: 'session' } & SessionRec)
79  | ({ type: 'turn' } & TurnRec)
80  | ({ type: 'step' } & Step)
81  | ({ type: 'model' } & ModelRec)
82  | ({ type: 'branch' } & BranchRec)
83