SLOPSHOPPER

track-session-changes

No description.

newguardprocess
A shopper browsing a rack in a slop shop
README

track-session-changes

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:

AgentAdapterLoaded by
Claude Codehooks/register.ts: a mod on tool.call and session.startCLAUDE_CODE_PLUGIN_DIRS in dot_claude/settings.json.tmpl
pipi.ts: tool_call, tool_result, and session_startextensions in dot_pi/agent/modify_settings.json
ompomp.ts: the track of pi.ts with the omp tools, which adds a git snapshot around eval and a write to xd://resolveextensions in dot_omp/private_agent/private_config.yml
Codexcommand.ts: a command hook, node command.ts codexdot_codex/modify_hooks.json

What it records

Tools and events

Agent and toolEventsFilesLine
Claude Code Edit, Writetool.call, after nextfile_path of the inputFirst hunk of structuredPatch in the result. A new file has no hunks: line 1
Claude Code NotebookEdittool.call, after nextnotebook_path of the inputFirst line that differs between original_file and updated_file of the result
Claude Code Bashtool.call, before and after nextSnapshot differenceFirst hunk of git diff-files -p -U0
Codex BashPreToolUse, PostToolUse, also after a failed commandSnapshot differenceThe same
Codex apply_patchPreToolUse, PostToolUse*** Add/Update/Delete File, Move toFrom the snapshot. A new file outside it: 1
pi and omp bashtool_call, tool_resultSnapshot differenceThe same as Bash
omp eval, and a write to xd://resolvetool_call, tool_resultSnapshot differenceThe same as Bash
pi and omp edittool_call, tool_resultpi: the input path. omp: each file of the tool detailsfirstChangedLine of the details. An omp file it creates: 1
pi and omp writetool_call, tool_resultThe input pathThe old file, read at the call, against the file after it
Each agentSession startNone: deletes logs and state after 30 daysNone

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.

Failed and denied calls

  • Bash: a failed call logs its changes, because a command that fails can still write files.
  • Edit, Write, NotebookEdit, pi edit, and pi write: a failed call logs nothing.
  • Denied calls: in Claude Code, a call that another hook denies logs nothing.
  • Unchanged files: a pi 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.

omp edits and other tools

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.

Limits

  • A Bash call snapshots only the repo of its working directory and the repos in the session log. So a Bash write to another repo is not logged until a tool that names its files changes that repo, or a call runs with its working directory there. Bash writes to git-ignored files are not logged.
  • A Bash snapshot cannot tell which process wrote a file. A file that another process changes during the call is logged as a change of the call. The overlap filter of the agent-changed-files package drops such a write when another session logged it with a tool path.
  • A background Bash call (run_in_background) returns before the command ends, so the hook misses its later writes.
  • In Claude Code, the mod reads a file through $.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.
  • A Codex call that a hook denies leaves a pending file and its index links. A pi or omp call that gets no tool_result leaves its index links. The prune deletes them after one day.
  • The paths in an omp log keep the logical working directory, for example /tmp/x. pi logs the resolved path, /private/tmp/x on macOS.
  • The prune deletes the objects/ of a session when both its log and its state are gone, so after 30 days of no use.
  • A blob ID is the SHA-1 of git. A repo with SHA-256 object names gets IDs that it does not hold.

Requirements

  • git: each adapter runs git.
  • Node 23.6 or later: command.ts needs it, because this version runs .ts files without a flag.
  • Codex trust: Codex runs a hook only after you trust its entry. After a change of an entry, trust the hooks again in the Codex TUI, on the host and in the devcontainer. See docs/agents/codex.md.

How it works

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.

Snapshots

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>/:

PathContent
index/<hash>The git index of one repo, named by the SHA-1 of the repo path
repos.jsonThe repo of each directory that a call looked up
calls/<id>.<n>.<ms>.indexA 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.gitAn 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.

Blobs

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.

ToolBefore blobAfter blob
Claude Code Edit and WriteoriginalFile of the result, with the line endings of the file. Else a rebuild of an EditThe file on disk
Claude Code NotebookEditoriginal_file of the resultThe file on disk
pi and omp edit, writeThe files that the input names, read at the tool callThe file on disk
Bash, apply_patchThe index entry of the file in the snapshot of the callThe file on disk
A file that the call madeNoneAs above
A file that the call deletedAs aboveNone

A before blob is missing in these cases:

  • A file with mixed line endings.
  • An Edit whose rebuild fails.
  • An omp edit that names its files only inside its patch.
  • An apply_patch file outside every snapshot repo.

An after blob is missing for a file that git cannot read.

What the model sees

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.

What you see

The hook shows no message in the transcript or the terminal. Its only output is the session log.

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:

FieldExampleNote
start2026-10-14T09:12:03.120ZUTC with milliseconds
end2026-10-14T09:12:03.480ZUTC with milliseconds
toolBashThe tool name of the agent, such as Edit or pi's edit
statusMA, M, or D. A type change, such as file to link, is M
repo/Users/me/repoTop of the git work tree. Empty outside a repo
path/Users/me/repo/x.orgAbsolute
line412First changed line after the call. Empty for D or unknown
before3b18e512dbc8…Git blob ID of the file before the call. Empty for A
after9daeafb9864c…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.

Settings

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.

Failure behavior

The hook fails open: no failure blocks or changes a tool call, and you see no message.

  • Mod and pi.ts: each step catches its own errors and drops them.
  • command.ts: always exits 0 and prints nothing.
  • No node on PATH: the Codex entries exit 0, and the SessionStart check of Codex gives a warning.
  • Failed snapshot: the hook skips that repo.
  • Failed patch diff: the hook keeps the paths, with an empty line.

Testing

node test/check.ts checks the parsers, command.ts, the mod, pi.ts, and omp.ts, see docs/testing.md.

Source 2 files
hooks/register.ts 135 lines
1/**
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};
135
core.ts 917 lines
1/**
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