No description.

A hook that logs each file that an agent session changes, with the tool, the call window, the first changed line, and the git blobs of the file before and after the call. The session-files skill and the agent-changed-files package of the chezmoi repo read the log to find the changes of one session.
One core, core.ts, serves four agents through three adapters:
| Agent | Adapter | Loaded by |
|---|---|---|
| Claude Code | hooks/register.ts: a mod on tool.call and session.start | CLAUDE_CODE_PLUGIN_DIRS in dot_claude/settings.json.tmpl |
| pi | pi.ts: tool_call, tool_result, and session_start | extensions in dot_pi/agent/modify_settings.json |
| omp | omp.ts: the track of pi.ts with the omp tools, which adds a git snapshot around eval and a write to xd://resolve | extensions in dot_omp/private_agent/private_config.yml |
| Codex | command.ts: a command hook, node command.ts codex | dot_codex/modify_hooks.json |
| Agent and tool | Events | Files | Line |
|---|---|---|---|
| Claude Code Edit, Write | tool.call, after next | file_path of the input | First hunk of structuredPatch in the result. A new file has no hunks: line 1 |
| Claude Code NotebookEdit | tool.call, after next | notebook_path of the input | First line that differs between original_file and updated_file of the result |
| Claude Code Bash | tool.call, before and after next | Snapshot difference | First hunk of git diff-files -p -U0 |
| Codex Bash | PreToolUse, PostToolUse, also after a failed command | Snapshot difference | The same |
Codex apply_patch | PreToolUse, PostToolUse | *** Add/Update/Delete File, Move to | From the snapshot. A new file outside it: 1 |
pi and omp bash | tool_call, tool_result | Snapshot difference | The same as Bash |
omp eval, and a write to xd://resolve | tool_call, tool_result | Snapshot difference | The same as Bash |
pi and omp edit | tool_call, tool_result | pi: the input path. omp: each file of the tool details | firstChangedLine of the details. An omp file it creates: 1 |
pi and omp write | tool_call, tool_result | The input path | The old file, read at the call, against the file after it |
| Each agent | Session start | None: deletes logs and state after 30 days | None |
A snapshot difference is the set of files that differ between the work tree after the call and a git index of the work tree before it, see Snapshots.
edit, and pi write: a failed call logs nothing.write that leaves the file as it was logs nothing.A tool that names its files also logs a file outside every repo and a git-ignored file.
An omp edit can change several files, delete a file, or move one. A delete logs D, and a move logs the old path as D and the new path as A. An omp ast_edit changes files only when a write to xd://resolve applies its preview, and the snapshot of that write logs them.
The hook does not log other tools that write files, for example a tool of another extension.
agent-changed-files package drops such a write when another session logged it with a tool path.run_in_background) returns before the command ends, so the hook misses its later writes.$.fs, which refuses a file over 4 MiB, and $.process.run keeps 4 MiB of output. So a session log over 4 MiB gives no touched repos, an Edit of a file over 4 MiB gets no before blob, and a patch over 4 MiB gives the paths with an empty line. command.ts and pi.ts read up to 64 MiB of output.tool_result leaves its index links. The prune deletes them after one day./tmp/x. pi logs the resolved path, /private/tmp/x on macOS.objects/ of a session when both its log and its state are gone, so after 30 days of no use.git.command.ts needs it, because this version runs .ts files without a flag.docs/agents/codex.md.A tool that names its files, such as Edit or pi edit, gives the path in its input, so the hook needs no snapshot. For Bash and the other tools that name no file, the hook compares the work tree before and after the call. In the devcontainer, the mod adds 17 to 28 ms at the start of a Bash call, 10 to 13 ms at its end, and 5 to 6 ms after an Edit or a Write, see docs/research.md.
Bash names no file, so the hook stores the work tree in a git index before the call. After the call, it logs the files that differ from that index. It snapshots the repo of the working directory and each repo in the repo field of the session log, and apply_patch also snapshots the repos of its files.
The blobs of a snapshot go to the session objects/, see Blobs. The snapshot state is in ~/.cache/track-session-changes/<agent>/<session id>/:
| Path | Content |
|---|---|
index/<hash> | The git index of one repo, named by the SHA-1 of the repo path |
repos.json | The repo of each directory that a call looked up |
calls/<id>.<n>.<ms>.index | A link to the index of repo n at the start of the call, made at time <ms>, removed at the end of the call |
pending/<id> | Codex only: the start time and the repo records from PreToolUse, removed at the end of the call |
blobs.git | An empty bare repo. git writes no object outside a repo, so the blobs of a file outside every repo go through this one |
The mod and pi.ts keep the snapshot of a call in memory from the start of the call to its end. command.ts runs once for each event, so it keeps the snapshot in pending/.
For how a snapshot works and how the line comes from the diff, see docs/implementation.md.
The hook stores the blobs of each logged file in ~/.local/state/agent-changes/<agent>/<session id>/objects/, a git object directory beside the log. In the devcontainer, the bind mount of the log tree puts these blobs on the host disk too. The hook reads the objects of the repo as an alternate, so it does not copy a blob that the repo holds.
git hash-object -w writes each blob, so a blob gets the filters of the repo attributes, as with git add.
| Tool | Before blob | After blob |
|---|---|---|
| Claude Code Edit and Write | originalFile of the result, with the line endings of the file. Else a rebuild of an Edit | The file on disk |
| Claude Code NotebookEdit | original_file of the result | The file on disk |
pi and omp edit, write | The files that the input names, read at the tool call | The file on disk |
Bash, apply_patch | The index entry of the file in the snapshot of the call | The file on disk |
| A file that the call made | None | As above |
| A file that the call deleted | As above | None |
A before blob is missing in these cases:
apply_patch file outside every snapshot repo.An after blob is missing for a file that git cannot read.
Nothing. The hook never blocks or changes a tool call, and it sends no output to the model. command.ts always exits 0 and prints nothing.
The hook shows no message in the transcript or the terminal. Its only output is the session log.
Each session writes one log: ~/.local/state/agent-changes/<agent>/<session id>.tsv, with claude, codex, pi, or omp as the agent. Subagent calls carry the session ID of the main session, so they go to the same log. In pi and omp, the session ID is ctx.sessionManager.getSessionId(), which is also part of the name of the session file.
The log has one line per file per call, with tab-separated fields:
| Field | Example | Note |
|---|---|---|
| start | 2026-10-14T09:12:03.120Z | UTC with milliseconds |
| end | 2026-10-14T09:12:03.480Z | UTC with milliseconds |
| tool | Bash | The tool name of the agent, such as Edit or pi's edit |
| status | M | A, M, or D. A type change, such as file to link, is M |
| repo | /Users/me/repo | Top of the git work tree. Empty outside a repo |
| path | /Users/me/repo/x.org | Absolute |
| line | 412 | First changed line after the call. Empty for D or unknown |
| before | 3b18e512dbc8… | Git blob ID of the file before the call. Empty for A |
| after | 9daeafb9864c… | Git blob ID of the file after the call. Empty for D |
A line with 7 fields has no before and after. A blob ID is empty also when the hook cannot know it, see Blobs. The agent-changed-files package reads the blobs to find the changes of one session in a file.
The hook has no variables or config files, and it does not read AGENT_HOOKS_DISABLE. To turn it off for one agent, remove its entry from the file in the "Loaded by" column of the adapter table.
The hook fails open: no failure blocks or changes a tool call, and you see no message.
pi.ts: each step catches its own errors and drops them.command.ts: always exits 0 and prints nothing.node on PATH: the Codex entries exit 0, and the SessionStart check of Codex gives a warning.node test/check.ts checks the parsers, command.ts, the mod, pi.ts, and omp.ts, see docs/testing.md.
hooks/register.ts 135 lines1/**
2 * Claude Code mod: logs the files that a Bash, Write, Edit, or NotebookEdit
3 * call changed, and prunes old logs at the start of a session. A Bash call is
4 * snapshotted before `next` and compared after it, and the snapshot stays in
5 * memory between the two. Advisory: each step catches its own errors, so a
6 * failure never stops or changes the tool call.
7 */
8
9import type { EngineInterface, Register } from "claude-code";
10import {
11 beginCall,
12 dropCall,
13 endCall,
14 prune,
15 recordTool,
16 type Io,
17 type ToolResult,
18} from "../core.ts";
19
20const ok = (done: Promise<{ exitCode: number }>) =>
21 done.then(
22 (ran) => ran.exitCode === 0,
23 () => false,
24 );
25
26/**
27 * The `Io` of the core over `$`. `$.fs` has no append, copy, link, rename, or
28 * remove, so those run a program. Each `$` call is spelled out here, as the
29 * engine requires.
30 */
31const modIo = ($: EngineInterface): Io => ({
32 read: (path) =>
33 $.fs.read(path).then(
34 (text) => text,
35 () => undefined,
36 ),
37 write: (path, text) => $.fs.write(path, text),
38 append: async (path, text) => {
39 await $.process.run(["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat >>"$1"', "sh", path], {
40 stdin: text,
41 });
42 },
43 exists: (path) => $.fs.exists(path).catch(() => false),
44 mtime: (path) =>
45 $.fs.stat(path).then(
46 (found) => found.mtimeMs,
47 () => undefined,
48 ),
49 list: (dir) =>
50 $.fs.list(dir).then(
51 (entries) => entries.map((entry) => entry.name),
52 () => [],
53 ),
54 realpath: (path) =>
55 $.fs.stat(path, { resolve: true }).then(
56 (found) => found.realPath,
57 () => undefined,
58 ),
59 mkdir: async (path) => {
60 await ok($.process.run(["mkdir", "-p", path]));
61 },
62 copy: (from, to) => ok($.process.run(["cp", "-p", from, to])),
63 link: (from, to) => ok($.process.run(["ln", from, to])),
64 rename: (from, to) => ok($.process.run(["mv", from, to])),
65 remove: async (path) => {
66 // The core builds each path under the home folder; refuse anything else.
67 if (path.startsWith("/") && path.split("/").length > 3)
68 await ok($.process.run(["rm", "-rf", path]));
69 },
70 run: (argv, options) =>
71 $.process.run(argv, options).then(
72 (ran) => ({
73 exitCode: ran.exitCode,
74 stdout: ran.stdout,
75 truncated: ran.isStdoutTruncated,
76 }),
77 () => undefined,
78 ),
79});
80
81export const register: Register = (on) => {
82 on("session.start", async ($, e, next) => {
83 const started = await next(e);
84 try {
85 const home = await $.env.get("HOME");
86 if (home) await prune(modIo($), home);
87 } catch {
88 // Advisory: the prune runs again at the next session start.
89 }
90 return started;
91 });
92
93 on("tool.call", { tool: ["Bash", "Write", "Edit", "NotebookEdit"] }, async ($, e, next) => {
94 const io = modIo($);
95 const begin = async () => {
96 const home = await $.env.get("HOME");
97 if (!home) return undefined;
98 const where = { home, agent: "claude", session: await $.session.id() };
99 return { where, cwd: await $.session.cwd(), start: new Date() };
100 };
101 const call = await begin().catch(() => undefined);
102 if (!call) return next(e);
103
104 if (e.tool === "Bash") {
105 const pending = await beginCall(io, call.where, call.cwd).catch(() => undefined);
106 let ran;
107 try {
108 ran = await next(e);
109 } finally {
110 // A denied call did not run, and a throw leaves nothing to compare.
111 if (ran === undefined || ran.deny !== undefined)
112 await dropCall(io, pending).catch(() => undefined);
113 }
114 if (ran.deny === undefined)
115 await endCall(io, call.where, pending, "Bash").catch(() => undefined);
116 return ran;
117 }
118
119 const ran = await next(e);
120 if (ran.deny !== undefined || ran.isError) return ran;
121 const file = e.tool === "NotebookEdit" ? e.notebook_path : e.file_path;
122 await recordTool(
123 io,
124 call.where,
125 e.tool,
126 file,
127 ran.result as ToolResult,
128 call.cwd,
129 call.start,
130 new Date(),
131 ).catch(() => undefined);
132 return ran;
133 });
134};
135core.ts 917 lines1/**
2 * Records the files that an agent session changes, one log line per file per
3 * tool call. The adapters pass in the file and process functions (`Io`):
4 * hooks/register.ts (the Claude Code mod), pi.ts (pi and omp), and command.ts
5 * (Codex). No Node import, so the mod can run it.
6 *
7 * Log: ~/.local/state/agent-changes/<agent>/<session id>.tsv, one line per file:
8 * start, end, tool, status (A, M, D), repo, path, line, before, after. The
9 * line is the first changed line after the call, empty for D or when it is not
10 * known. Before and after are the git blob IDs of the file around the call.
11 *
12 * Objects: ~/.local/state/agent-changes/<agent>/<session id>/objects/, a git
13 * object directory with the blobs of the snapshots and of each before and
14 * after. It is beside the log, so a bind mount gives the host the blobs of a
15 * container session too. It reads the repo objects as an alternate, so the
16 * hook never writes to a repo.
17 *
18 * State: ~/.cache/track-session-changes/<agent>/<session id>/, with one git
19 * index per repo, the repos found so far, a link to the index of each running
20 * call, and for command.ts the pending calls.
21 */
22
23/** One changed file of one tool call. */
24export interface Change {
25 status: "A" | "M" | "D";
26 /** Absolute path. */
27 path: string;
28 /** First changed line after the call. Undefined for D or when not known. */
29 line?: number;
30 /** The repo top. Undefined when not looked up yet, "" outside a repo. */
31 repo?: string;
32 /** Blob ID of the file before the call. Undefined for A or when not known. */
33 before?: string;
34 /** Blob ID of the file after the call. Undefined for D or when not known. */
35 after?: string;
36 /**
37 * The text before the call, for a tool that names its file. `recordFiles`
38 * stores it as the before blob.
39 */
40 old?: string;
41}
42
43/** A hunk of Claude Code `structuredPatch`, as in Edit and Write results. */
44export interface Hunk {
45 oldStart?: number;
46 oldLines?: number;
47 newStart: number;
48 newLines?: number;
49 lines: string[];
50}
51
52/**
53 * The file and process functions of the core. Paths are absolute. A function
54 * that fails returns undefined, false, or [] instead of throwing.
55 */
56export interface Io {
57 /** The text of a file; undefined when it cannot be read. */
58 read(path: string): Promise<string | undefined>;
59 /** Writes a whole file, and makes its folders. */
60 write(path: string, text: string): Promise<void>;
61 /** Appends to a file, and makes its folders. */
62 append(path: string, text: string): Promise<void>;
63 exists(path: string): Promise<boolean>;
64 /** The last change of a file or folder in ms; undefined when missing. */
65 mtime(path: string): Promise<number | undefined>;
66 /** The names in a folder; [] when it is missing. */
67 list(dir: string): Promise<string[]>;
68 /** The path with every link resolved; undefined when missing. */
69 realpath(path: string): Promise<string | undefined>;
70 /** Makes a folder and its parents. */
71 mkdir(path: string): Promise<void>;
72 /** Copies a file, with its times: git trusts the stat data of the index. */
73 copy(from: string, to: string): Promise<boolean>;
74 /** Makes a hard link. */
75 link(from: string, to: string): Promise<boolean>;
76 rename(from: string, to: string): Promise<boolean>;
77 /** Removes a file or a folder tree; nothing when missing. */
78 remove(path: string): Promise<void>;
79 /**
80 * Runs a program with `env` over the environment of the agent and `stdin`
81 * as its input. Drops stderr. `truncated` when the adapter kept only the
82 * first part of stdout. Undefined when the program cannot start.
83 */
84 run(
85 argv: string[],
86 options?: { env?: Record<string, string>; stdin?: string },
87 ): Promise<{ exitCode: number; stdout: string; truncated: boolean } | undefined>;
88}
89
90/** The log of one session: the home folder, the agent, and the session id. */
91export interface Where {
92 home: string;
93 agent: string;
94 session: string;
95}
96
97const DAY = 24 * 60 * 60 * 1000;
98/** A log or a state directory that was not written for this long is deleted. */
99const KEEP = 30 * DAY;
100/** A pending call this old lost its end event, for example a denied call. */
101const KEEP_PENDING = DAY;
102
103// ── Paths ─────────────────────────────────────────────────────────────────────
104
105/** `path` with `.` and `..` folded, and no empty part. */
106function normalize(path: string): string {
107 const parts: string[] = [];
108 for (const part of path.split("/"))
109 if (part === "..") parts.pop();
110 else if (part !== "" && part !== ".") parts.push(part);
111 return `/${parts.join("/")}`;
112}
113/** `file` against the absolute folder `dir`, as `path.resolve` does. */
114export const resolve = (dir: string, file: string) =>
115 normalize(file.startsWith("/") ? file : `${dir}/${file}`);
116const dirname = (path: string) => path.slice(0, Math.max(path.lastIndexOf("/"), 0)) || "/";
117
118const logRoot = (home: string) => `${home}/.local/state/agent-changes`;
119const stateRoot = (home: string) => `${home}/.cache/track-session-changes`;
120
121const logFile = ({ home, agent, session }: Where) => `${logRoot(home)}/${agent}/${session}.tsv`;
122const stateDir = ({ home, agent, session }: Where) => `${stateRoot(home)}/${agent}/${session}`;
123const objectsDir = ({ home, agent, session }: Where) =>
124 `${logRoot(home)}/${agent}/${session}/objects`;
125
126/** An agent, session, or call id becomes a file name, so it must not be a path. */
127const safeName = (...names: string[]) =>
128 names.every((name) => /^[\w.-]+$/.test(name) && !/^\.+$/.test(name));
129
130/** The SHA-1 of the UTF-8 of `text`, in hex. */
131async function sha1(text: string): Promise<string> {
132 // TextEncoder always returns an ArrayBuffer view. The cast says so for the
133 // test program, where the Claude Code TextEncoder meets the Node digest.
134 const digest = await crypto.subtle.digest(
135 "SHA-1",
136 new TextEncoder().encode(text) as Uint8Array<ArrayBuffer>,
137 );
138 return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
139}
140
141/** Makes `dir` unless it is there: a `$.fs.exists` costs less than a `mkdir`. */
142async function ensureDir(io: Io, dir: string) {
143 if (!(await io.exists(dir))) await io.mkdir(dir);
144}
145
146// ── Git ───────────────────────────────────────────────────────────────────────
147
148/** The stdout of a git command in `top`; undefined when it fails. */
149async function git(
150 io: Io,
151 top: string,
152 args: string[],
153 env: Record<string, string> = {},
154): Promise<{ stdout: string; truncated: boolean } | undefined> {
155 const ran = await io.run(["git", "-C", top, ...args], { env });
156 return ran?.exitCode === 0 ? ran : undefined;
157}
158
159/** A git work tree, with the object directory and the index of the repo. */
160interface Repo {
161 top: string;
162 objects: string;
163 index: string;
164}
165
166/**
167 * The repo that holds `dir`, in one git process. A missing `dir` counts from
168 * its nearest parent that exists. The top keeps symlinks: `--show-toplevel`
169 * would resolve them, and then a Bash path would not match the tool path of
170 * the same file.
171 */
172async function repoAt(io: Io, dir: string): Promise<Repo | undefined> {
173 while (!(await io.exists(dir)) && dirname(dir) !== dir) dir = dirname(dir);
174 const out = await git(io, dir, [
175 "rev-parse",
176 "--show-cdup",
177 "--path-format=absolute",
178 "--git-path",
179 "objects",
180 "--git-path",
181 "index",
182 ]);
183 const [cdup = "", objects, index] = out?.stdout.split("\n") ?? [];
184 if (!objects || !index) return undefined;
185 return { top: resolve(dir, cdup), objects, index };
186}
187
188/**
189 * `repoAt` with a cache in `repos.json` of the session state, so a call starts
190 * no `rev-parse` for a directory that an earlier call looked up. An entry
191 * holds while `<top>/.git` exists and no directory between `dir` and the top
192 * has a `.git`. A directory outside every repo is not cached, so a later
193 * `git init` there counts. The result maps each of `dirs` to its repo,
194 * undefined outside a repo.
195 */
196async function cachedRepos(
197 io: Io,
198 state: string,
199 dirs: string[],
200): Promise<Map<string, Repo | undefined>> {
201 if (dirs.length === 0) return new Map();
202 const file = `${state}/repos.json`;
203 let cache: Record<string, Repo> = {};
204 try {
205 cache = JSON.parse((await io.read(file)) ?? "{}") as Record<string, Repo>;
206 } catch {
207 // A broken cache is rebuilt.
208 }
209 const holds = async (dir: string, { top }: Repo) => {
210 if (!(await io.exists(`${top}/.git`))) return false;
211 for (; dir !== top; dir = dirname(dir))
212 if (dir === dirname(dir) || (await io.exists(`${dir}/.git`))) return false;
213 return true;
214 };
215 let changed = false;
216 const repos = new Map<string, Repo | undefined>();
217 for (const dir of dirs) {
218 if (repos.has(dir)) continue;
219 const known = cache[dir];
220 let repo = known && (await holds(dir, known)) ? known : undefined;
221 if (!repo) {
222 repo = await repoAt(io, dir);
223 if (repo) [cache[dir], changed] = [repo, true];
224 }
225 repos.set(dir, repo);
226 }
227 if (changed) {
228 const tmp = `${file}.${crypto.randomUUID()}`;
229 await io.write(tmp, JSON.stringify(cache));
230 if (!(await io.rename(tmp, file))) await io.remove(tmp);
231 }
232 return repos;
233}
234
235/**
236 * The environment that points git at the session object directory. The repo
237 * objects stay readable as an alternate, and new objects go to the session.
238 * shortcut: `objects/` in the state directory is a second alternate, because
239 * some session states hold blobs there. git only warns when it is missing.
240 * The prune deletes the last of these directories by 2026-11-14; then remove
241 * the second alternate.
242 */
243const objectEnv = (objects: string, state: string, repo: Repo) => ({
244 GIT_OBJECT_DIRECTORY: objects,
245 GIT_ALTERNATE_OBJECT_DIRECTORIES: `${repo.objects}:${state}/objects`,
246});
247
248// ── Blobs ─────────────────────────────────────────────────────────────────────
249
250/**
251 * Writes blobs to the session objects with `git hash-object -w`, in one git
252 * start, and returns their IDs: `old` (the text of `file` before a call) first,
253 * then `file` on disk. In a repo, git applies the filters of the attributes
254 * to both, as `git add` does. Outside a repo, git writes no object, so a bare
255 * repo in the session state takes the write, with no filters. An ID is
256 * undefined when git fails.
257 */
258async function storeBlobs(
259 io: Io,
260 where: Where,
261 repo: Repo | undefined,
262 file: string,
263 { old, after }: { old?: string; after: boolean },
264): Promise<{ before?: string; after?: string }> {
265 if (old === undefined && !after) return {};
266 const objects = objectsDir(where);
267 const state = stateDir(where);
268 await ensureDir(io, objects);
269 let argv: string[];
270 let env: Record<string, string>;
271 if (repo) {
272 argv = ["git", "-C", repo.top, "hash-object", "-w", `--path=${file}`];
273 env = objectEnv(objects, state, repo);
274 } else {
275 const bare = `${state}/blobs.git`;
276 if (!(await io.exists(bare)))
277 await io.run(["git", "init", "-q", "--bare", "--template=", bare]);
278 argv = ["git", `--git-dir=${bare}`, "hash-object", "-w", "--no-filters"];
279 env = { GIT_OBJECT_DIRECTORY: objects };
280 }
281 if (old !== undefined) argv.push("--stdin");
282 if (after) argv.push(file);
283 const ran = await io.run(argv, { env, stdin: old ?? "" });
284 if (ran?.exitCode !== 0) return {};
285 const ids = ran.stdout.split("\n");
286 return old === undefined
287 ? { after: ids[0] }
288 : { before: ids[0], after: after ? ids[1] : undefined };
289}
290
291/**
292 * Writes the files of a repo on disk as blobs, in one git start, and maps each
293 * path to its ID. git stops at a file that it cannot read, so the files after
294 * it go in one more start. A path with a newline gets no ID.
295 */
296async function storeFiles(
297 io: Io,
298 where: Where,
299 repo: Repo,
300 files: string[],
301): Promise<Map<string, string>> {
302 const objects = objectsDir(where);
303 const env = objectEnv(objects, stateDir(where), repo);
304 await ensureDir(io, objects);
305 const ids = new Map<string, string>();
306 let rest = files.filter((file) => !file.includes("\n"));
307 while (rest.length) {
308 const ran = await io.run(["git", "-C", repo.top, "hash-object", "-w", "--stdin-paths"], {
309 env,
310 stdin: rest.map((file) => `${file}\n`).join(""),
311 });
312 if (!ran) break;
313 const found = ran.stdout.split("\n").filter(Boolean);
314 found.forEach((id, i) => ids.set(rest[i] ?? "", id));
315 if (ran.exitCode === 0) break;
316 rest = rest.slice(found.length + 1);
317 }
318 return ids;
319}
320
321/**
322 * Brings the session index of the repo up to date with the work tree, and
323 * links it to `link`, the index of one call. Each session keeps one index per
324 * repo, so `git add -A` hashes only the files whose stat changed since the
325 * last call. It stores their blobs, so the end of the call can diff them.
326 */
327async function snapshot(io: Io, where: Where, repo: Repo, link: string): Promise<boolean> {
328 const state = stateDir(where);
329 const objects = objectsDir(where);
330 const index = `${state}/index/${await sha1(repo.top)}`;
331 await ensureDir(io, `${state}/index`);
332 await ensureDir(io, objects);
333 // Parallel calls share the index, so work on a copy and rename it back.
334 const tmp = `${index}.${crypto.randomUUID()}`;
335 let renamed = false;
336 try {
337 const from = (await io.exists(index)) ? index : repo.index;
338 if ((await io.exists(from)) && !(await io.copy(from, tmp))) return false;
339 const env = { ...objectEnv(objects, state, repo), GIT_INDEX_FILE: tmp };
340 if (!(await git(io, repo.top, ["add", "-A"], env))) return false;
341 // A repo with no file has no index, and git reads a missing link as empty.
342 if (!(await io.exists(tmp))) return true;
343 // git replaces the index file on each write, so the link keeps this one.
344 await ensureDir(io, dirname(link));
345 if (!(await io.link(tmp, link)) && !(await io.copy(tmp, link))) return false;
346 renamed = await io.rename(tmp, index);
347 return true;
348 } finally {
349 if (!renamed) await io.remove(tmp);
350 }
351}
352
353/** Reads the path of a `diff --git a/P b/P` header. Renames are off, so both sides match. */
354export function headerPath(header: string): string {
355 const rest = header.slice("diff --git ".length);
356 if (!rest.startsWith('"')) return rest.slice(2, 2 + (rest.length - 5) / 2);
357 // git C-quotes a path with a control character, a quote, or a backslash.
358 const escapes: Record<string, number> = {
359 a: 7,
360 b: 8,
361 t: 9,
362 n: 10,
363 v: 11,
364 f: 12,
365 r: 13,
366 };
367 const encoder = new TextEncoder();
368 const bytes: number[] = [];
369 for (let i = 1; i < rest.length && rest[i] !== '"'; i++) {
370 const next = rest[i + 1] ?? "";
371 if (rest[i] !== "\\") {
372 const char = String.fromCodePoint(rest.codePointAt(i) ?? 0);
373 bytes.push(...encoder.encode(char));
374 i += char.length - 1;
375 } else if (/[0-7]/.test(next)) {
376 bytes.push(parseInt(rest.slice(i + 1, i + 4), 8));
377 i += 3;
378 } else {
379 i++;
380 bytes.push(escapes[next] ?? next.charCodeAt(0));
381 }
382 }
383 return new TextDecoder().decode(new Uint8Array(bytes)).slice(2);
384}
385
386/** The first changed line of a `-U0` hunk. A pure deletion (`+c,0`) sits after line c. */
387export function hunkLine(header: string): number | undefined {
388 const match = /^@@ -\S+ \+(\d+)(?:,(\d+))? @@/.exec(header);
389 if (!match) return undefined;
390 return Number(match[1]) + (match[2] === "0" ? 1 : 0);
391}
392
393/**
394 * The files that differ between the index of a call and the work tree, with
395 * the first changed line and the blobs of each. `git status` gives the paths
396 * and the before blobs in one process, one `hash-object` writes the after
397 * blobs, and a patch runs only when a tracked file changed.
398 */
399async function diffCall(io: Io, where: Where, repo: Repo, link: string): Promise<Change[]> {
400 const { top } = repo;
401 const env = {
402 ...objectEnv(objectsDir(where), stateDir(where), repo),
403 GIT_INDEX_FILE: link,
404 };
405 const status = await git(
406 io,
407 top,
408 [
409 "--no-optional-locks",
410 "status",
411 "--porcelain=v2",
412 "-z",
413 "--untracked-files=all",
414 "--no-renames",
415 "--ignore-submodules=dirty",
416 ],
417 env,
418 );
419 if (!status) return [];
420 const entries = status.stdout.split("\0");
421 // The last entry of a cut output can be cut too.
422 if (status.truncated) entries.pop();
423 const changes: Change[] = [];
424 const add = (status: Change["status"], file: string, before?: string) =>
425 changes.push({
426 status,
427 path: resolve(top, file),
428 line: status === "A" ? 1 : undefined,
429 repo: top,
430 before: before && !/^0+$/.test(before) ? before : undefined,
431 });
432 for (const entry of entries) {
433 // `1 XY sub mH mI mW hH hI path`: Y is the work tree against the index,
434 // and hI the blob of the file in the index of the call.
435 // T, a type change such as a file that became a symlink, counts as M.
436 // A nested repo shows as an untracked directory, `? dir/`, and is left out.
437 const fields = entry.split(" ");
438 const xy = fields[1] ?? "";
439 if (entry.startsWith("1 ") && xy[1] !== ".")
440 add(xy[1] === "D" ? "D" : "M", fields.slice(8).join(" "), fields[7]);
441 else if (entry.startsWith("? ") && !entry.endsWith("/")) add("A", entry.slice(2));
442 }
443 const ids = await storeFiles(
444 io,
445 where,
446 repo,
447 changes.filter((change) => change.status !== "D").map(({ path }) => path),
448 );
449 for (const change of changes) change.after = ids.get(change.path);
450 if (!changes.some((change) => change.status === "M")) return changes;
451 // The patch only adds the lines. When it fails or is cut, the paths still count.
452 const patch = await git(
453 io,
454 top,
455 [
456 "-c",
457 "core.quotePath=false",
458 "diff-files",
459 "--no-ext-diff",
460 "--no-textconv",
461 "--ignore-submodules=dirty",
462 "--diff-filter=MT",
463 "--src-prefix=a/",
464 "--dst-prefix=b/",
465 "-p",
466 "-U0",
467 ],
468 env,
469 );
470 if (!patch || patch.truncated) return changes;
471 const lines = new Map<string, number | undefined>();
472 let file: string | undefined;
473 for (const text of patch.stdout.split("\n")) {
474 if (text.startsWith("diff --git ")) file = resolve(top, headerPath(text));
475 else if (file !== undefined && text.startsWith("@@ ") && !lines.has(file))
476 lines.set(file, hunkLine(text));
477 }
478 for (const change of changes) if (change.status === "M") change.line = lines.get(change.path);
479 return changes;
480}
481
482// ── Log ───────────────────────────────────────────────────────────────────────
483
484/** Appends one line per change to the session log, in one write. */
485async function append(
486 io: Io,
487 where: Where,
488 tool: string,
489 start: Date,
490 end: Date,
491 changes: Change[],
492) {
493 if (changes.length === 0) return;
494 const repos = await cachedRepos(
495 io,
496 stateDir(where),
497 changes.filter((change) => change.repo === undefined).map((change) => dirname(change.path)),
498 );
499 const text = changes.map((change) => {
500 const fields = [
501 start.toISOString(),
502 end.toISOString(),
503 tool,
504 change.status,
505 change.repo ?? repos.get(dirname(change.path))?.top ?? "",
506 change.path,
507 change.line ?? "",
508 change.before ?? "",
509 change.after ?? "",
510 ];
511 return `${fields.join("\t")}\n`;
512 });
513 await io.append(logFile(where), text.join(""));
514}
515
516/** The repos that the session log names: the repos the session changed so far. */
517async function touchedRepos(io: Io, where: Where): Promise<string[]> {
518 const text = (await io.read(logFile(where))) ?? "";
519 return [
520 ...new Set(
521 text
522 .split("\n")
523 .map((line) => line.split("\t")[4] ?? "")
524 .filter(Boolean),
525 ),
526 ];
527}
528
529// ── Tools with a path in the input ────────────────────────────────────────────
530
531/**
532 * The first changed line of a Claude Code Edit or Write result: the new start
533 * of the first hunk plus the context lines before the first + or - line. A
534 * Write that creates a file has no hunks, so it gives line 1.
535 */
536export function patchLine(hunks: Hunk[] | undefined): number {
537 const hunk = hunks?.[0];
538 if (!hunk) return 1;
539 const context = hunk.lines.findIndex((line) => line.startsWith("+") || line.startsWith("-"));
540 return hunk.newStart + Math.max(context, 0);
541}
542
543/**
544 * The first line that differs between two texts, for a pi or omp write. Lines
545 * removed at the end give the line after the last one that is left, as a pure
546 * deletion in `hunkLine`. Undefined when the texts are equal.
547 */
548export function changedLine(before: string, after: string): number | undefined {
549 if (before === after) return undefined;
550 const old = before.split("\n");
551 const now = after.split("\n");
552 let line = 0;
553 while (line < old.length && line < now.length && old[line] === now[line]) line++;
554 return line + 1;
555}
556
557/**
558 * The before text of a Claude Code Edit or Write, with the line endings of the
559 * file on disk. `originalFile` has LF line endings, but the tool keeps the
560 * CRLF endings of the file. So when each line of `after` ends in CRLF, each LF
561 * of `original` becomes CRLF. Undefined for a file with mixed line endings.
562 */
563export function lineEndings(original: string, after: string): string | undefined {
564 if (!after.includes("\r\n") || original.includes("\r")) return original;
565 const lines = after.split("\n").slice(0, -1);
566 if (!lines.every((line) => line.endsWith("\r"))) return undefined;
567 return original.replace(/\n/g, "\r\n");
568}
569
570/** The edit of a Claude Code Edit result. */
571export interface Edit {
572 oldString?: string;
573 newString?: string;
574 replaceAll?: boolean;
575}
576
577/**
578 * Checks that the old side of each hunk is in `text`. `structuredPatch` shows a
579 * tab as two spaces, so the check does too.
580 */
581function hunksFit(text: string, hunks: Hunk[]): boolean {
582 const lines = text.split("\n");
583 const norm = (line: string) => line.replace(/\t/g, " ");
584 return hunks.every((hunk) => {
585 if (hunk.oldStart === undefined) return false;
586 const old = hunk.lines
587 .filter((line) => line[0] === " " || line[0] === "-")
588 .map((line) => line.slice(1));
589 const first = Math.max(hunk.oldStart - 1, 0);
590 return old.every((line, i) => norm(lines[first + i] ?? "") === norm(line));
591 });
592}
593
594/**
595 * The text of a file before a Claude Code Edit, rebuilt from the text after
596 * it, for a result without `originalFile`. Undoing the hunks alone is wrong,
597 * because `structuredPatch` shows a tab as two spaces. So it puts `oldString`
598 * back in place of `newString` at an occurrence that overlaps a hunk, or at
599 * each such occurrence with `replaceAll`, and checks the old side of each hunk.
600 * An occurrence can reach past its hunk, which holds only the changed lines
601 * and 3 context lines. Undefined when no occurrence passes, so the rebuild
602 * never gives a wrong text.
603 */
604export function rebuildOriginal(after: string, edit: Edit, hunks: Hunk[]): string | undefined {
605 const { oldString, newString, replaceAll } = edit;
606 if (oldString === undefined || newString === undefined || !hunks.length) return undefined;
607 const starts = [0];
608 for (let i = after.indexOf("\n"); i !== -1; i = after.indexOf("\n", i + 1)) starts.push(i + 1);
609 const lineOf = (offset: number) => {
610 let [low, high] = [0, starts.length - 1];
611 while (low < high) {
612 const mid = (low + high + 1) >> 1;
613 if ((starts[mid] ?? 0) <= offset) low = mid;
614 else high = mid - 1;
615 }
616 return low + 1;
617 };
618 const ranges = hunks.map((hunk) => {
619 const count = hunk.newLines ?? hunk.lines.filter((line) => line[0] !== "-").length;
620 return [hunk.newStart, hunk.newStart + Math.max(count, 1) - 1] as const;
621 });
622 const overlaps = (from: number, to: number) =>
623 ranges.some(([first, last]) => lineOf(from) <= last && lineOf(to) >= first);
624 const spots: number[] = [];
625 if (newString === "") {
626 // A deletion leaves no text to find, so each offset of a hunk is a spot.
627 if (replaceAll) return undefined;
628 for (const [first, last] of ranges)
629 for (let i = starts[first - 1] ?? after.length; i <= (starts[last] ?? after.length); i++)
630 spots.push(i);
631 } else
632 for (
633 let i = after.indexOf(newString);
634 i !== -1;
635 i = after.indexOf(newString, i + newString.length)
636 )
637 if (overlaps(i, i + newString.length - 1)) spots.push(i);
638 const build = (at: number[]) => {
639 let [text, last] = ["", 0];
640 for (const i of at) {
641 text += after.slice(last, i) + oldString;
642 last = i + newString.length;
643 }
644 return text + after.slice(last);
645 };
646 if (replaceAll) {
647 const text = build(spots);
648 return spots.length && hunksFit(text, hunks) ? text : undefined;
649 }
650 // Each try copies the file, so a large file with many spots gives up.
651 if (spots.length * after.length > 64 * 1024 * 1024) return undefined;
652 for (const i of spots) {
653 const text = build([i]);
654 if (hunksFit(text, hunks)) return text;
655 }
656 return undefined;
657}
658
659/**
660 * Logs the files that a tool named in its input. Such a tool needs no
661 * snapshot. The file on disk is the after blob of a change, and its `old` text
662 * the before blob.
663 */
664export async function recordFiles(
665 io: Io,
666 where: Where,
667 tool: string,
668 start: Date,
669 end: Date,
670 changes: Change[],
671) {
672 if (!safeName(where.agent, where.session)) return;
673 const repos = await cachedRepos(
674 io,
675 stateDir(where),
676 changes.map((change) => dirname(change.path)),
677 );
678 for (const change of changes) {
679 const repo = repos.get(dirname(change.path));
680 change.repo ??= repo?.top ?? "";
681 const blobs = await storeBlobs(io, where, repo, change.path, {
682 old: change.status === "A" || change.before !== undefined ? undefined : change.old,
683 after: change.status !== "D" && change.after === undefined,
684 });
685 change.after ??= blobs.after;
686 change.before ??= blobs.before;
687 }
688 await append(io, where, tool, start, end, changes);
689}
690
691/** The result of a Claude Code Edit, Write, or NotebookEdit. */
692export interface ToolResult extends Edit {
693 type?: string;
694 filePath?: string;
695 structuredPatch?: Hunk[];
696 originalFile?: string | null;
697 error?: string;
698 original_file?: string;
699 updated_file?: string;
700}
701
702/**
703 * Logs a Claude Code Edit, Write, or NotebookEdit from its result. `file` is
704 * the path of the input, against `cwd`; the call ran from `start` to `end`.
705 */
706export async function recordTool(
707 io: Io,
708 where: Where,
709 tool: string,
710 file: string | undefined,
711 result: ToolResult | undefined,
712 cwd: string,
713 start: Date,
714 end: Date,
715) {
716 // A NotebookEdit result holds the file text before and after the call. The
717 // after blob comes from the file on disk, because `updated_file` can format
718 // the JSON in another way.
719 if (tool === "NotebookEdit") {
720 if (!file || !result || result.error || result.original_file === undefined) return;
721 const line = changedLine(result.original_file ?? "", result.updated_file ?? "");
722 const path = resolve(cwd, file);
723 return recordFiles(io, where, tool, start, end, [
724 { status: "M", path, line, old: result.original_file },
725 ]);
726 }
727
728 // A successful Edit or Write returns structuredPatch. A failed one returns an error string.
729 file ??= result?.filePath;
730 if ((tool !== "Edit" && tool !== "Write") || !file || !result?.structuredPatch) return;
731 const status = result.type === "create" ? "A" : "M";
732 const path = resolve(cwd, file);
733 // The text before the call: `originalFile`, else a rebuild of the Edit.
734 const after = status === "M" ? await io.read(path) : undefined;
735 let old: string | undefined;
736 if (after !== undefined) {
737 const original =
738 result.originalFile ??
739 (tool === "Edit"
740 ? rebuildOriginal(after.replace(/\r\n/g, "\n"), result, result.structuredPatch)
741 : undefined);
742 if (original !== undefined) old = lineEndings(original, after);
743 }
744 await recordFiles(io, where, tool, start, end, [
745 { status, path, line: patchLine(result.structuredPatch), old },
746 ]);
747}
748
749// ── Tools without a path: Bash and apply_patch ────────────────────────────────
750
751/** The snapshots of a running call: its start, and the index link of each repo. */
752export interface Pending {
753 start: string;
754 repos: (Repo & { link: string })[];
755}
756
757/**
758 * Snapshots the repos that a call can change, before it runs: the repo of
759 * `cwd`, the repos of `paths`, and the repos the session changed so far. The
760 * adapter keeps the result until the end of the call. A link name holds its
761 * time, which the prune reads.
762 */
763export async function beginCall(
764 io: Io,
765 where: Where,
766 cwd: string,
767 paths: string[] = [],
768): Promise<Pending | undefined> {
769 if (!safeName(where.agent, where.session)) return undefined;
770 const state = stateDir(where);
771 const dirs = [cwd, ...paths.map(dirname)];
772 const repos = new Map<string, Repo>();
773 for (const repo of (
774 await cachedRepos(io, state, [...dirs, ...(await touchedRepos(io, where))])
775 ).values()) {
776 if (!repo) continue;
777 // One repo can have two names, such as /tmp and /private/tmp on macOS.
778 const real = (await io.realpath(repo.top)) ?? repo.top;
779 if (!repos.has(real)) repos.set(real, repo);
780 }
781 const pending: Pending = { start: new Date().toISOString(), repos: [] };
782 const id = crypto.randomUUID();
783 for (const repo of repos.values()) {
784 const n = pending.repos.length;
785 const link = `${state}/calls/${id}.${n}.${Date.now()}.index`;
786 if (await snapshot(io, where, repo, link)) pending.repos.push({ ...repo, link });
787 }
788 return pending.repos.length ? pending : undefined;
789}
790
791/** Removes the index links of a call that did not run, such as a denied call. */
792export async function dropCall(io: Io, pending: Pending | undefined) {
793 for (const { link } of pending?.repos ?? []) await io.remove(link);
794}
795
796/**
797 * Compares the work tree with the index of each repo from `beginCall` and logs
798 * the difference. With `named`, the call named its files (apply_patch): only
799 * those are logged, and the snapshot gives their lines and blobs. A named file
800 * outside the snapshot repos gets line 1 for A and no line for M, and no
801 * before blob.
802 */
803export async function endCall(
804 io: Io,
805 where: Where,
806 pending: Pending | undefined,
807 tool: string,
808 named?: Change[],
809) {
810 if (!safeName(where.agent, where.session)) return;
811 const end = new Date();
812 const start = pending ? new Date(pending.start) : end;
813 const changes: Change[] = [];
814 for (const { link, ...repo } of pending?.repos ?? []) {
815 changes.push(...(await diffCall(io, where, repo, link)));
816 await io.remove(link);
817 }
818 if (!named) return append(io, where, tool, start, end, changes);
819 const found = new Map(changes.map((change) => [change.path, change]));
820 for (const change of named) {
821 const snap = found.get(change.path);
822 change.before ??= snap?.before;
823 if (change.status === "D") continue;
824 change.line ??= snap?.line ?? (change.status === "A" ? 1 : undefined);
825 change.after ??= snap?.after;
826 change.after ??= (await storeBlobs(io, where, undefined, change.path, { after: true })).after;
827 }
828 await append(io, where, tool, start, end, named);
829}
830
831/**
832 * The file that holds a pending call between two runs of command.ts.
833 * Undefined for an id that is not a safe file name.
834 */
835export const pendingFile = (where: Where, callId: string) =>
836 safeName(where.agent, where.session, callId) ? `${stateDir(where)}/pending/${callId}` : undefined;
837
838/**
839 * The files of a Codex apply_patch call. Relative paths resolve against `cwd`.
840 * A move logs the old path as D and the new path as A.
841 */
842export function patchFiles(patch: string, cwd: string): Change[] {
843 const statuses: Record<string, Change["status"]> = {
844 "Add File": "A",
845 "Update File": "M",
846 "Delete File": "D",
847 "Move to": "A",
848 };
849 const changes: Change[] = [];
850 for (const [, verb = "", file = ""] of patch.matchAll(
851 /^\*\*\* (Add File|Update File|Delete File|Move to): (.+)$/gm,
852 )) {
853 const last = changes.at(-1);
854 if (verb === "Move to" && last) last.status = "D";
855 changes.push({
856 status: statuses[verb] ?? "M",
857 path: resolve(cwd, file.trim()),
858 });
859 }
860 return changes;
861}
862
863// ── Prune ─────────────────────────────────────────────────────────────────────
864
865/**
866 * Deletes the logs and the session state of all agents that were not written
867 * for 30 days, and pending calls older than one day. The objects of a session
868 * go when both its log and its state are gone. A snapshot renames a file
869 * in index/, and command.ts adds and removes one in pending/, so the newest
870 * mtime of the session directory and these two is its last use. The index of
871 * a call in calls/ is a link that keeps the mtime of the index, so its age is
872 * the time in its name. A link with no time in its name is from before the
873 * mod, and goes at once.
874 */
875export async function prune(io: Io, home: string, now = Date.now()) {
876 const logs = logRoot(home);
877 const states = stateRoot(home);
878 const age = async (path: string) => now - ((await io.mtime(path)) ?? 0);
879 for (const agent of await io.list(logs))
880 for (const name of await io.list(`${logs}/${agent}`)) {
881 const log = `${logs}/${agent}/${name}`;
882 if (name.endsWith(".tsv") && (await age(log)) > KEEP) await io.remove(log);
883 }
884 for (const agent of await io.list(states))
885 for (const name of await io.list(`${states}/${agent}`)) {
886 const session = `${states}/${agent}/${name}`;
887 const ages = [
888 await age(session),
889 await age(`${session}/index`),
890 await age(`${session}/pending`),
891 ];
892 if (Math.min(...ages) > KEEP) {
893 await io.remove(session);
894 continue;
895 }
896 for (const call of await io.list(`${session}/pending`))
897 if ((await age(`${session}/pending/${call}`)) > KEEP_PENDING)
898 await io.remove(`${session}/pending/${call}`);
899 for (const call of await io.list(`${session}/calls`)) {
900 const made = Number(/\.(\d+)\.index$/.exec(call)?.[1] ?? 0);
901 if (now - made > KEEP_PENDING) await io.remove(`${session}/calls/${call}`);
902 }
903 }
904 // containers/ holds the logs of each container, which its own prune keeps.
905 for (const agent of await io.list(logs))
906 if (agent !== "containers")
907 for (const session of await io.list(`${logs}/${agent}`)) {
908 const dir = `${logs}/${agent}/${session}`;
909 if (
910 (await io.exists(`${dir}/objects`)) &&
911 !(await io.exists(`${dir}.tsv`)) &&
912 !(await io.exists(stateDir({ home, agent, session })))
913 )
914 await io.remove(dir);
915 }
916}
917