@cruxy/cli 1.11.1 → 1.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { APP_VERSION } from "../constants.js";
4
4
  import { formatBytes } from "../utils/disk.js";
5
5
  import { sessionFile } from "./paths.js";
6
+ import { claimSession, describeHolder, releaseSession, sessionHeldBy, } from "./owner.js";
6
7
  import { pruneSessions } from "./prune.js";
7
8
  import { SESSION_FILE_VERSION, } from "./types.js";
8
9
  /**
@@ -20,6 +21,8 @@ export class SessionLog {
20
21
  currentRunId;
21
22
  /** Set once a write fails: the log goes inert rather than warning per turn. */
22
23
  broken = false;
24
+ /** Whether this process has stamped itself as the file's owner (P1). */
25
+ claimed = false;
23
26
  /**
24
27
  * A NEW session's `meta` line, held until the session records something.
25
28
  * Null on a reopen (the file already has its meta) and null again the moment
@@ -77,6 +80,14 @@ export class SessionLog {
77
80
  const log = new SessionLog(file, opts);
78
81
  log.pruneOnce(opts);
79
82
  if (hasContent(file)) {
83
+ // OWNERSHIP (P1). A reopen is the moment a second process would start
84
+ // interleaving its turns into this file, so the stamp is taken here,
85
+ // eagerly, like the `resumed` event below. `loadResume` has already
86
+ // refused a file another live cruxy holds; this is the claim that makes
87
+ // the NEXT resume see us. A fresh session claims at its first flush
88
+ // instead — see {@link write} — for the same reason `meta` is buffered:
89
+ // a conversation with nothing in it should leave nothing on disk.
90
+ log.claim();
80
91
  log.write({
81
92
  kind: "resumed",
82
93
  at: new Date().toISOString(),
@@ -232,9 +243,46 @@ export class SessionLog {
232
243
  this.pendingMeta = null;
233
244
  if (!this.writeLine(meta))
234
245
  return false;
246
+ this.claim(); // the file now exists — so can a second `--resume` of it
235
247
  }
236
248
  return this.writeLine(event);
237
249
  }
250
+ /**
251
+ * Stamp this process as the file's owner (P1 — see `owner.ts`). Idempotent.
252
+ * The stamp is released on process exit; a crash leaves it behind, and that
253
+ * is fine — a stamp is pid + start-time, so the next reader sees a dead
254
+ * owner and ignores it. Never fatal: an unwritable stamp is the pre-P1
255
+ * behaviour (no ownership), and a session must still run without one.
256
+ */
257
+ claim() {
258
+ if (this.claimed)
259
+ return;
260
+ // Never overwrite a LIVE owner's stamp. `loadResume` refuses that file
261
+ // before we get here, so this is the guard for any other writer that
262
+ // opens a log — and for the race two resumes in the same instant would
263
+ // be. Recording continues (the format tolerates it); ownership does not
264
+ // move, so the third process to come along still sees the real holder.
265
+ const holder = sessionHeldBy(this.file);
266
+ if (holder) {
267
+ this.logger?.warn(`session is open in another cruxy (${describeHolder(holder)}) — this process is not taking it over`);
268
+ return;
269
+ }
270
+ this.claimed = claimSession(this.file);
271
+ if (this.claimed)
272
+ releaseOnExit(this.file);
273
+ }
274
+ /**
275
+ * Give the file up: remove our stamp. The exit hook does this for the
276
+ * normal case; this is for a caller that ends a session while the process
277
+ * lives on (tests, and any future in-process session switch).
278
+ */
279
+ close() {
280
+ if (!this.claimed)
281
+ return;
282
+ this.claimed = false;
283
+ releaseSession(this.file);
284
+ claimedFiles.delete(this.file);
285
+ }
238
286
  /**
239
287
  * Append one event as a single line. Returns whether it landed. The first
240
288
  * failure warns and latches `broken`, so a persistent problem (a full disk)
@@ -255,6 +303,24 @@ export class SessionLog {
255
303
  }
256
304
  }
257
305
  }
306
+ /**
307
+ * Every session file this process has stamped, released together on exit.
308
+ * ONE listener for the process rather than one per log: a test opens dozens
309
+ * of logs, and `process.on` warns past ten listeners.
310
+ */
311
+ const claimedFiles = new Set();
312
+ let exitHookInstalled = false;
313
+ function releaseOnExit(file) {
314
+ claimedFiles.add(file);
315
+ if (exitHookInstalled)
316
+ return;
317
+ exitHookInstalled = true;
318
+ process.on("exit", () => {
319
+ for (const f of claimedFiles)
320
+ releaseSession(f);
321
+ claimedFiles.clear();
322
+ });
323
+ }
258
324
  /**
259
325
  * Whether `file` is an existing log with content — i.e. this is a reopen.
260
326
  *
@@ -0,0 +1,123 @@
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { describeOwner, selfStamp, } from "../utils/process-owner.js";
5
+ import { SESSION_FILE_EXT } from "./paths.js";
6
+ /**
7
+ * Session ownership across processes (P1).
8
+ *
9
+ * A session log is one append-only file, and `SessionLog` was built so that
10
+ * concurrent appenders interleave whole lines rather than tearing them. That
11
+ * keeps the file PARSEABLE under two writers; it does not keep it MEANINGFUL.
12
+ * Two processes that `--resume` the same id each replay the same history,
13
+ * each append their own turns, and the result is one transcript with two
14
+ * conversations shuffled into it — replayable, and wrong.
15
+ *
16
+ * The fix is ownership, and the minimum that prevents the observed failure is
17
+ * a stamp, not a lock:
18
+ *
19
+ * - `<id>.owner.json` beside the log holds the {@link ProcessStamp} of the
20
+ * cruxy that has it open. It is written when the session first lands on
21
+ * disk (a new session) or on reopen (a resume), and removed on exit.
22
+ * - `--resume` reads it BEFORE replaying. A stamp whose process is still
23
+ * running is a refusal; a stamp whose process is gone — or whose pid has
24
+ * been recycled onto something else — is ignored and overwritten.
25
+ *
26
+ * WHY A STAMP AND NOT A LOCK. A lock file that must be deleted to be released
27
+ * outlives every crash, and a lock that outlives a crash is worse than no
28
+ * lock: the next `--resume` is refused with nothing to refuse it for, and the
29
+ * user learns to `rm` it — at which point it stops meaning anything. A pid +
30
+ * start-time stamp is self-invalidating: liveness is decided by asking the OS,
31
+ * never by whether cleanup ran. See `utils/process-owner.ts` for why the pid
32
+ * marker refused for JOB logs is category-correct for sessions.
33
+ *
34
+ * WHY NOT A PROJECT-LEVEL LOCK. Several cruxy processes in one project is the
35
+ * multi-agent case, not a misuse of it. What must not happen is two of them
36
+ * on ONE session; the stamp is scoped to exactly that.
37
+ */
38
+ const OwnerSchema = z.object({
39
+ pid: z.number().int().positive(),
40
+ token: z.string().min(1),
41
+ startedAt: z.string(),
42
+ /** When the stamp was written — for messages. */
43
+ claimedAt: z.string(),
44
+ });
45
+ /** `<id>.owner.json` next to `<id>.jsonl`. */
46
+ export function ownerFile(sessionFile) {
47
+ const dir = path.dirname(sessionFile);
48
+ const id = path.basename(sessionFile, SESSION_FILE_EXT);
49
+ return path.join(dir, `${id}.owner.json`);
50
+ }
51
+ /** The recorded owner, or null when there is none (absent, unreadable, malformed). */
52
+ export function readOwner(sessionFile) {
53
+ let raw;
54
+ try {
55
+ raw = readFileSync(ownerFile(sessionFile), "utf8");
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ try {
61
+ const parsed = OwnerSchema.safeParse(JSON.parse(raw));
62
+ return parsed.success ? parsed.data : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Who has the session open right now, if anyone other than this process.
70
+ * A stale stamp is not a holder; neither is our own.
71
+ */
72
+ export function sessionHeldBy(sessionFile) {
73
+ const owner = readOwner(sessionFile);
74
+ if (!owner)
75
+ return null;
76
+ return describeOwner(owner) === "live" ? owner : null;
77
+ }
78
+ /**
79
+ * Record this process as the session's owner. Temp-then-rename so a reader
80
+ * never sees a half-written stamp; `0600` like everything else in `~/.cruxy`.
81
+ * Never throws: a stamp that cannot be written degrades to the pre-P1
82
+ * behaviour (no ownership), and the session must still run.
83
+ */
84
+ export function claimSession(sessionFile) {
85
+ const file = ownerFile(sessionFile);
86
+ const me = selfStamp();
87
+ const record = { ...me, claimedAt: new Date().toISOString() };
88
+ try {
89
+ mkdirSync(path.dirname(file), { recursive: true });
90
+ const tmp = `${file}.${process.pid}.tmp`;
91
+ writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
92
+ renameSync(tmp, file);
93
+ return true;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ /** Remove our stamp — only ours; a later owner's is left alone. Never throws. */
100
+ export function releaseSession(sessionFile) {
101
+ const owner = readOwner(sessionFile);
102
+ if (!owner || describeOwner(owner) !== "self")
103
+ return;
104
+ try {
105
+ unlinkSync(ownerFile(sessionFile));
106
+ }
107
+ catch {
108
+ // already gone, or unwritable — nothing to do either way
109
+ }
110
+ }
111
+ /** Drop a session's stamp unconditionally — for deleting the session itself. */
112
+ export function removeOwnerFile(sessionFile) {
113
+ try {
114
+ unlinkSync(ownerFile(sessionFile));
115
+ }
116
+ catch {
117
+ // no stamp to remove
118
+ }
119
+ }
120
+ /** One line for a refusal or a picker row: which process, since when. */
121
+ export function describeHolder(stamp) {
122
+ return `pid ${stamp.pid}, started ${stamp.startedAt}`;
123
+ }
@@ -4,6 +4,7 @@ import { INTERRUPTED, idKey, jobLogFilesByRecency, readJobLogTerminal, sessionKe
4
4
  import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
5
5
  import { sessionFilesByRecency } from "./list.js";
6
6
  import { SESSION_FILE_EXT } from "./paths.js";
7
+ import { removeOwnerFile, sessionHeldBy } from "./owner.js";
7
8
  /** `<sessionId>.jsonl` → `<sessionId>`. */
8
9
  function idOf(file) {
9
10
  return path.basename(file, SESSION_FILE_EXT);
@@ -59,6 +60,15 @@ export function pruneSessions(cwd, opts) {
59
60
  result.kept++;
60
61
  continue;
61
62
  }
63
+ // A session ANOTHER live cruxy has open is spared for the same reason our
64
+ // own is (P1): its writer would recreate the file on its next append with
65
+ // no `meta` line, and that session would be unloadable from then on. The
66
+ // stamp is pid + start-time, so a crashed owner's file is prunable again
67
+ // the moment it is looked at — nothing outlives the crash.
68
+ if (sessionHeldBy(ref.file)) {
69
+ result.kept++;
70
+ continue;
71
+ }
62
72
  const tooOld = ref.mtimeMs < cutoff;
63
73
  const beyondCap = index >= retention;
64
74
  if (!tooOld && !beyondCap) {
@@ -67,6 +77,7 @@ export function pruneSessions(cwd, opts) {
67
77
  }
68
78
  try {
69
79
  unlinkSync(ref.file);
80
+ removeOwnerFile(ref.file); // a stale stamp has nothing left to own
70
81
  result.removed.push({ ...ref, sessionId });
71
82
  result.bytesFreed += ref.size;
72
83
  }
@@ -2,6 +2,7 @@ import { selectList } from "../components/index.js";
2
2
  import { usageError } from "../errors/index.js";
3
3
  import { listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
4
4
  import { replaySession } from "./replay.js";
5
+ import { describeHolder, ownerFile, sessionHeldBy } from "./owner.js";
5
6
  /** How many sessions the bare-`--resume` picker offers. */
6
7
  export const PICKER_LIMIT = 10;
7
8
  /** Short, stable id form — enough to identify a session, short enough to type. */
@@ -75,6 +76,18 @@ export function priorDirectoriesWarning(state, cwd) {
75
76
  * worth failing loudly on, unlike an individual torn line.
76
77
  */
77
78
  export function loadResume(session, cwd) {
79
+ // OWNERSHIP (P1). Asked BEFORE the replay, and answered by the OS rather
80
+ // than by a file's presence: a stamp whose process is gone is not a holder.
81
+ // Two processes appending to one log produce a transcript that replays
82
+ // cleanly and means nothing, so this is a refusal, not a warning.
83
+ const holder = sessionHeldBy(session.file);
84
+ if (holder) {
85
+ throw usageError(`session ${shortId(session.sessionId)} is open in another cruxy (${describeHolder(holder)})`, [
86
+ "continue it there, or quit that cruxy and resume here",
87
+ "start a new session with `cruxy`",
88
+ `if that process is not a cruxy, remove ${ownerFile(session.file)}`,
89
+ ]);
90
+ }
78
91
  let state;
79
92
  try {
80
93
  state = replaySession(session.file);
@@ -189,9 +202,15 @@ export async function resumePicker(cwd, opts = {}) {
189
202
  ];
190
203
  const picked = await selectList(rows, {
191
204
  title: "resume a session",
192
- toLabel: (row) => row.kind === "new"
193
- ? "+ new session"
194
- : describeSession(row.session, now),
205
+ toLabel: (row) => {
206
+ if (row.kind === "new")
207
+ return "+ new session";
208
+ const label = describeSession(row.session, now);
209
+ // A row another live cruxy holds is offered — the user may want to
210
+ // see it — but says so, so picking it is not a surprise refusal.
211
+ const holder = sessionHeldBy(row.session.file);
212
+ return holder ? `${label} · open in another cruxy` : label;
213
+ },
195
214
  // Non-interactive with no id named: starting fresh is the safe default,
196
215
  // never an arbitrary session picked on the user's behalf.
197
216
  defaultValue: { kind: "new" },
@@ -7,7 +7,7 @@ import { UNRESOLVED_TIER, } from "../budget/index.js";
7
7
  import { resolveTaskModel } from "../routing/index.js";
8
8
  import { Workspace } from "../workspace/index.js";
9
9
  import { Budget, resolveBudget } from "../agent/budget.js";
10
- import { scopeRegistry, SUBAGENT_WRITE_TOOLS } from "./registry-scope.js";
10
+ import { isWriteTool, scopeRegistry } from "./registry-scope.js";
11
11
  import { Semaphore } from "./semaphore.js";
12
12
  import { makeSpawnSubagentTool } from "./spawn-tool.js";
13
13
  /** Longest task excerpt shown in render chrome — display, not record. */
@@ -556,7 +556,7 @@ function taskLabel(task) {
556
556
  /** A child that holds any mutating tool — the disjoint-scope check's unit. A
557
557
  * spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
558
558
  function isWriter(spec) {
559
- return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
559
+ return (spec.tools ?? []).some(isWriteTool);
560
560
  }
561
561
  /**
562
562
  * The tokens a child is KNOWN to have spent, or `undefined` when no request
@@ -17,8 +17,20 @@ export const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
17
17
  /**
18
18
  * Mutating tools (C.33): a child holding ANY of these is a "writer" for the
19
19
  * disjoint-scope check. Two writers in one parallel batch must target distinct
20
- * roots, or the batch is refused pre-dispatch. Kept in sync with the gated,
21
- * side-effecting tool set (file writes, shell/test, VCS).
20
+ * roots, or the batch is refused pre-dispatch.
21
+ *
22
+ * This is the set of REGISTERED tool names that gate on `ctx.requestApproval`
23
+ * and act on a workspace root: file writes, shell/test, the PR tool, and the
24
+ * background-job dispatcher (a job is a whole agent run that may be granted
25
+ * any of the others). `registry-scope.test.ts` pins every name here to a tool
26
+ * that actually exists — until P1 this set named three tools that never did
27
+ * (`git_commit`, `git_branch`, `open_pr`) and omitted `create_pull_request`,
28
+ * so a child holding the PR tool was never counted as a writer and two of them
29
+ * could be dispatched against one root.
30
+ *
31
+ * Deliberately NOT here: `remember` (writes the memory store, not a root —
32
+ * root-disjointness says nothing about it) and the spawn tools (stripped from
33
+ * every child scope). MCP tools are covered by prefix in {@link isWriteTool}.
22
34
  */
23
35
  export const SUBAGENT_WRITE_TOOLS = new Set([
24
36
  "write_file",
@@ -26,10 +38,21 @@ export const SUBAGENT_WRITE_TOOLS = new Set([
26
38
  "apply_patch",
27
39
  "run_command",
28
40
  "run_tests",
29
- "git_commit",
30
- "git_branch",
31
- "open_pr",
41
+ "create_pull_request",
42
+ "run_in_background",
32
43
  ]);
44
+ /** The wire-name prefix every MCP-proxied tool carries (see `mcp/adapter.ts`). */
45
+ const MCP_TOOL_PREFIX = "mcp__";
46
+ /**
47
+ * Whether granting `name` to a child makes it a writer. The named set above,
48
+ * plus every MCP tool: the adapter gates each call on approval precisely
49
+ * because a server's tool can mutate anything, and the classifier never lets
50
+ * the server declare itself read-only — so the disjointness check must not
51
+ * either.
52
+ */
53
+ export function isWriteTool(name) {
54
+ return SUBAGENT_WRITE_TOOLS.has(name) || name.startsWith(MCP_TOOL_PREFIX);
55
+ }
33
56
  /**
34
57
  * The default child toolset: read-only investigation plus skills. Mirrors the
35
58
  * C.31 propose-phase set — no writes, no shell, no VCS unless the parent
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { resolveToolPath, toPosix } from "./paths.js";
5
5
  import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
6
+ import { changedSince, snapshotFile, snapshotOf, } from "./snapshot.js";
6
7
  /** How many leading lines of a created file the approval preview shows. */
7
8
  const PREVIEW_LINES = 20;
8
9
  /**
@@ -162,6 +163,20 @@ export const applyPatchTool = {
162
163
  if (!decision.allow) {
163
164
  return { ok: false, error: decision.feedback ?? "patch denied" };
164
165
  }
166
+ // The approval covered THESE files in THE states they were read in. Any
167
+ // path that moved during the wait — rewritten, deleted, or created by
168
+ // something else — voids the whole patch: nothing is applied, so the model
169
+ // re-reads and resubmits rather than landing a half-stale patch (P1).
170
+ // Every drifted path is named, not just the first: a retry that knows one
171
+ // of two moved re-reads one file and trips over the other.
172
+ const drifted = [];
173
+ for (const p of planned) {
174
+ const moved = await changedSince(p.abs, p.approved, p.rel);
175
+ if (moved)
176
+ drifted.push(moved);
177
+ }
178
+ if (drifted.length > 0)
179
+ return { ok: false, error: drifted.join("\n") };
165
180
  // Validation passed and the user approved; apply everything. A mid-apply I/O
166
181
  // failure is rare but reported with what already landed.
167
182
  const applied = [];
@@ -195,28 +210,44 @@ async function openTrack(i, op, abs, ctx) {
195
210
  // messages), consistent with every other path tool — see {@link toPosix}.
196
211
  const rel = toPosix(path.relative(ctx.cwd, abs));
197
212
  const base = { abs, rel, firstOp: i, hunks: [] };
198
- if (op.type === "create") {
199
- if (await exists(abs)) {
200
- return { ok: false, error: opError(i, op, "file already exists") };
213
+ if (op.type === "create" || op.type === "delete") {
214
+ let approved;
215
+ try {
216
+ approved = await snapshotFile(abs);
201
217
  }
202
- const content = op.content ?? "";
203
- return {
204
- ok: true,
205
- track: { ...base, kind: "create", content, eol: detectEol(content) },
206
- };
207
- }
208
- if (op.type === "delete") {
209
- if (!(await exists(abs))) {
218
+ catch (err) {
219
+ return { ok: false, error: opError(i, op, err.message) };
220
+ }
221
+ if (op.type === "create") {
222
+ if (approved.kind === "present") {
223
+ return { ok: false, error: opError(i, op, "file already exists") };
224
+ }
225
+ const content = op.content ?? "";
226
+ return {
227
+ ok: true,
228
+ track: {
229
+ ...base,
230
+ kind: "create",
231
+ content,
232
+ eol: detectEol(content),
233
+ approved,
234
+ },
235
+ };
236
+ }
237
+ if (approved.kind === "absent") {
210
238
  return { ok: false, error: opError(i, op, "file not found") };
211
239
  }
212
240
  return {
213
241
  ok: true,
214
- track: { ...base, kind: "delete", content: "", eol: "\n" },
242
+ track: { ...base, kind: "delete", content: "", eol: "\n", approved },
215
243
  };
216
244
  }
217
245
  let content;
246
+ let approved;
218
247
  try {
219
- content = await fs.readFile(abs, "utf8");
248
+ const bytes = await fs.readFile(abs);
249
+ approved = snapshotOf(bytes);
250
+ content = bytes.toString("utf8");
220
251
  }
221
252
  catch (err) {
222
253
  if (err.code === "ENOENT") {
@@ -229,6 +260,7 @@ async function openTrack(i, op, abs, ctx) {
229
260
  kind: "update",
230
261
  content,
231
262
  eol: detectEol(content),
263
+ approved,
232
264
  };
233
265
  const failure = applyHunk(i, op, track);
234
266
  return failure ? { ok: false, error: failure } : { ok: true, track };
@@ -261,12 +293,12 @@ function applyHunk(i, op, track) {
261
293
  }
262
294
  /** Collapse a finished track into the single write it represents. */
263
295
  function toPlanned(track) {
264
- const { kind, abs, rel, content, hunks } = track;
296
+ const { kind, abs, rel, content, hunks, approved } = track;
265
297
  if (kind === "delete")
266
- return { op: "delete", abs, rel };
298
+ return { op: "delete", abs, rel, approved };
267
299
  if (kind === "create")
268
- return { op: "create", abs, rel, content };
269
- return { op: "update", abs, rel, content, hunks };
300
+ return { op: "create", abs, rel, content, approved };
301
+ return { op: "update", abs, rel, content, hunks, approved };
270
302
  }
271
303
  /** Shape a planned op into its approval-preview form. */
272
304
  function toPreview(p) {
@@ -287,9 +319,3 @@ function toPreview(p) {
287
319
  function opError(i, op, reason) {
288
320
  return `operation ${i + 1} (${op.type} ${op.path}): ${reason}`;
289
321
  }
290
- async function exists(abs) {
291
- return fs
292
- .access(abs)
293
- .then(() => true)
294
- .catch(() => false);
295
- }
@@ -2,10 +2,15 @@ import { promises as fs } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { resolveToolPath } from "./paths.js";
4
4
  import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
5
+ import { changedSince, snapshotOf } from "./snapshot.js";
5
6
  /**
6
7
  * Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
7
8
  * The uniqueness requirement is checked before approval so the model can fix an
8
9
  * ambiguous match without burning a prompt; gated on `ctx.approve` before writing.
10
+ *
11
+ * The bytes read before approval are the state the approval is granted
12
+ * against; the write is refused if the file is no longer in that state when
13
+ * the write is about to happen (P1 — see `snapshot.ts`).
9
14
  */
10
15
  export const editFileTool = {
11
16
  name: "edit_file",
@@ -29,8 +34,11 @@ export const editFileTool = {
29
34
  return { ok: false, error: err.message };
30
35
  }
31
36
  let content;
37
+ let approvedState;
32
38
  try {
33
- content = await fs.readFile(abs, "utf8");
39
+ const bytes = await fs.readFile(abs);
40
+ approvedState = snapshotOf(bytes);
41
+ content = bytes.toString("utf8");
34
42
  }
35
43
  catch (err) {
36
44
  if (err.code === "ENOENT") {
@@ -64,6 +72,12 @@ export const editFileTool = {
64
72
  const updated = content.slice(0, match.start) +
65
73
  applyEol(input.new_str, detectEol(content)) +
66
74
  content.slice(match.end);
75
+ // The approval covered a diff against the bytes read above. Anything that
76
+ // changed the file during the wait makes `updated` a splice of stale
77
+ // content — refuse rather than overwrite what is there now (P1).
78
+ const moved = await changedSince(abs, approvedState, input.path);
79
+ if (moved)
80
+ return { ok: false, error: moved };
67
81
  try {
68
82
  await fs.writeFile(abs, updated, "utf8");
69
83
  return { ok: true, output: `edited ${input.path}` };
@@ -0,0 +1,63 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { ErrorCode } from "../../errors/index.js";
4
+ /** Snapshot the bytes a tool has ALREADY read (no second read). */
5
+ export function snapshotOf(bytes) {
6
+ return {
7
+ kind: "present",
8
+ digest: createHash("sha256").update(bytes).digest("hex"),
9
+ };
10
+ }
11
+ /**
12
+ * Read `abs` and snapshot it. A missing path is a legitimate state (`absent`)
13
+ * — it is what `write_file` and a patch `create` are approved against. Every
14
+ * other failure (a directory at the path, EACCES) propagates: the caller
15
+ * cannot build a truthful preview of a file it cannot read.
16
+ */
17
+ export async function snapshotFile(abs) {
18
+ try {
19
+ return snapshotOf(await fs.readFile(abs));
20
+ }
21
+ catch (err) {
22
+ if (err.code === "ENOENT") {
23
+ return { kind: "absent" };
24
+ }
25
+ throw err;
26
+ }
27
+ }
28
+ /**
29
+ * Re-read `abs` and compare it with the state the approval was granted
30
+ * against. Returns `null` when the file is exactly as it was, otherwise the
31
+ * refusal to hand back as the tool's error — one sentence on what moved, and
32
+ * the same next step every time: read it again and retry.
33
+ *
34
+ * Call this AFTER approval and IMMEDIATELY before the write, with nothing
35
+ * awaited in between — the point is to make the gap as small as the platform
36
+ * allows, not to check early and then wait.
37
+ */
38
+ export async function changedSince(abs, approved, rel) {
39
+ let now;
40
+ try {
41
+ now = await snapshotFile(abs);
42
+ }
43
+ catch (err) {
44
+ return refusal(rel, `could not be re-read before the approved write (${err.message})`);
45
+ }
46
+ if (approved.kind === "absent") {
47
+ return now.kind === "absent"
48
+ ? null
49
+ : refusal(rel, "was created by something else after it was approved as a new file");
50
+ }
51
+ if (now.kind === "absent") {
52
+ return refusal(rel, "was deleted after it was read");
53
+ }
54
+ if (now.digest !== approved.digest) {
55
+ return refusal(rel, "changed on disk after it was read");
56
+ }
57
+ return null;
58
+ }
59
+ /** The one refusal shape: what moved, that nothing was written, what to do. */
60
+ function refusal(rel, what) {
61
+ return (`${ErrorCode.FileChangedSinceRead}: ${rel} ${what}, so the approval no longer ` +
62
+ `covers this write; nothing was written — read the file again and retry`);
63
+ }
@@ -2,11 +2,18 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { z } from "zod";
4
4
  import { resolveToolPath } from "./paths.js";
5
+ import { changedSince, snapshotFile } from "./snapshot.js";
5
6
  /** How many leading lines of new content the approval preview shows. */
6
7
  const PREVIEW_LINES = 20;
7
8
  /**
8
9
  * Create or overwrite a file within the project root. Gated on `ctx.approve`
9
10
  * before anything is written.
11
+ *
12
+ * The target is snapshotted before approval — its current bytes, or its
13
+ * absence — and the write is refused if that state has moved by the time the
14
+ * write is about to happen (P1 — see `snapshot.ts`). This tool used to have
15
+ * no read at all: an "overwrite" approved against one version of a file would
16
+ * land on whatever version was there by the time the user pressed `y`.
10
17
  */
11
18
  export const writeFileTool = {
12
19
  name: "write_file",
@@ -25,11 +32,19 @@ export const writeFileTool = {
25
32
  catch (err) {
26
33
  return { ok: false, error: err.message };
27
34
  }
28
- // Does the target already exist? Drives create-vs-overwrite in the preview.
29
- const exists = await fs
30
- .access(abs)
31
- .then(() => true)
32
- .catch(() => false);
35
+ // Does the target already exist? Drives create-vs-overwrite in the preview,
36
+ // and its bytes (or absence) are the state the approval is granted against.
37
+ let approvedState;
38
+ try {
39
+ approvedState = await snapshotFile(abs);
40
+ }
41
+ catch (err) {
42
+ return {
43
+ ok: false,
44
+ error: `cannot read ${input.path}: ${err.message}`,
45
+ };
46
+ }
47
+ const exists = approvedState.kind === "present";
33
48
  // First N lines of the new content, with a count of what's omitted.
34
49
  const allLines = input.content.split("\n");
35
50
  const lines = allLines.slice(0, PREVIEW_LINES);
@@ -46,6 +61,12 @@ export const writeFileTool = {
46
61
  error: decision.feedback ?? `write to ${input.path} denied`,
47
62
  };
48
63
  }
64
+ // A create approved against "no file here" must not overwrite a file that
65
+ // appeared during the wait; an overwrite approved against one version must
66
+ // not land on another (P1).
67
+ const moved = await changedSince(abs, approvedState, input.path);
68
+ if (moved)
69
+ return { ok: false, error: moved };
49
70
  try {
50
71
  await fs.mkdir(path.dirname(abs), { recursive: true });
51
72
  await fs.writeFile(abs, input.content, "utf8");