dsh-rewind-plugin 0.11.0 → 0.12.0

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.
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
3
- import { unlink } from "node:fs/promises";
3
+ import { copyFile as copyFile2, rm as rm2, stat as stat2, unlink } from "node:fs/promises";
4
4
  import "@deepseek-ai/schemastery";
5
5
 
6
6
  // src/locales.ts
@@ -25,8 +25,9 @@ var en = {
25
25
  "failed": "Rewind failed: {error}. The session is unchanged.",
26
26
  "restore.count": "restored {count} file(s)",
27
27
  "delete.count": "deleted {count} file(s)",
28
- "skip.count": "skipped {count} link(s)",
28
+ "skip.count": "skipped {count} path(s)",
29
29
  "noRestorable": "; no restorable write-class changes after the target",
30
+ "storeUnsupported": "file restore unavailable: these snapshots use a newer store format (v{version}) than this plugin understands; nothing was changed. Update the plugin or clear this session's snapshots.",
30
31
  "success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
31
32
  "noUserMessages": "This session has no rewindable user messages yet.",
32
33
  "chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
@@ -73,8 +74,9 @@ var zh = {
73
74
  "failed": "\u56DE\u9000\u5931\u8D25\uFF1A{error}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002",
74
75
  "restore.count": "\u8FD8\u539F {count} \u4E2A\u6587\u4EF6",
75
76
  "delete.count": "\u5220\u9664 {count} \u4E2A\u6587\u4EF6",
76
- "skip.count": "\u8DF3\u8FC7 {count} \u4E2A\u94FE\u63A5",
77
+ "skip.count": "\u8DF3\u8FC7 {count} \u4E2A\u8DEF\u5F84",
77
78
  "noRestorable": "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4",
79
+ "storeUnsupported": "\u6587\u4EF6\u8FD8\u539F\u4E0D\u53EF\u7528\uFF1A\u8FD9\u4E9B\u5FEB\u7167\u4F7F\u7528\u4E86\u6BD4\u672C\u63D2\u4EF6\u66F4\u65B0\u7684\u5B58\u50A8\u683C\u5F0F\uFF08v{version}\uFF09\uFF1B\u5DE5\u4F5C\u533A\u672A\u505A\u4EFB\u4F55\u6539\u52A8\u3002\u8BF7\u66F4\u65B0\u63D2\u4EF6\uFF0C\u6216\u6E05\u9664\u8BE5\u4F1A\u8BDD\u7684\u5FEB\u7167\u3002",
78
80
  "success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
79
81
  "noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
80
82
  "chooseMode": "\u5C06\u56DE\u9000\u5230 {target}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A\n /rewind {target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD\n /rewind {target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6",
@@ -218,23 +220,104 @@ function execSessionCwd(exec, requestedPath) {
218
220
 
219
221
  // src/snapshot.ts
220
222
  import { createHash } from "node:crypto";
221
- import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
222
- import { dirname, join } from "node:path";
223
+ import { chmod, copyFile, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
224
+ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
223
225
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
224
226
  var SNAPSHOT_DIR_NAME = "rewind-snapshots";
227
+ var SIDECAR_SUFFIX = ".before";
228
+ var PENDING_DIR = ".pending";
229
+ var RESCUE_DIR = "rescue";
230
+ var JOURNAL_PREFIX = "journal-";
231
+ var LEGACY_JOURNAL_PREFIX = "restore-journal-";
232
+ var PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
233
+ var COMPARE_CHUNK_BYTES = 64 * 1024;
234
+ var REPLACEMENT_CHAR = "\uFFFD";
225
235
  var DEFAULT_SNAPSHOT_ROOT = join(resolveDshHome(), SNAPSHOT_DIR_NAME);
226
236
  var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
227
237
  var MAX_ANCHOR_GROUPS = 100;
238
+ var CURRENT_STORE_VERSION = 2;
239
+ var UnknownStoreVersionError = class extends Error {
240
+ constructor(version, source) {
241
+ super(`snapshot store version ${version} is newer than this plugin understands (${source})`);
242
+ this.version = version;
243
+ this.source = source;
244
+ this.name = "UnknownStoreVersionError";
245
+ }
246
+ version;
247
+ source;
248
+ };
249
+ function textSourceOf(text) {
250
+ return text.includes(REPLACEMENT_CHAR) ? { kind: "lossyText", text } : { kind: "text", bytes: Buffer.from(text, "utf8") };
251
+ }
228
252
  function isLinkEntry(entry) {
229
253
  return "ref" in entry;
230
254
  }
255
+ async function sameFileBytes(aPath, bPath) {
256
+ const sizes = await Promise.all([stat(aPath), stat(bPath)]);
257
+ if (sizes[0].size !== sizes[1].size) return false;
258
+ const [a, b] = await Promise.all([open(aPath, "r"), open(bPath, "r")]);
259
+ try {
260
+ const aChunk = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);
261
+ const bChunk = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);
262
+ for (; ; ) {
263
+ const [ra, rb] = await Promise.all([
264
+ a.read(aChunk, 0, COMPARE_CHUNK_BYTES, null),
265
+ b.read(bChunk, 0, COMPARE_CHUNK_BYTES, null)
266
+ ]);
267
+ if (ra.bytesRead !== rb.bytesRead) return false;
268
+ if (ra.bytesRead === 0) return true;
269
+ if (!aChunk.subarray(0, ra.bytesRead).equals(bChunk.subarray(0, rb.bytesRead))) return false;
270
+ }
271
+ } finally {
272
+ await Promise.all([a.close(), b.close()]);
273
+ }
274
+ }
275
+ async function sameFileBuffer(path, bytes) {
276
+ const st = await stat(path);
277
+ if (st.size !== bytes.length) return false;
278
+ const handle = await open(path, "r");
279
+ try {
280
+ const chunk = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);
281
+ let offset = 0;
282
+ for (; ; ) {
283
+ const read = await handle.read(chunk, 0, Math.min(COMPARE_CHUNK_BYTES, bytes.length - offset), offset);
284
+ if (read.bytesRead === 0) return offset === bytes.length;
285
+ if (!chunk.subarray(0, read.bytesRead).equals(bytes.subarray(offset, offset + read.bytesRead))) return false;
286
+ offset += read.bytesRead;
287
+ }
288
+ } finally {
289
+ await handle.close();
290
+ }
291
+ }
292
+ function isEnoent(error) {
293
+ return error?.code === "ENOENT";
294
+ }
231
295
  var defaultProbe = {
232
- async readText(path) {
296
+ async matches(source, path) {
233
297
  try {
234
- return await readFile(path, "utf8");
298
+ if (source === null) {
299
+ await stat(path);
300
+ return false;
301
+ }
302
+ if (source.kind === "blob") return await sameFileBytes(path, source.path);
303
+ if (source.kind === "text") return await sameFileBuffer(path, source.bytes);
304
+ return (await readFile(path)).toString("utf8") === source.text;
235
305
  } catch (error) {
236
- if (error.code === "ENOENT") return void 0;
237
- throw error;
306
+ if (isEnoent(error)) return source === null ? true : false;
307
+ return void 0;
308
+ }
309
+ },
310
+ async copy(path, dest) {
311
+ try {
312
+ await copyFile(path, dest);
313
+ const st = await stat(dest);
314
+ return { kind: "copied", size: st.size };
315
+ } catch (error) {
316
+ if (isEnoent(error)) {
317
+ const source = await stat(path).catch(() => void 0);
318
+ if (source === void 0) return { kind: "absent" };
319
+ }
320
+ return { kind: "failed", message: error instanceof Error ? error.message : String(error) };
238
321
  }
239
322
  },
240
323
  isLink: isLinkPath
@@ -253,43 +336,253 @@ async function writeJsonAtomic(file, data, afterTempWrite) {
253
336
  await rename(tmp, file);
254
337
  }
255
338
  var RESTORE_JOURNAL_STATES = /* @__PURE__ */ new Set(["running", "rollback-running", "completed", "rolled-back", "recovery-required"]);
339
+ function isRawJournalRef(value) {
340
+ if (value === null) return true;
341
+ if (typeof value === "string") return true;
342
+ if (typeof value !== "object") return false;
343
+ const ref = value;
344
+ return typeof ref.blob === "string" || typeof ref.text === "string";
345
+ }
256
346
  function isRestoreJournal(value) {
257
347
  if (typeof value !== "object" || value === null) return false;
258
348
  const v = value;
259
349
  if (typeof v.id !== "string" || typeof v.sessionId !== "string" || typeof v.targetSeq !== "number") return false;
350
+ if (v.version !== void 0 && v.version !== 1 && v.version !== 2) return false;
260
351
  if (typeof v.state !== "string" || !RESTORE_JOURNAL_STATES.has(v.state)) return false;
261
352
  if (!Array.isArray(v.actions)) return false;
262
353
  return v.actions.every((action) => {
263
354
  if (typeof action !== "object" || action === null) return false;
264
355
  const a = action;
265
- return typeof a.path === "string" && (a.action === "restore" || a.action === "delete") && (typeof a.before === "string" || a.before === null) && (typeof a.rescue === "string" || a.rescue === null) && typeof a.done === "boolean";
356
+ if (typeof a.path !== "string" || a.action !== "restore" && a.action !== "delete") return false;
357
+ if (typeof a.done !== "boolean") return false;
358
+ if (!isRawJournalRef(a.before) || !isRawJournalRef(a.rescue)) return false;
359
+ if (a.action === "restore" && a.before === null) return false;
360
+ return true;
266
361
  });
267
362
  }
268
- async function readEntry(file) {
363
+ function refToSource(raw, sessionDir) {
364
+ if (raw === null) return null;
365
+ if (typeof raw === "string") return textSourceOf(raw);
366
+ if (typeof raw !== "object") return void 0;
367
+ const ref = raw;
368
+ if (typeof ref.blob === "string") {
369
+ if (!isSafeBackupRef(ref.blob)) return void 0;
370
+ return { kind: "blob", path: join(sessionDir, ref.blob) };
371
+ }
372
+ if (typeof ref.text === "string") return textSourceOf(ref.text);
373
+ return void 0;
374
+ }
375
+ function sourceToRef(source, sessionDir, entryPath) {
376
+ if (source === null) return null;
377
+ if (source.kind === "blob") {
378
+ const blob = relative(sessionDir, source.path);
379
+ if (!isSafeBackupRef(blob)) throw new Error(`unsafe backup ref ${blob} for ${entryPath}`);
380
+ return { blob };
381
+ }
382
+ return { text: source.kind === "text" ? source.bytes.toString("utf8") : source.text };
383
+ }
384
+ function journalToJson(journal, sessionDir) {
385
+ return {
386
+ version: 2,
387
+ id: journal.id,
388
+ sessionId: journal.sessionId,
389
+ targetSeq: journal.targetSeq,
390
+ startedAt: journal.startedAt,
391
+ ...journal.finishedAt !== void 0 ? { finishedAt: journal.finishedAt } : {},
392
+ state: journal.state,
393
+ actions: journal.actions.map((action) => ({
394
+ path: action.path,
395
+ action: action.action,
396
+ before: sourceToRef(action.before, sessionDir, action.path),
397
+ rescue: sourceToRef(action.rescue, sessionDir, action.path),
398
+ ...action.rescueError !== void 0 ? { rescueError: action.rescueError } : {},
399
+ ...action.mode !== void 0 ? { mode: action.mode } : {},
400
+ ...action.rescueMode !== void 0 ? { rescueMode: action.rescueMode } : {},
401
+ ...action.parent !== void 0 ? { parent: action.parent } : {},
402
+ done: action.done,
403
+ ...action.failed !== void 0 ? { failed: action.failed } : {}
404
+ })),
405
+ ...journal.rollbackError !== void 0 ? { rollbackError: journal.rollbackError } : {}
406
+ };
407
+ }
408
+ function journalFromJson(raw, sessionDir) {
409
+ const actions = [];
410
+ for (const value of raw.actions) {
411
+ const before = refToSource(value.before, sessionDir);
412
+ const rescue = refToSource(value.rescue, sessionDir);
413
+ if (before === void 0 || rescue === void 0) return void 0;
414
+ actions.push({
415
+ path: value.path,
416
+ action: value.action,
417
+ before: value.action === "delete" ? null : before,
418
+ rescue,
419
+ ...typeof value.rescueError === "string" ? { rescueError: value.rescueError } : {},
420
+ ...typeof value.mode === "number" ? { mode: value.mode } : {},
421
+ ...typeof value.rescueMode === "number" ? { rescueMode: value.rescueMode } : {},
422
+ ...typeof value.parent === "string" && value.parent.length > 0 ? { parent: value.parent } : {},
423
+ done: value.done,
424
+ ...typeof value.failed === "string" ? { failed: value.failed } : {}
425
+ });
426
+ }
427
+ const version = raw.version === 1 ? 1 : 2;
428
+ return {
429
+ version,
430
+ id: raw.id,
431
+ sessionId: raw.sessionId,
432
+ targetSeq: raw.targetSeq,
433
+ startedAt: typeof raw.startedAt === "number" ? raw.startedAt : 0,
434
+ ...typeof raw.finishedAt === "number" ? { finishedAt: raw.finishedAt } : {},
435
+ state: raw.state,
436
+ actions,
437
+ ...typeof raw.rollbackError === "string" ? { rollbackError: raw.rollbackError } : {}
438
+ };
439
+ }
440
+ function isJournalName(name2) {
441
+ if (!name2.endsWith(".json")) return false;
442
+ return name2.startsWith(JOURNAL_PREFIX) || name2.startsWith(LEGACY_JOURNAL_PREFIX);
443
+ }
444
+ function journalOpIdOf(name2) {
445
+ const prefix = name2.startsWith(LEGACY_JOURNAL_PREFIX) ? LEGACY_JOURNAL_PREFIX : JOURNAL_PREFIX;
446
+ return name2.slice(prefix.length, -".json".length);
447
+ }
448
+ function entryFileName(callId) {
449
+ return `${safeFileId(callId)}-${shortHash(callId)}.json`;
450
+ }
451
+ function shortHash(value) {
452
+ return createHash("sha256").update(value).digest("hex").slice(0, 8);
453
+ }
454
+ function sidecarName(entryFile) {
455
+ return `${entryFile.slice(0, -".json".length)}${SIDECAR_SUFFIX}`;
456
+ }
457
+ function refAnchorOf(ref) {
458
+ const slash = ref.indexOf("/");
459
+ return slash === -1 ? Number.NaN : Number(ref.slice(0, slash));
460
+ }
461
+ function entryToJson(entry) {
462
+ const base = {
463
+ store: 2,
464
+ callId: entry.callId,
465
+ file: entry.path,
466
+ time: entry.time,
467
+ ...entry.parent !== void 0 ? { parent: entry.parent } : {},
468
+ ...entry.mode !== void 0 ? { mode: entry.mode } : {},
469
+ ...entry.lossy === true ? { lossy: true } : {}
470
+ };
471
+ if (entry.before === null) return { ...base, blob: null, size: 0 };
472
+ if (entry.before.kind !== "blob") throw new Error(`entry for ${entry.path} is not blob-backed`);
473
+ return { ...base, blob: basename(entry.before.path), size: entry.size };
474
+ }
475
+ function linkToJson(link) {
476
+ return {
477
+ store: 2,
478
+ callId: link.callId,
479
+ file: link.path,
480
+ ref: link.ref,
481
+ time: link.time,
482
+ ...link.parent !== void 0 ? { parent: link.parent } : {}
483
+ };
484
+ }
485
+ async function readEntry(file, anchorSeq) {
486
+ let parsed;
269
487
  try {
270
- const parsed = JSON.parse(await readFile(file, "utf8"));
271
- if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
272
- const base = {
273
- callId: String(parsed.callId ?? ""),
274
- anchorSeq: parsed.anchorSeq,
275
- path: parsed.path,
276
- time: typeof parsed.time === "number" ? parsed.time : 0
277
- };
278
- if (typeof parsed.ref === "string") {
279
- return { ...base, ref: parsed.ref };
488
+ const value = JSON.parse(await readFile(file, "utf8"));
489
+ if (typeof value !== "object" || value === null) return void 0;
490
+ parsed = value;
491
+ } catch {
492
+ return void 0;
493
+ }
494
+ if (typeof parsed.store === "number" && parsed.store > CURRENT_STORE_VERSION) {
495
+ throw new UnknownStoreVersionError(parsed.store, file);
496
+ }
497
+ const callId = String(parsed.callId ?? "");
498
+ const time = typeof parsed.time === "number" ? parsed.time : 0;
499
+ const origin = { file };
500
+ if (parsed.store === 2) {
501
+ if (typeof parsed.file !== "string") return void 0;
502
+ const parent = typeof parsed.parent === "string" && parsed.parent.length > 0 ? parsed.parent : void 0;
503
+ const base2 = { callId, anchorSeq, path: parsed.file, time, ...parent !== void 0 ? { parent } : {}, ...origin };
504
+ if (typeof parsed.ref === "string") return { ...base2, ref: parsed.ref };
505
+ if (parsed.blob === null) {
506
+ if (parsed.lossy === true) return void 0;
507
+ if (typeof parsed.size === "number" && parsed.size !== 0) return void 0;
508
+ return { ...base2, before: null, size: 0 };
509
+ }
510
+ if (typeof parsed.blob !== "string") return void 0;
511
+ if (parsed.blob !== sidecarName(basename(file))) return void 0;
512
+ const size = typeof parsed.size === "number" && parsed.size >= 0 ? parsed.size : 0;
513
+ const mode = typeof parsed.mode === "number" ? parsed.mode : void 0;
514
+ if (parsed.lossy === true) {
515
+ try {
516
+ const text = await readFile(join(dirname(file), parsed.blob), "utf8");
517
+ return { ...base2, before: { kind: "lossyText", text }, size, lossy: true, ...mode !== void 0 ? { mode } : {} };
518
+ } catch {
519
+ return void 0;
520
+ }
280
521
  }
281
522
  return {
282
- ...base,
283
- before: typeof parsed.before === "string" ? parsed.before : null
523
+ ...base2,
524
+ before: { kind: "blob", path: join(dirname(file), parsed.blob) },
525
+ size,
526
+ ...mode !== void 0 ? { mode } : {}
284
527
  };
528
+ }
529
+ if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
530
+ const base = { callId, anchorSeq: parsed.anchorSeq, path: parsed.path, time, ...origin };
531
+ if (typeof parsed.ref === "string") return { ...base, ref: parsed.ref };
532
+ if (parsed.before !== null && typeof parsed.before !== "string") return void 0;
533
+ if (parsed.before === null) return { ...base, before: null, size: 0 };
534
+ return { ...base, before: textSourceOf(parsed.before), size: Buffer.byteLength(parsed.before, "utf8") };
535
+ }
536
+ async function dirBytes(dir) {
537
+ let names;
538
+ try {
539
+ names = await readdir(dir);
285
540
  } catch {
286
- return void 0;
541
+ return 0;
542
+ }
543
+ let total = 0;
544
+ for (const name2 of names) {
545
+ const full = join(dir, name2);
546
+ const st = await lstat(full).catch(() => void 0);
547
+ if (st === void 0) continue;
548
+ if (st.isDirectory()) total += await dirBytes(full);
549
+ else if (st.isFile()) total += st.size;
287
550
  }
551
+ return total;
288
552
  }
289
553
  async function isLinkPath(path) {
290
554
  try {
291
- const stat2 = await lstat(path);
292
- return stat2.isSymbolicLink() || stat2.nlink > 1;
555
+ const stat3 = await lstat(path);
556
+ return stat3.isSymbolicLink() || stat3.nlink > 1;
557
+ } catch {
558
+ return false;
559
+ }
560
+ }
561
+ async function nearestExistingAncestor(dir) {
562
+ let current = dir;
563
+ for (; ; ) {
564
+ const st = await lstat(current).catch(() => void 0);
565
+ if (st !== void 0) return current;
566
+ const parent = dirname(current);
567
+ if (parent === current) return void 0;
568
+ current = parent;
569
+ }
570
+ }
571
+ async function parentStillMatches(path, recorded) {
572
+ if (recorded === void 0) return true;
573
+ const dir = dirname(path);
574
+ try {
575
+ return await realpath(dir) === recorded;
576
+ } catch (error) {
577
+ if (!isEnoent(error)) return false;
578
+ }
579
+ const ancestor = await nearestExistingAncestor(dir);
580
+ if (ancestor === void 0) return false;
581
+ try {
582
+ const real = await realpath(ancestor);
583
+ const inside = relative(real, recorded);
584
+ const escapes = inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
585
+ return inside !== "" && !escapes;
293
586
  } catch {
294
587
  return false;
295
588
  }
@@ -297,6 +590,13 @@ async function isLinkPath(path) {
297
590
  function isSafeLinkRef(ref) {
298
591
  return /^[0-9]+\/[a-zA-Z0-9._-]+\.json$/.test(ref);
299
592
  }
593
+ function isSafeBackupRef(ref) {
594
+ if (ref.length === 0 || ref.startsWith("/") || ref.includes("\\")) return false;
595
+ const segments = ref.split("/");
596
+ if (segments.length !== 2 && segments.length !== 3) return false;
597
+ if (segments.length === 3 && segments[0] !== RESCUE_DIR) return false;
598
+ return segments.every((segment) => segment !== "." && segment !== ".." && /^[a-zA-Z0-9._-]+$/.test(segment));
599
+ }
300
600
  async function dirSizeAndLastActive(dir) {
301
601
  let size = 0;
302
602
  let lastActiveMs = 0;
@@ -319,7 +619,7 @@ async function dirSizeAndLastActive(dir) {
319
619
  return;
320
620
  }
321
621
  for (const name2 of names) {
322
- if (name2.startsWith(".")) continue;
622
+ if (name2.startsWith(".") && name2 !== PENDING_DIR) continue;
323
623
  await visit(join(current, name2));
324
624
  }
325
625
  };
@@ -329,6 +629,10 @@ async function dirSizeAndLastActive(dir) {
329
629
  var SnapshotStore = class _SnapshotStore {
330
630
  /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
331
631
  static PRUNE_INTERVAL_MS = 1e3;
632
+ /** Session-format-version marker file inside the session dir. Non-`.json`, so it never counts as a checkpoint entry. */
633
+ static FORMAT_FILE = "format";
634
+ /** Plugin STORE-format marker file inside the session dir (non-`.json`, same reasoning). */
635
+ static STORE_FILE = "store";
332
636
  lastPruneAt = 0;
333
637
  /**
334
638
  * Monotonic entry clock. Date.now() has 1ms precision, so back-to-back
@@ -349,14 +653,18 @@ var SnapshotStore = class _SnapshotStore {
349
653
  root;
350
654
  /**
351
655
  * In-memory per-path "most recent entry" for content dedup, keyed by
352
- * `<sessionId>\0<path>`. Each value holds the entry's effective `before`
353
- * content and its own file ref, so a new record with the same content links
354
- * to the immediately-prior entry (linear chain). Seeded lazily per session
355
- * from the bounded on-disk window, so dedup survives a host restart.
656
+ * `<sessionId>\0<path>`. Each value holds the entry's effective byte source
657
+ * (a handle, not a copy) and its own file ref, so a new record with the same
658
+ * content links to the immediately-prior entry (linear chain). Seeded lazily
659
+ * per session from the bounded on-disk window, so dedup survives a host
660
+ * restart. A handle whose bytes vanished (pruned out of band) is treated as
661
+ * "never recorded" — dedup then stores MORE, never less.
356
662
  */
357
663
  lastEntry = /* @__PURE__ */ new Map();
358
664
  /** Sessions whose dedup state has been seeded from disk this process. */
359
665
  seededSessions = /* @__PURE__ */ new Set();
666
+ /** Sessions whose store-format marker this process has already stamped. */
667
+ storeStamped = /* @__PURE__ */ new Set();
360
668
  /**
361
669
  * Session-format version snapshots are anchored under, stamped into each
362
670
  * session's `format` marker when an entry is recorded. `null` until the host
@@ -378,7 +686,54 @@ var SnapshotStore = class _SnapshotStore {
378
686
  }
379
687
  /** Absolute file ref (relative to the session dir) of an entry. */
380
688
  entryRefOf(sessionId, callId, anchorSeq) {
381
- return `${anchorSeq}/${safeFileId(callId)}.json`;
689
+ return `${anchorSeq}/${entryFileName(callId)}`;
690
+ }
691
+ /**
692
+ * The session-relative ref of an entry READ from disk: the file that really
693
+ * holds it. A v1 entry keeps its released name, so recomputing the name from
694
+ * the call id would produce a dangling reference.
695
+ */
696
+ refOfRead(sessionId, entry) {
697
+ if (entry.file !== void 0) return relative(this.sessionDir(sessionId), entry.file);
698
+ return this.entryRefOf(sessionId, entry.callId, entry.anchorSeq);
699
+ }
700
+ /** Drop every in-memory trace of one session (its directory is gone). */
701
+ forgetSession(sessionId) {
702
+ this.seededSessions.delete(sessionId);
703
+ this.storeStamped.delete(sessionId);
704
+ for (const key of [...this.lastEntry.keys()]) {
705
+ if (key.startsWith(`${sessionId}\0`)) this.lastEntry.delete(key);
706
+ }
707
+ }
708
+ /**
709
+ * Forget in-memory state for sessions whose directory no longer exists —
710
+ * after a sweep, or after the user removed a session dir out of band. A
711
+ * stale handle is SAFE (dedup and the boundary both fail toward storing
712
+ * more), but keeping it means the store holds state for a session it deleted
713
+ * and skips re-stamping that session's `format`/`store` markers.
714
+ */
715
+ async forgetMissingSessions() {
716
+ const known = /* @__PURE__ */ new Set([...this.seededSessions, ...this.storeStamped]);
717
+ for (const key of this.lastEntry.keys()) {
718
+ const separator = key.indexOf("\0");
719
+ if (separator !== -1) known.add(key.slice(0, separator));
720
+ }
721
+ for (const sessionId of known) {
722
+ const present = await this.exists(this.sessionDir(sessionId)).catch(() => true);
723
+ if (!present) this.forgetSession(sessionId);
724
+ }
725
+ }
726
+ /**
727
+ * Stage a capture slot for one tool call: create the session's `.pending/`
728
+ * area and return the absolute path the caller copies the before-bytes into
729
+ * (never through memory). The slot lives inside the session dir so the
730
+ * commit can `rename` it into the anchor group atomically; a slot that is
731
+ * never committed is either unlinked by its caller or collected by `prune`.
732
+ */
733
+ async stageCapture(sessionId, key) {
734
+ const dir = join(this.sessionDir(sessionId), PENDING_DIR);
735
+ await mkdir(dir, { recursive: true });
736
+ return join(dir, `${safeFileId(key)}-${shortHash(key)}${SIDECAR_SUFFIX}`);
382
737
  }
383
738
  /**
384
739
  * Seed a session's dedup state from the existing (bounded) on-disk window:
@@ -393,8 +748,8 @@ var SnapshotStore = class _SnapshotStore {
393
748
  for (const entry of await this.entriesAfter(sessionId, 0)) {
394
749
  const key = `${sessionId}\0${entry.path}`;
395
750
  if (this.lastEntry.has(key)) continue;
396
- const content = await this.resolveBefore(sessionId, entry);
397
- this.lastEntry.set(key, { content, ref: this.entryRefOf(sessionId, entry.callId, entry.anchorSeq) });
751
+ const source = await this.resolveBefore(sessionId, entry);
752
+ this.lastEntry.set(key, { source, ref: this.refOfRead(sessionId, entry) });
398
753
  }
399
754
  } catch {
400
755
  this.seededSessions.delete(sessionId);
@@ -408,44 +763,135 @@ var SnapshotStore = class _SnapshotStore {
408
763
  * path from a restore).
409
764
  */
410
765
  async resolveBefore(sessionId, entry, seen = /* @__PURE__ */ new Set()) {
411
- if (!isLinkEntry(entry)) return entry.before;
766
+ if (!isLinkEntry(entry)) return this.validatedSource(entry);
412
767
  const key = `${entry.anchorSeq}:${entry.callId}`;
413
768
  if (seen.has(key)) throw new Error(`link cycle at ${entry.path} (${key})`);
414
769
  seen.add(key);
415
770
  if (!isSafeLinkRef(entry.ref)) throw new Error(`unsafe link ref ${entry.ref} for ${entry.path}`);
416
- const referenced = await readEntry(join(this.sessionDir(sessionId), entry.ref));
771
+ const referenced = await readEntry(join(this.sessionDir(sessionId), entry.ref), refAnchorOf(entry.ref));
417
772
  if (referenced === void 0) throw new Error(`dangling link ${entry.ref} for ${entry.path}`);
418
773
  return this.resolveBefore(sessionId, referenced, seen);
419
774
  }
420
- /** Commit one before-backup (or an in-place dedup link) under its anchor. */
421
- async recordEntry(sessionId, entry, opts) {
775
+ /**
776
+ * Validate a real entry's byte source against the store's own files: a
777
+ * sidecar that is missing, not a regular file, or a different size than the
778
+ * metadata records is an INTEGRITY failure (thrown), never a silent skip and
779
+ * never a fallback to "the file was created" — a restore must not delete a
780
+ * file whose backup it cannot read.
781
+ */
782
+ async validatedSource(entry) {
783
+ const source = entry.before;
784
+ if (source === null || source.kind !== "blob") return source;
785
+ const st = await stat(source.path).catch((error) => {
786
+ if (isEnoent(error)) throw new Error(`missing backup sidecar ${source.path} for ${entry.path}`);
787
+ throw error;
788
+ });
789
+ if (!st.isFile()) throw new Error(`backup sidecar is not a file: ${source.path}`);
790
+ if (st.size !== entry.size) {
791
+ throw new Error(`backup sidecar size mismatch for ${entry.path} (recorded ${entry.size}, found ${st.size})`);
792
+ }
793
+ return source;
794
+ }
795
+ /**
796
+ * True when two recorded byte sources are the same content. Comparison is
797
+ * STREAMING (size first, then chunks) so large files never enter memory.
798
+ * Any unreadable handle — or any legacy lossy source, whose original bytes
799
+ * are unknowable — answers `false`: dedup must fail toward storing more,
800
+ * never toward claiming "unchanged".
801
+ */
802
+ async sourcesMatch(a, b) {
803
+ if (a === null || b === null) return a === null && b === null;
804
+ try {
805
+ if (a.kind === "blob" && b.kind === "blob") return await sameFileBytes(a.path, b.path);
806
+ if (a.kind === "text" && b.kind === "text") return a.bytes.equals(b.bytes);
807
+ if (a.kind === "blob" && b.kind === "text") return await sameFileBuffer(a.path, b.bytes);
808
+ if (a.kind === "text" && b.kind === "blob") return await sameFileBuffer(b.path, a.bytes);
809
+ return false;
810
+ } catch {
811
+ return false;
812
+ }
813
+ }
814
+ /**
815
+ * Write raw bytes to a sidecar path atomically (temp + rename): a crash
816
+ * between the steps leaves only a `.tmp` that no reader picks up.
817
+ */
818
+ async writeSidecar(dest, source) {
819
+ const tmp = `${dest}.tmp`;
820
+ if (source.kind === "blob") await copyFile(source.path, tmp);
821
+ else if (source.kind === "text") await writeFile(tmp, source.bytes);
822
+ else await writeFile(tmp, Buffer.from(source.text, "utf8"));
823
+ await rename(tmp, dest);
824
+ }
825
+ /**
826
+ * Place one entry's sidecar next to its entry file: MOVE a staged capture
827
+ * (same filesystem, atomic) or write the bytes from a source. Returns the
828
+ * blob source and its size, or null for a created file. The sidecar is
829
+ * always complete before the entry JSON is written.
830
+ */
831
+ async placeSidecar(entryFile, content) {
832
+ if (content.source === null) return null;
833
+ const dest = join(dirname(entryFile), sidecarName(basename(entryFile)));
834
+ if (content.staged !== void 0) await rename(content.staged.file, dest);
835
+ else await this.writeSidecar(dest, content.source);
836
+ const st = await stat(dest);
837
+ return { source: { kind: "blob", path: dest }, size: st.size };
838
+ }
839
+ /**
840
+ * Commit one entry (a full before-backup or an in-place dedup link) under
841
+ * its anchor group.
842
+ */
843
+ async commit(sessionId, entry, content, opts) {
422
844
  const time = Math.max(Date.now(), this.lastEntryTime + 1);
423
845
  this.lastEntryTime = time;
846
+ const parent = await realpath(dirname(entry.path)).catch(() => void 0);
424
847
  await this.ensureDedupSeeded(sessionId);
848
+ await this.assertKnownStoreVersion(sessionId);
425
849
  const dir = this.anchorDir(sessionId, entry.anchorSeq);
426
850
  await mkdir(dir, { recursive: true });
427
- const file = join(dir, `${safeFileId(entry.callId)}.json`);
851
+ const file = join(dir, entryFileName(entry.callId));
428
852
  const selfRef = this.entryRefOf(sessionId, entry.callId, entry.anchorSeq);
429
853
  const key = `${sessionId}\0${entry.path}`;
430
854
  const prior = this.lastEntry.get(key);
431
- if (this.dedup && opts?.dedup !== false && prior !== void 0 && prior.content === entry.before) {
855
+ const incoming = content.source === null ? null : content.staged !== void 0 ? { kind: "blob", path: content.staged.file } : content.source;
856
+ const link = this.dedup && opts?.dedup !== false && prior !== void 0 && await this.sourcesMatch(prior.source, incoming);
857
+ if (link) {
432
858
  const committed = {
433
859
  callId: entry.callId,
434
860
  anchorSeq: entry.anchorSeq,
435
861
  path: entry.path,
436
862
  ref: prior.ref,
863
+ ...parent !== void 0 ? { parent } : {},
437
864
  time
438
865
  };
439
- await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
440
- this.lastEntry.set(key, { content: prior.content, ref: selfRef });
866
+ if (content.staged !== void 0) await rm(content.staged.file, { force: true });
867
+ await writeJsonAtomic(file, linkToJson(committed), () => opts?.crash?.("after-temp-write"));
868
+ this.lastEntry.set(key, { source: prior.source, ref: selfRef });
441
869
  } else {
442
- const committed = { ...entry, time };
443
- await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
444
- this.lastEntry.set(key, { content: entry.before, ref: selfRef });
870
+ const placed = await this.placeSidecar(file, content);
871
+ const committed = {
872
+ callId: entry.callId,
873
+ anchorSeq: entry.anchorSeq,
874
+ path: entry.path,
875
+ before: placed?.source ?? null,
876
+ size: placed?.size ?? 0,
877
+ // Content that was already lossy when it reached the store (a v1
878
+ // string, or a materialized link to one) stays marked, so a later
879
+ // reader cannot mistake its re-encoded bytes for a faithful backup.
880
+ ...incoming?.kind === "lossyText" ? { lossy: true } : {},
881
+ ...content.mode !== void 0 ? { mode: content.mode } : {},
882
+ ...parent !== void 0 ? { parent } : {},
883
+ time
884
+ };
885
+ await writeJsonAtomic(file, entryToJson(committed), () => opts?.crash?.("after-temp-write"));
886
+ this.lastEntry.set(key, { source: committed.before, ref: selfRef });
445
887
  }
446
888
  if (this.formatVersion !== null) {
447
889
  await this.markFormatVersion(sessionId, this.formatVersion);
448
890
  }
891
+ if (!this.storeStamped.has(sessionId)) {
892
+ await this.markStoreVersion(sessionId, CURRENT_STORE_VERSION);
893
+ this.storeStamped.add(sessionId);
894
+ }
449
895
  const now = Date.now();
450
896
  if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
451
897
  this.lastPruneAt = now;
@@ -453,16 +899,39 @@ var SnapshotStore = class _SnapshotStore {
453
899
  }
454
900
  }
455
901
  /**
456
- * The effective content recorded by the path's MOST RECENT entry, or
457
- * undefined when the path has never been recorded (a fresh tracking sight).
458
- * This is the single in-memory "last known state" the boundary uses to
459
- * decide whether a tracked file changed — the same source `recordEntry`
460
- * dedups against, so there is one content copy and one comparison per
461
- * decision, not two. Seeding is idempotent (once per session from disk).
902
+ * Commit one before-backup whose content the caller already holds as raw
903
+ * text (the boundary-friendly API: tests, synthetic records). The bytes are
904
+ * encoded UTF-8, exactly as the released v1 build did for text content.
905
+ */
906
+ async recordEntry(sessionId, entry, opts) {
907
+ await this.commit(sessionId, entry, {
908
+ source: entry.before === null ? null : textSourceOf(entry.before)
909
+ }, opts);
910
+ }
911
+ /**
912
+ * Commit one before-backup whose content is an existing byte file (the
913
+ * capture and boundary paths): `backup.file` is MOVED into the anchor group
914
+ * (same filesystem, so this is atomic), or `null` when the file did not
915
+ * exist — a creation.
916
+ */
917
+ async recordBackup(sessionId, entry, backup, opts) {
918
+ await this.commit(sessionId, entry, {
919
+ source: backup === null ? null : { kind: "blob", path: backup.file },
920
+ ...backup !== null ? { staged: { file: backup.file } } : {},
921
+ ...backup?.mode !== void 0 ? { mode: backup.mode } : {}
922
+ }, opts);
923
+ }
924
+ /**
925
+ * The byte source recorded by the path's MOST RECENT entry, or undefined
926
+ * when the path has never been recorded (a fresh tracking sight). This is
927
+ * the single in-memory "last known state" the boundary compares the disk
928
+ * against — the same source `recordEntry` dedups against, so there is one
929
+ * handle and one comparison per decision, not two. Seeding is idempotent
930
+ * (once per session from disk).
462
931
  */
463
932
  async lastKnownContent(sessionId, path) {
464
933
  await this.ensureDedupSeeded(sessionId);
465
- return this.lastEntry.get(`${sessionId}\0${path}`)?.content;
934
+ return this.lastEntry.get(`${sessionId}\0${path}`)?.source;
466
935
  }
467
936
  /**
468
937
  * All committed entries anchored at or after `targetSeq`, newest first (for
@@ -487,7 +956,7 @@ var SnapshotStore = class _SnapshotStore {
487
956
  const files = await readdir(this.anchorDir(sessionId, anchorSeq)).catch(() => []);
488
957
  for (const file of files) {
489
958
  if (!file.endsWith(".json")) continue;
490
- const entry = await readEntry(join(this.anchorDir(sessionId, anchorSeq), file));
959
+ const entry = await readEntry(join(this.anchorDir(sessionId, anchorSeq), file), anchorSeq);
491
960
  if (entry !== void 0) entries.push(entry);
492
961
  }
493
962
  }
@@ -521,9 +990,15 @@ var SnapshotStore = class _SnapshotStore {
521
990
  * `delete` ONLY when the file currently exists; an already-absent file
522
991
  * is a no-op — this kills the "ghost impact" of replaying an entry a
523
992
  * previous rewind already consumed.
524
- * - `before === 'X'` plans a `restore` ONLY when the current content
525
- * differs from X (or the file is missing); identical content is a no-op
526
- * this keeps repeated rewinds idempotent.
993
+ * - a recorded byte source plans a `restore` ONLY when the current bytes
994
+ * differ from it (or the file is missing); identical bytes are a no-op
995
+ * this keeps repeated rewinds idempotent.
996
+ * - A released-v1 record that lost bytes to a lossy decode (`lossyText`) is
997
+ * compared with the same lossy decode but NEVER written back: a skip is
998
+ * reported instead of destroying live bytes with U+FFFD content.
999
+ * - An unreadable / unresolvable record is a per-file FAILURE, never a
1000
+ * delete: planning a delete for a file we cannot restore is the one
1001
+ * mistake that loses data.
527
1002
  * - Symlinked / hard-linked paths are never planned (they are reported as
528
1003
  * skipped by the restore pass, never written through).
529
1004
  * - A probe failure (e.g. a permission error reading the file) plans the
@@ -536,40 +1011,54 @@ var SnapshotStore = class _SnapshotStore {
536
1011
  * @returns the planned actions, the link paths skipped, and per-file failures.
537
1012
  */
538
1013
  async planRestore(sessionId, targetSeq, probe) {
1014
+ await this.assertKnownStoreVersion(sessionId);
539
1015
  const actions = [];
540
1016
  const skipped = [];
541
1017
  const failed = [];
542
1018
  for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
1019
+ let source;
1020
+ try {
1021
+ source = await this.resolveBefore(sessionId, entry);
1022
+ } catch (error) {
1023
+ failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
1024
+ continue;
1025
+ }
543
1026
  try {
544
1027
  if (await probe.isLink(entry.path)) {
545
1028
  skipped.push(entry.path);
546
1029
  continue;
547
1030
  }
548
- let before;
549
- try {
550
- before = await this.resolveBefore(sessionId, entry);
551
- } catch (error) {
552
- failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
1031
+ if (!await parentStillMatches(entry.path, entry.parent)) {
1032
+ skipped.push(entry.path);
553
1033
  continue;
554
1034
  }
555
- const current = await probe.readText(entry.path);
556
- if (before === null) {
557
- if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
558
- } else if (current !== before) {
559
- actions.push({ path: entry.path, action: "restore", before });
1035
+ if (source !== null && source.kind === "lossyText") {
1036
+ const same2 = await probe.matches(source, entry.path);
1037
+ if (same2 !== true) skipped.push(entry.path);
1038
+ continue;
560
1039
  }
561
- } catch (error) {
562
- let before;
563
- try {
564
- before = await this.resolveBefore(sessionId, entry);
565
- } catch {
566
- before = null;
1040
+ const same = await probe.matches(source, entry.path);
1041
+ if (same === true) continue;
1042
+ const pin = entry.parent !== void 0 ? { parent: entry.parent } : {};
1043
+ if (source === null) actions.push({ path: entry.path, action: "delete", ...pin });
1044
+ else {
1045
+ const mode = isLinkEntry(entry) ? void 0 : entry.mode;
1046
+ actions.push({
1047
+ path: entry.path,
1048
+ action: "restore",
1049
+ before: source,
1050
+ ...mode !== void 0 ? { mode } : {},
1051
+ ...pin
1052
+ });
567
1053
  }
568
- if (before === null) {
569
- actions.push({ path: entry.path, action: "delete" });
570
- } else {
571
- actions.push({ path: entry.path, action: "restore", before });
1054
+ } catch {
1055
+ if (source !== null && source.kind === "lossyText") {
1056
+ skipped.push(entry.path);
1057
+ continue;
572
1058
  }
1059
+ const pin = entry.parent !== void 0 ? { parent: entry.parent } : {};
1060
+ if (source === null) actions.push({ path: entry.path, action: "delete", ...pin });
1061
+ else actions.push({ path: entry.path, action: "restore", before: source, ...pin });
573
1062
  }
574
1063
  }
575
1064
  return { actions, skipped, failed };
@@ -615,7 +1104,22 @@ var SnapshotStore = class _SnapshotStore {
615
1104
  const journalAction = journal.actions[i];
616
1105
  let applied;
617
1106
  try {
618
- applied = await this.applyActionToDisk(action.action, action.path, action.action === "restore" ? action.before : null, deleteFile);
1107
+ applied = await this.applyActionToDisk(
1108
+ action.action,
1109
+ action.path,
1110
+ action.action === "restore" ? action.before : null,
1111
+ deleteFile,
1112
+ {
1113
+ ...action.action === "restore" && action.mode !== void 0 ? { mode: action.mode } : {},
1114
+ ...action.parent !== void 0 ? { parent: action.parent } : {}
1115
+ }
1116
+ );
1117
+ if (applied === "skipped") {
1118
+ journalAction.failed = `parent directory moved or repointed: ${dirname(action.path)}`;
1119
+ await this.saveJournal(journal);
1120
+ failed.push({ path: action.path, message: journalAction.failed });
1121
+ continue;
1122
+ }
619
1123
  if (applied === "enoent") {
620
1124
  journalAction.done = true;
621
1125
  await this.saveJournal(journal);
@@ -640,61 +1144,95 @@ var SnapshotStore = class _SnapshotStore {
640
1144
  await this.saveJournal(journal);
641
1145
  return { restored, deleted, skipped, failed };
642
1146
  }
643
- /** Prefix of one restore-op journal file inside the session dir. */
644
- static JOURNAL_PREFIX = "restore-journal-";
645
- /** Session-format-version marker file inside the session dir. Non-`.json`, so it never counts as a checkpoint entry. */
646
- static FORMAT_FILE = "format";
647
- /** Absolute path of one restore-op journal file. */
1147
+ /** Absolute path of one restore-op journal file (the current prefix). */
648
1148
  journalPath(sessionId, opId) {
649
- return join(this.sessionDir(sessionId), `${_SnapshotStore.JOURNAL_PREFIX}${safeFileId(opId)}.json`);
1149
+ return join(this.sessionDir(sessionId), `${JOURNAL_PREFIX}${safeFileId(opId)}.json`);
1150
+ }
1151
+ /**
1152
+ * Locate an existing journal file for an op: the current prefix first, then
1153
+ * the prefix the released v1 build wrote (a restore interrupted before the
1154
+ * upgrade must still be continuable / rollbackable).
1155
+ */
1156
+ async findJournalFile(sessionId, opId) {
1157
+ const dir = this.sessionDir(sessionId);
1158
+ for (const name2 of [`${JOURNAL_PREFIX}${safeFileId(opId)}.json`, `${LEGACY_JOURNAL_PREFIX}${safeFileId(opId)}.json`]) {
1159
+ const file = join(dir, name2);
1160
+ try {
1161
+ await stat(file);
1162
+ return file;
1163
+ } catch {
1164
+ continue;
1165
+ }
1166
+ }
1167
+ return void 0;
650
1168
  }
651
1169
  /**
652
1170
  * Best-effort journal persist: journal IO failures are non-fatal by design —
653
1171
  * a restore must never fail because its audit journal could not be written.
654
1172
  * reconcileRestores() re-derives the true state from the disk, so a missing
655
1173
  * or stale journal only loses the trail, never the recovery ability.
1174
+ *
1175
+ * A journal read back from a legacy file is rewritten IN PLACE (same file),
1176
+ * so a redo / rollback of a pre-upgrade op never leaves two divergent
1177
+ * versions of the same op on disk.
656
1178
  */
657
1179
  async saveJournal(journal) {
658
1180
  try {
659
- await writeJsonAtomic(this.journalPath(journal.sessionId, journal.id), journal);
1181
+ const file = journal.sourceFile ?? this.journalPath(journal.sessionId, journal.id);
1182
+ await writeJsonAtomic(file, journalToJson(journal, this.sessionDir(journal.sessionId)));
660
1183
  } catch {
661
1184
  }
662
1185
  }
663
1186
  /**
664
1187
  * Journal one restore pass before mutating anything: capture the rescue
665
- * (pre-restore) state of every planned path and persist the intent
666
- * atomically. Returns the in-memory journal; a persist failure degrades to
667
- * a journal-less restore (non-fatal, see {@link saveJournal}).
1188
+ * (pre-restore) state of every planned path as a raw byte copy and persist
1189
+ * the intent (references only) atomically. Returns the in-memory journal; a
1190
+ * persist failure degrades to a journal-less restore (non-fatal, see
1191
+ * {@link saveJournal}).
668
1192
  */
669
1193
  async beginRestore(sessionId, targetSeq, actions, probe) {
670
1194
  const sessionDir = this.sessionDir(sessionId);
671
1195
  try {
672
1196
  await this.pruneTerminalJournals(sessionDir, await readdir(sessionDir));
673
1197
  } catch (error) {
674
- if (error.code !== "ENOENT") throw error;
1198
+ if (!isEnoent(error)) throw error;
675
1199
  }
1200
+ const id = `op-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1201
+ const rescueDir = join(sessionDir, RESCUE_DIR, safeFileId(id));
1202
+ let rescueDirReady = false;
676
1203
  const journalActions = [];
677
- for (const action of actions) {
1204
+ for (const [index, action] of actions.entries()) {
678
1205
  let rescue = null;
679
1206
  let rescueError;
1207
+ const dest = join(rescueDir, `${index}${SIDECAR_SUFFIX}`);
680
1208
  try {
681
- rescue = await probe.readText(action.path) ?? null;
1209
+ if (!rescueDirReady) {
1210
+ await mkdir(rescueDir, { recursive: true });
1211
+ rescueDirReady = true;
1212
+ }
1213
+ const copied = await probe.copy(action.path, dest);
1214
+ if (copied.kind === "copied") rescue = { kind: "blob", path: dest };
1215
+ else if (copied.kind === "failed") rescueError = copied.message;
682
1216
  } catch (error) {
683
1217
  rescueError = error instanceof Error ? error.message : String(error);
684
1218
  }
1219
+ const rescueMode = (await stat(action.path).catch(() => void 0))?.mode;
685
1220
  const journalAction = {
686
1221
  path: action.path,
687
1222
  action: action.action,
688
1223
  before: action.action === "restore" ? action.before : null,
689
1224
  rescue,
1225
+ ...action.action === "restore" && action.mode !== void 0 ? { mode: action.mode } : {},
1226
+ ...action.parent !== void 0 ? { parent: action.parent } : {},
1227
+ ...rescueMode !== void 0 ? { rescueMode: rescueMode & 4095 } : {},
690
1228
  done: false
691
1229
  };
692
1230
  if (rescueError !== void 0) journalAction.rescueError = rescueError;
693
1231
  journalActions.push(journalAction);
694
1232
  }
695
1233
  const journal = {
696
- version: 1,
697
- id: `op-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
1234
+ version: 2,
1235
+ id,
698
1236
  sessionId,
699
1237
  targetSeq,
700
1238
  startedAt: Date.now(),
@@ -710,26 +1248,24 @@ var SnapshotStore = class _SnapshotStore {
710
1248
  * a journal would silently erase the interrupted restore's recovery record.
711
1249
  */
712
1250
  async readJournal(sessionId, opId) {
713
- const file = this.journalPath(sessionId, opId);
714
- let text;
715
- try {
716
- text = await readFile(file, "utf8");
717
- } catch (error) {
718
- if (error.code === "ENOENT") return void 0;
719
- throw error;
720
- }
1251
+ const file = await this.findJournalFile(sessionId, opId);
1252
+ if (file === void 0) return void 0;
721
1253
  let parsed;
722
1254
  try {
723
- parsed = JSON.parse(text);
1255
+ parsed = JSON.parse(await readFile(file, "utf8"));
724
1256
  } catch (error) {
725
1257
  throw new Error(`restore journal ${file} is corrupt: ${error instanceof Error ? error.message : String(error)}`);
726
1258
  }
727
1259
  if (!isRestoreJournal(parsed)) throw new Error(`restore journal ${file} failed schema validation`);
728
- return parsed;
1260
+ const journal = journalFromJson(parsed, this.sessionDir(sessionId));
1261
+ if (journal === void 0) throw new Error(`restore journal ${file} failed schema validation`);
1262
+ journal.sourceFile = file;
1263
+ return journal;
729
1264
  }
730
1265
  /**
731
- * Every journal file of a session — valid ones plus corrupt ones with their
732
- * error — so reconciliation can report corruption instead of dropping it.
1266
+ * Every journal file of a session (both prefixes) — valid ones plus corrupt
1267
+ * ones with their error — so reconciliation can report corruption instead of
1268
+ * dropping it.
733
1269
  */
734
1270
  async listJournals(sessionId) {
735
1271
  const sessionDir = this.sessionDir(sessionId);
@@ -737,20 +1273,26 @@ var SnapshotStore = class _SnapshotStore {
737
1273
  try {
738
1274
  names = await readdir(sessionDir);
739
1275
  } catch (error) {
740
- if (error.code === "ENOENT") return { journals: [], corrupt: [] };
1276
+ if (isEnoent(error)) return { journals: [], corrupt: [] };
741
1277
  throw error;
742
1278
  }
743
1279
  const journals = [];
744
1280
  const corrupt = [];
745
1281
  for (const name2 of names) {
746
- if (!name2.startsWith(_SnapshotStore.JOURNAL_PREFIX) || !name2.endsWith(".json")) continue;
1282
+ if (!isJournalName(name2)) continue;
747
1283
  try {
748
1284
  const parsed = JSON.parse(await readFile(join(sessionDir, name2), "utf8"));
749
1285
  if (!isRestoreJournal(parsed)) {
750
1286
  corrupt.push({ file: name2, message: "journal failed schema validation" });
751
1287
  continue;
752
1288
  }
753
- journals.push(parsed);
1289
+ const journal = journalFromJson(parsed, sessionDir);
1290
+ if (journal === void 0) {
1291
+ corrupt.push({ file: name2, message: "journal references are invalid" });
1292
+ continue;
1293
+ }
1294
+ journal.sourceFile = join(sessionDir, name2);
1295
+ journals.push(journal);
754
1296
  } catch (error) {
755
1297
  corrupt.push({ file: name2, message: error instanceof Error ? error.message : String(error) });
756
1298
  }
@@ -760,29 +1302,54 @@ var SnapshotStore = class _SnapshotStore {
760
1302
  /**
761
1303
  * Execute ONE fs mutation with exactly the pre-journal semantics: a delete
762
1304
  * runs through the injected deleteFile (ENOENT tolerated — the file is
763
- * already absent, i.e. the target state is reached), a restore is a plain
764
- * writeFile with a recursive mkdir of the parent. Returns how the outcome
765
- * should record it.
1305
+ * already absent, i.e. the target state is reached), a restore copies the
1306
+ * recorded bytes back over the file (creating the parent if needed).
766
1307
  *
767
1308
  * This is the only place the store writes restored content to the real FS,
768
- * and it is deliberately a raw `writeFile`/`unlink` rather than the fs
769
- * service: the caller only ever hands it a path from `planRestore` — one the
770
- * session's own write-class tool call recorded and resolved (never a
1309
+ * and it is deliberately raw `copyFile`/`writeFile`/`unlink` rather than the
1310
+ * fs service: the caller only ever hands it a path from `planRestore` — one
1311
+ * the session's own write-class tool call recorded and resolved (never a
771
1312
  * symlink/hard link) and only when it differs from the live disk. So no
772
1313
  * arbitrary path, no model input, never automatic.
1314
+ *
1315
+ * The write is IN PLACE (no temp + rename): it keeps the file's inode and
1316
+ * thus its xattrs/ACL, and crash safety is provided by the journal plus disk
1317
+ * reconciliation instead (a half-written file simply does not match the
1318
+ * goal, so a redo rewrites it).
1319
+ *
1320
+ * Permissions are best-effort (ADR-9/R3): the mode is only ever applied as
1321
+ * part of a CONTENT restore (never as a reason to plan one), and a chmod
1322
+ * failure never fails the restore.
773
1323
  */
774
- async applyActionToDisk(kind, path, content, deleteFile) {
1324
+ async applyActionToDisk(kind, path, content, deleteFile, opts) {
1325
+ if (!await parentStillMatches(path, opts?.parent)) return "skipped";
775
1326
  if (kind === "delete") {
776
1327
  try {
777
1328
  await deleteFile(path);
778
1329
  return "deleted";
779
1330
  } catch (error) {
780
- if (error.code !== "ENOENT") throw error;
1331
+ if (!isEnoent(error)) throw error;
781
1332
  return "enoent";
782
1333
  }
783
1334
  }
1335
+ if (content === null) throw new Error(`restore of ${path} has no recorded content`);
784
1336
  await mkdir(dirname(path), { recursive: true });
785
- await writeFile(path, content, "utf8");
1337
+ const current = (await stat(path).catch(() => void 0))?.mode;
1338
+ let widened = false;
1339
+ if (current !== void 0 && (current & 128) === 0) {
1340
+ await chmod(path, current | 128).catch(() => void 0);
1341
+ widened = true;
1342
+ }
1343
+ try {
1344
+ if (content.kind === "blob") await copyFile(content.path, path);
1345
+ else if (content.kind === "text") await writeFile(path, content.bytes);
1346
+ else await writeFile(path, Buffer.from(content.text, "utf8"));
1347
+ } catch (error) {
1348
+ if (widened && current !== void 0) await chmod(path, current).catch(() => void 0);
1349
+ throw error;
1350
+ }
1351
+ if (opts?.mode !== void 0) await chmod(path, opts.mode).catch(() => void 0);
1352
+ else if (widened && current !== void 0) await chmod(path, current).catch(() => void 0);
786
1353
  return "restored";
787
1354
  }
788
1355
  /**
@@ -794,6 +1361,13 @@ var SnapshotStore = class _SnapshotStore {
794
1361
  * terminal state and not reported. A corrupt journal is reported
795
1362
  * `recovery-required` — never silently dropped.
796
1363
  *
1364
+ * Deliberately NOT gated on the session's `store` marker: a journal is fully
1365
+ * self-describing (`version` plus byte references), and refusing to finish an
1366
+ * interrupted op merely because the SESSION marker looks newer would strand a
1367
+ * half-restored workspace — the outcome the legacy-journal support exists to
1368
+ * prevent. A reference the newer build moved shows up as a per-file failure,
1369
+ * never as a silent write.
1370
+ *
797
1371
  * @param sessionId - session whose journals to reconcile.
798
1372
  * @param probe - current-disk state probe (defaults to the real FS).
799
1373
  * @returns one report per non-terminal journal still needing attention.
@@ -803,7 +1377,7 @@ var SnapshotStore = class _SnapshotStore {
803
1377
  const reports = [];
804
1378
  for (const bad of corrupt) {
805
1379
  reports.push({
806
- opId: bad.file.slice(_SnapshotStore.JOURNAL_PREFIX.length, -".json".length),
1380
+ opId: journalOpIdOf(bad.file),
807
1381
  state: "recovery-required",
808
1382
  journalState: "recovery-required",
809
1383
  targetSeq: 0,
@@ -843,9 +1417,8 @@ var SnapshotStore = class _SnapshotStore {
843
1417
  }
844
1418
  let reached;
845
1419
  try {
846
- const state = await probe.readText(action.path) ?? null;
847
1420
  const goal = rollbackPhase ? action.rescue : action.action === "delete" ? null : action.before;
848
- reached = state === goal;
1421
+ reached = await probe.matches(goal, action.path) === true;
849
1422
  } catch {
850
1423
  reached = false;
851
1424
  }
@@ -873,7 +1446,7 @@ var SnapshotStore = class _SnapshotStore {
873
1446
  };
874
1447
  }
875
1448
  /**
876
- * 补做 (redo) an interrupted restore: finish the op by applying every action
1449
+ * Continue (redo) an interrupted restore: finish the op by applying every action
877
1450
  * whose disk state does not yet match its goal — the restore target for
878
1451
  * `running` journals. Actions are decided by the REAL disk (the same "disk
879
1452
  * is truth" rule as reconciliation), so a crash between an fs op and its
@@ -896,8 +1469,8 @@ var SnapshotStore = class _SnapshotStore {
896
1469
  opts?.crash?.("before-action", i);
897
1470
  let reached;
898
1471
  try {
899
- const state = await probe.readText(action.path) ?? null;
900
- reached = state === (action.action === "delete" ? null : action.before);
1472
+ const goal = action.action === "delete" ? null : action.before;
1473
+ reached = await probe.matches(goal, action.path) === true;
901
1474
  } catch {
902
1475
  reached = false;
903
1476
  }
@@ -909,7 +1482,22 @@ var SnapshotStore = class _SnapshotStore {
909
1482
  }
910
1483
  let applied;
911
1484
  try {
912
- applied = await this.applyActionToDisk(action.action, action.path, action.action === "restore" ? action.before : null, deleteFile);
1485
+ applied = await this.applyActionToDisk(
1486
+ action.action,
1487
+ action.path,
1488
+ action.action === "restore" ? action.before : null,
1489
+ deleteFile,
1490
+ {
1491
+ ...action.action === "restore" && action.mode !== void 0 ? { mode: action.mode } : {},
1492
+ ...action.parent !== void 0 ? { parent: action.parent } : {}
1493
+ }
1494
+ );
1495
+ if (applied === "skipped") {
1496
+ action.failed = `parent directory moved or repointed: ${dirname(action.path)}`;
1497
+ await this.saveJournal(journal);
1498
+ failed.push({ path: action.path, message: action.failed });
1499
+ continue;
1500
+ }
913
1501
  if (applied === "enoent") {
914
1502
  action.done = true;
915
1503
  await this.saveJournal(journal);
@@ -936,7 +1524,7 @@ var SnapshotStore = class _SnapshotStore {
936
1524
  return { restored, deleted, skipped: [], failed };
937
1525
  }
938
1526
  /**
939
- * 回滚 (roll back) an interrupted restore: undo every action whose disk
1527
+ * Roll back an interrupted restore: undo every action whose disk
940
1528
  * state does not match its rescue (pre-restore) record, returning the
941
1529
  * workspace to the exact state it had before the restore started. Decided
942
1530
  * by the REAL disk, so actions the crash left applied-but-unmarked are
@@ -973,8 +1561,7 @@ var SnapshotStore = class _SnapshotStore {
973
1561
  opts?.crash?.("before-action", i);
974
1562
  let reached;
975
1563
  try {
976
- const state = await probe.readText(action.path) ?? null;
977
- reached = state === action.rescue;
1564
+ reached = await probe.matches(action.rescue, action.path) === true;
978
1565
  } catch {
979
1566
  reached = false;
980
1567
  }
@@ -985,7 +1572,24 @@ var SnapshotStore = class _SnapshotStore {
985
1572
  }
986
1573
  let applied;
987
1574
  try {
988
- applied = await this.applyActionToDisk(action.rescue === null ? "delete" : "restore", action.path, action.rescue, deleteFile);
1575
+ applied = await this.applyActionToDisk(
1576
+ action.rescue === null ? "delete" : "restore",
1577
+ action.path,
1578
+ action.rescue,
1579
+ deleteFile,
1580
+ {
1581
+ ...action.rescueMode !== void 0 ? { mode: action.rescueMode } : {},
1582
+ ...action.parent !== void 0 ? { parent: action.parent } : {}
1583
+ }
1584
+ );
1585
+ if (applied === "skipped") {
1586
+ journal.rollbackError = `parent directory moved or repointed: ${action.path}`;
1587
+ journal.state = "recovery-required";
1588
+ await this.saveJournal(journal);
1589
+ failed.push({ path: action.path, message: journal.rollbackError });
1590
+ rollbackFailed = true;
1591
+ continue;
1592
+ }
989
1593
  if (applied === "enoent") {
990
1594
  action.done = false;
991
1595
  await this.saveJournal(journal);
@@ -1038,60 +1642,147 @@ var SnapshotStore = class _SnapshotStore {
1038
1642
  try {
1039
1643
  names = await readdir(sessionDir);
1040
1644
  } catch (error) {
1041
- if (error.code === "ENOENT") return;
1645
+ if (isEnoent(error)) return;
1042
1646
  throw error;
1043
1647
  }
1044
1648
  await this.pruneTerminalJournals(sessionDir, names);
1649
+ await this.prunePendingCaptures(join(sessionDir, PENDING_DIR));
1045
1650
  const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
1046
1651
  const excess = seqs.length - keep;
1047
1652
  if (excess <= 0) return;
1048
1653
  const doomed = new Set(seqs.slice(0, excess));
1654
+ for (const seq of await this.pinnedAnchors(sessionDir, names)) doomed.delete(seq);
1655
+ if (doomed.size === 0) return;
1049
1656
  for (const seq of seqs.slice(excess)) {
1050
1657
  const files = await readdir(this.anchorDir(sessionId, seq)).catch(() => []);
1051
1658
  for (const file of files) {
1052
1659
  if (!file.endsWith(".json")) continue;
1053
- const entry = await readEntry(join(this.anchorDir(sessionId, seq), file));
1660
+ const entryFile = join(this.anchorDir(sessionId, seq), file);
1661
+ let entry;
1662
+ try {
1663
+ entry = await readEntry(entryFile, seq);
1664
+ } catch {
1665
+ continue;
1666
+ }
1054
1667
  if (entry === void 0 || !isLinkEntry(entry)) continue;
1055
1668
  if (!isSafeLinkRef(entry.ref)) continue;
1056
- const slash = entry.ref.indexOf("/");
1057
- const refAnchor = slash === -1 ? Number.NaN : Number(entry.ref.slice(0, slash));
1669
+ const refAnchor = refAnchorOf(entry.ref);
1058
1670
  if (!Number.isSafeInteger(refAnchor) || !doomed.has(refAnchor)) continue;
1059
- let before;
1671
+ let source;
1060
1672
  try {
1061
- before = await this.resolveBefore(sessionId, entry);
1062
- } catch {
1673
+ source = await this.resolveBefore(sessionId, entry);
1674
+ } catch (error) {
1675
+ if (error instanceof UnknownStoreVersionError) throw error;
1063
1676
  continue;
1064
1677
  }
1065
- const real = {
1066
- callId: entry.callId,
1067
- anchorSeq: entry.anchorSeq,
1068
- path: entry.path,
1069
- before,
1070
- time: entry.time
1071
- };
1072
- await writeJsonAtomic(join(this.anchorDir(sessionId, seq), file), real, () => opts?.crash?.("after-temp-write"));
1678
+ const pin = entry.parent !== void 0 ? { parent: entry.parent } : {};
1679
+ let real;
1680
+ if (source === null) {
1681
+ real = {
1682
+ callId: entry.callId,
1683
+ anchorSeq: entry.anchorSeq,
1684
+ path: entry.path,
1685
+ before: null,
1686
+ size: 0,
1687
+ ...pin,
1688
+ time: entry.time
1689
+ };
1690
+ } else {
1691
+ const dest = join(dirname(entryFile), sidecarName(basename(entryFile)));
1692
+ await this.writeSidecar(dest, source);
1693
+ const st = await stat(dest);
1694
+ real = {
1695
+ callId: entry.callId,
1696
+ anchorSeq: entry.anchorSeq,
1697
+ path: entry.path,
1698
+ before: { kind: "blob", path: dest },
1699
+ size: st.size,
1700
+ ...source.kind === "lossyText" ? { lossy: true } : {},
1701
+ ...pin,
1702
+ time: entry.time
1703
+ };
1704
+ }
1705
+ await writeJsonAtomic(entryFile, entryToJson(real), () => opts?.crash?.("after-temp-write"));
1073
1706
  }
1074
1707
  }
1075
1708
  for (const seq of doomed) {
1076
1709
  await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
1077
1710
  }
1711
+ this.seededSessions.delete(sessionId);
1712
+ for (const key of [...this.lastEntry.keys()]) {
1713
+ if (key.startsWith(`${sessionId}\0`)) this.lastEntry.delete(key);
1714
+ }
1715
+ }
1716
+ /**
1717
+ * Collect staged captures that were never committed and are older than
1718
+ * {@link PENDING_MAX_AGE_MS}: a crash between `tools/execute` and
1719
+ * `tools/post-execute` can leak one, and the process that would have
1720
+ * unlinked it is gone.
1721
+ */
1722
+ async prunePendingCaptures(pendingDir) {
1723
+ let names;
1724
+ try {
1725
+ names = await readdir(pendingDir);
1726
+ } catch {
1727
+ return;
1728
+ }
1729
+ const cutoff = Date.now() - PENDING_MAX_AGE_MS;
1730
+ for (const name2 of names) {
1731
+ const file = join(pendingDir, name2);
1732
+ const st = await lstat(file).catch(() => void 0);
1733
+ if (st === void 0 || !st.isFile() || st.mtimeMs >= cutoff) continue;
1734
+ await rm(file, { force: true });
1735
+ }
1736
+ }
1737
+ /**
1738
+ * Anchor groups a NON-TERMINAL journal still depends on — the groups holding
1739
+ * the sidecars its actions restore from. `prune` must not evict them while
1740
+ * the op can still be finished. Rescue copies live under `rescue/`, never in
1741
+ * an anchor group, so only `before` references matter; a group is pinned only
1742
+ * for a well-formed, safe reference (a corrupt journal pins nothing).
1743
+ */
1744
+ async pinnedAnchors(sessionDir, names) {
1745
+ const pinned = /* @__PURE__ */ new Set();
1746
+ for (const name2 of names) {
1747
+ if (!isJournalName(name2)) continue;
1748
+ let parsed;
1749
+ try {
1750
+ parsed = JSON.parse(await readFile(join(sessionDir, name2), "utf8"));
1751
+ } catch {
1752
+ continue;
1753
+ }
1754
+ if (!isRestoreJournal(parsed)) continue;
1755
+ const journal = journalFromJson(parsed, sessionDir);
1756
+ if (journal === void 0) continue;
1757
+ if (journal.state === "completed" || journal.state === "rolled-back") continue;
1758
+ for (const action of journal.actions) {
1759
+ const source = action.before;
1760
+ if (source === null || source.kind !== "blob") continue;
1761
+ const ref = relative(sessionDir, source.path);
1762
+ if (!isSafeBackupRef(ref)) continue;
1763
+ const anchor = refAnchorOf(ref);
1764
+ if (Number.isSafeInteger(anchor)) pinned.add(anchor);
1765
+ }
1766
+ }
1767
+ return pinned;
1078
1768
  }
1079
1769
  /**
1080
1770
  * Recycle terminal restore journals (`completed` / `rolled-back`): once an
1081
- * op finished, its journal's before + rescue content is dead weight that
1082
- * would otherwise accumulate without bound (one journal per both-mode
1083
- * rewind). Non-terminal journals (crashed ops awaiting reconcile /
1084
- * continue / rollback) and unclassifiable (corrupt) ones are ALWAYS kept —
1085
- * a recovery record that cannot be classified is never destroyed.
1771
+ * op finished, its journal and its rescue bytes are dead weight that would
1772
+ * otherwise accumulate without bound (one journal per both-mode rewind).
1773
+ * Non-terminal journals (crashed ops awaiting reconcile / continue /
1774
+ * rollback) and unclassifiable (corrupt) ones are ALWAYS kept — a recovery
1775
+ * record that cannot be classified is never destroyed.
1086
1776
  */
1087
1777
  async pruneTerminalJournals(sessionDir, names) {
1088
1778
  for (const name2 of names) {
1089
- if (!name2.startsWith(_SnapshotStore.JOURNAL_PREFIX) || !name2.endsWith(".json")) continue;
1779
+ if (!isJournalName(name2)) continue;
1090
1780
  const file = join(sessionDir, name2);
1091
1781
  try {
1092
1782
  const parsed = JSON.parse(await readFile(file, "utf8"));
1093
1783
  if (parsed.state === "completed" || parsed.state === "rolled-back") {
1094
1784
  await rm(file, { force: true });
1785
+ await rm(join(sessionDir, RESCUE_DIR, safeFileId(journalOpIdOf(name2))), { recursive: true, force: true });
1095
1786
  }
1096
1787
  } catch {
1097
1788
  }
@@ -1103,7 +1794,7 @@ var SnapshotStore = class _SnapshotStore {
1103
1794
  await stat(path);
1104
1795
  return true;
1105
1796
  } catch (error) {
1106
- if (error.code === "ENOENT") return false;
1797
+ if (isEnoent(error)) return false;
1107
1798
  throw error;
1108
1799
  }
1109
1800
  }
@@ -1124,7 +1815,9 @@ var SnapshotStore = class _SnapshotStore {
1124
1815
  * - a non-positive `maxAgeDays` throws instead of degenerating into a
1125
1816
  * mass-destructive `cutoff` in the far future;
1126
1817
  * - the walk uses `lstat` (no symlink following) and skips dot-prefixed
1127
- * temp left overs, so measurement stays inside the store root.
1818
+ * temp left overs except the real `.pending/` area, whose staged bytes
1819
+ * are content and whose freshness is activity — so measurement stays
1820
+ * inside the store root.
1128
1821
  *
1129
1822
  * `dryRun` computes and reports exactly what would be removed without
1130
1823
  * deleting anything — the `/snapshot-auto-cleanup run` preview.
@@ -1176,6 +1869,7 @@ var SnapshotStore = class _SnapshotStore {
1176
1869
  remainingBytes += size;
1177
1870
  }
1178
1871
  }
1872
+ if (!dryRun) await this.forgetMissingSessions();
1179
1873
  return report();
1180
1874
  }
1181
1875
  /**
@@ -1193,11 +1887,16 @@ var SnapshotStore = class _SnapshotStore {
1193
1887
  }
1194
1888
  /**
1195
1889
  * Summarize a session's on-disk footprint for a clear dry-run: anchor-group
1196
- * count, committed checkpoint-entry count, restore-journal count, and total
1197
- * bytes. Walks with `lstat` (never follows a symlink, so a hostile symlink
1198
- * cannot escape the store root or inflate the measurement) and skips
1199
- * dot-prefixed temp leftovers and non-`.json` members they are never
1200
- * checkpoint entries.
1890
+ * count, committed checkpoint-entry count (one per `.json` in an anchor
1891
+ * group), restore-journal count (both journal prefixes), and the total bytes
1892
+ * the session dir occupies entry JSONs, raw byte sidecars, `rescue/**` and
1893
+ * the staged `.pending/**` copies alike, so the number matches what a `du` of
1894
+ * that directory reports.
1895
+ *
1896
+ * Walks with `lstat` (never follows a symlink, so a hostile symlink cannot
1897
+ * escape the store root or inflate the measurement) and skips dot-prefixed
1898
+ * temp leftovers (the one exception is `.pending/`, whose staged bytes are
1899
+ * real store content).
1201
1900
  */
1202
1901
  async sessionStats(sessionId) {
1203
1902
  const sessionDir = this.sessionDir(sessionId);
@@ -1205,7 +1904,7 @@ var SnapshotStore = class _SnapshotStore {
1205
1904
  try {
1206
1905
  names = await readdir(sessionDir);
1207
1906
  } catch (error) {
1208
- if (error.code === "ENOENT") return { anchorGroups: 0, entries: 0, journals: 0, bytes: 0 };
1907
+ if (isEnoent(error)) return { anchorGroups: 0, entries: 0, journals: 0, bytes: 0 };
1209
1908
  throw error;
1210
1909
  }
1211
1910
  let anchorGroups = 0;
@@ -1213,33 +1912,26 @@ var SnapshotStore = class _SnapshotStore {
1213
1912
  let journals = 0;
1214
1913
  let bytes = 0;
1215
1914
  for (const name2 of names) {
1216
- if (name2.startsWith(".")) continue;
1915
+ if (name2.startsWith(".") && name2 !== PENDING_DIR) continue;
1217
1916
  const full = join(sessionDir, name2);
1218
- let st;
1219
- try {
1220
- st = await lstat(full);
1221
- } catch {
1222
- continue;
1223
- }
1917
+ const st = await lstat(full).catch(() => void 0);
1918
+ if (st === void 0) continue;
1224
1919
  if (st.isDirectory()) {
1225
- if (!Number.isSafeInteger(Number(name2))) continue;
1226
- anchorGroups++;
1227
- let files;
1228
- try {
1229
- files = await readdir(full);
1230
- } catch {
1920
+ if (name2 === PENDING_DIR || name2 === RESCUE_DIR) {
1921
+ bytes += await dirBytes(full);
1231
1922
  continue;
1232
1923
  }
1233
- for (const file of files) {
1234
- if (!file.endsWith(".json")) continue;
1235
- entries++;
1236
- const fileSt = await lstat(join(full, file)).catch(() => void 0);
1237
- if (fileSt !== void 0) bytes += fileSt.size;
1924
+ if (!Number.isSafeInteger(Number(name2))) continue;
1925
+ anchorGroups++;
1926
+ for (const file of await readdir(full).catch(() => [])) {
1927
+ if (file.endsWith(".json")) entries++;
1238
1928
  }
1239
- } else if (name2.startsWith(_SnapshotStore.JOURNAL_PREFIX) && name2.endsWith(".json")) {
1240
- journals++;
1241
- bytes += st.size;
1929
+ bytes += await dirBytes(full);
1930
+ continue;
1242
1931
  }
1932
+ if (!st.isFile()) continue;
1933
+ if (isJournalName(name2)) journals++;
1934
+ bytes += st.size;
1243
1935
  }
1244
1936
  return { anchorGroups, entries, journals, bytes };
1245
1937
  }
@@ -1278,13 +1970,10 @@ var SnapshotStore = class _SnapshotStore {
1278
1970
  const dryRun = opts?.dryRun ?? false;
1279
1971
  const stats = await this.sessionStats(sessionId);
1280
1972
  if (!dryRun) {
1281
- if (stats.anchorGroups > 0 || stats.journals > 0) {
1973
+ if (stats.anchorGroups > 0 || stats.journals > 0 || stats.bytes > 0) {
1282
1974
  await rm(this.sessionDir(sessionId), { recursive: true, force: true });
1283
1975
  }
1284
- this.seededSessions.delete(sessionId);
1285
- for (const key of this.lastEntry.keys()) {
1286
- if (key.startsWith(`${sessionId}\0`)) this.lastEntry.delete(key);
1287
- }
1976
+ this.forgetSession(sessionId);
1288
1977
  }
1289
1978
  return { sessionId, ...stats, dryRun };
1290
1979
  }
@@ -1328,6 +2017,48 @@ var SnapshotStore = class _SnapshotStore {
1328
2017
  setFormatVersion(sessionVersion) {
1329
2018
  this.formatVersion = sessionVersion;
1330
2019
  }
2020
+ /**
2021
+ * Read the plugin's STORE-format marker for a session, or null when there is
2022
+ * none (a released-v1 dir, or a session that never recorded a snapshot). The
2023
+ * marker is a quick session-level signal; every entry and journal is also
2024
+ * self-describing (`store` / `version`), so a missing marker never changes
2025
+ * how an entry is read.
2026
+ */
2027
+ async readStoreVersion(sessionId) {
2028
+ try {
2029
+ const raw = await readFile(join(this.sessionDir(sessionId), _SnapshotStore.STORE_FILE), "utf8");
2030
+ const parsed = Number(raw.trim());
2031
+ return Number.isFinite(parsed) ? parsed : null;
2032
+ } catch {
2033
+ return null;
2034
+ }
2035
+ }
2036
+ /**
2037
+ * Stamp the store-format marker (atomically, like `format`). Written
2038
+ * alongside every byte-format entry, so a session that only ever holds the
2039
+ * released string format keeps no marker and is read as v1.
2040
+ */
2041
+ async markStoreVersion(sessionId, storeVersion) {
2042
+ const dir = this.sessionDir(sessionId);
2043
+ await mkdir(dir, { recursive: true });
2044
+ const file = join(dir, _SnapshotStore.STORE_FILE);
2045
+ const tmp = `${file}.tmp`;
2046
+ await writeFile(tmp, `${storeVersion}`, "utf8");
2047
+ await rename(tmp, file);
2048
+ }
2049
+ /**
2050
+ * Refuse to plan against (or write into) a session whose store format is
2051
+ * NEWER than this build understands (ADR-10): the caller reports it and
2052
+ * changes nothing — no partial restore, no clear, no v2 entry written into a
2053
+ * v3 store. Checked before any entry is read, so the marker alone is enough
2054
+ * to fail closed.
2055
+ */
2056
+ async assertKnownStoreVersion(sessionId) {
2057
+ const version = await this.readStoreVersion(sessionId);
2058
+ if (version !== null && version > CURRENT_STORE_VERSION) {
2059
+ throw new UnknownStoreVersionError(version, join(this.sessionDir(sessionId), _SnapshotStore.STORE_FILE));
2060
+ }
2061
+ }
1331
2062
  /**
1332
2063
  * Session-format-version guard: clear a session's snapshot dir when the
1333
2064
  * format its snapshots were anchored under differs from the current session
@@ -1359,25 +2090,37 @@ var SnapshotStore = class _SnapshotStore {
1359
2090
  }
1360
2091
  };
1361
2092
  function hashPath(path) {
1362
- return createHash("sha256").update(path).digest("hex").slice(0, 8);
2093
+ return shortHash(path);
1363
2094
  }
1364
2095
  async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = defaultProbe) {
2096
+ try {
2097
+ await store.assertKnownStoreVersion(sessionId);
2098
+ } catch {
2099
+ return 0;
2100
+ }
1365
2101
  let recorded = 0;
1366
2102
  for (const path of tracked) {
1367
2103
  try {
1368
2104
  if (await probe.isLink(path)) continue;
1369
- const current = await probe.readText(path);
1370
- const state = current ?? null;
1371
2105
  const last = await store.lastKnownContent(sessionId, path);
1372
- if (last === void 0 || last !== state) {
1373
- await store.recordEntry(sessionId, {
1374
- callId: `recheck-${anchorSeq}-${hashPath(path)}`,
1375
- anchorSeq,
1376
- path,
1377
- before: state
1378
- }, { dedup: false });
1379
- recorded++;
2106
+ if (last !== void 0 && last !== null && last.kind !== "lossyText") {
2107
+ const same = await probe.matches(last, path);
2108
+ if (same === void 0 || same) continue;
2109
+ }
2110
+ const callId = `recheck-${anchorSeq}-${hashPath(path)}`;
2111
+ const staged = await store.stageCapture(sessionId, callId);
2112
+ const copied = await probe.copy(path, staged);
2113
+ if (copied.kind === "failed") {
2114
+ await rm(staged, { force: true });
2115
+ continue;
1380
2116
  }
2117
+ await store.recordBackup(
2118
+ sessionId,
2119
+ { callId, anchorSeq, path },
2120
+ copied.kind === "absent" ? null : { file: staged, size: copied.size },
2121
+ { dedup: false }
2122
+ );
2123
+ recorded++;
1381
2124
  } catch {
1382
2125
  }
1383
2126
  }
@@ -1518,6 +2261,13 @@ function usage() {
1518
2261
  t("usage.blocked")
1519
2262
  ].join("\n");
1520
2263
  }
2264
+ async function discardCapture(capture) {
2265
+ if (capture === void 0 || capture.backup === null) return;
2266
+ try {
2267
+ await rm2(capture.backup.file, { force: true });
2268
+ } catch {
2269
+ }
2270
+ }
1521
2271
  function mutationPathOf(exec) {
1522
2272
  const args = exec.arguments;
1523
2273
  if (exec.name === "write" || exec.name === "edit") {
@@ -1549,43 +2299,72 @@ async function resolveTarget(fs, path, cwd, signal) {
1549
2299
  return void 0;
1550
2300
  }
1551
2301
  }
1552
- async function readTextOrUndefined(fs, target, signal) {
1553
- try {
1554
- return await fs.readText(target, signal);
1555
- } catch (error) {
1556
- const code = error?.code;
1557
- if (code === "ENOENT" || code === "FS_NOT_FOUND") return void 0;
1558
- throw error;
1559
- }
2302
+ function isNotFoundError(error) {
2303
+ const code = error?.code;
2304
+ return code === "ENOENT" || code === "FS_NOT_FOUND";
1560
2305
  }
1561
- async function captureBefore(fs, exec, pending) {
2306
+ async function captureBefore(fs, store, exec, pending) {
1562
2307
  if (!TRACKED_TOOLS.has(exec.name)) return;
1563
- const header = exec.agent?.session.header;
2308
+ const session = exec.agent?.session;
2309
+ const header = session?.header;
1564
2310
  if (header !== void 0 && (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0)) return;
1565
2311
  const path = mutationPathOf(exec);
1566
2312
  if (path === void 0) return;
1567
2313
  const cwd = execSessionCwd(exec, path);
1568
2314
  const target = await resolveTarget(fs, path, cwd, exec.signal);
1569
2315
  if (target === void 0) return;
1570
- const before = await readTextOrUndefined(fs, target, exec.signal);
1571
- pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
2316
+ const info = await fs.stat(target, exec.signal).catch((error) => {
2317
+ if (isNotFoundError(error)) return void 0;
2318
+ throw error;
2319
+ });
2320
+ if (session === void 0) return;
2321
+ if (info !== void 0 && info.type !== "file") return;
2322
+ const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
2323
+ if (info === void 0) {
2324
+ pending.set(key, { path: target.displayPath, backup: null });
2325
+ return;
2326
+ }
2327
+ const staged = await store.stageCapture(session.id, key);
2328
+ let backup;
2329
+ try {
2330
+ await copyFile2(target.displayPath, staged);
2331
+ const st = await stat2(staged);
2332
+ const source = await stat2(target.displayPath).catch(() => void 0);
2333
+ backup = {
2334
+ file: staged,
2335
+ size: st.size,
2336
+ ...source !== void 0 ? { mode: source.mode & 4095 } : {}
2337
+ };
2338
+ } catch (error) {
2339
+ await rm2(staged, { force: true });
2340
+ throw error;
2341
+ }
2342
+ pending.set(key, { path: target.displayPath, backup });
1572
2343
  }
1573
2344
  async function commitEntry(store, pending, anchorCache, trackedBySession, exec, result) {
1574
2345
  const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
1575
2346
  const capture = pending.get(key);
1576
2347
  if (capture === void 0) return;
1577
2348
  pending.delete(key);
1578
- if (result.isError) return;
2349
+ if (result.isError) {
2350
+ await discardCapture(capture);
2351
+ return;
2352
+ }
1579
2353
  const agent = exec.agent;
1580
- if (agent === void 0) return;
2354
+ if (agent === void 0) {
2355
+ await discardCapture(capture);
2356
+ return;
2357
+ }
1581
2358
  const anchorSeq = anchorSeqOf(agent.session, anchorCache);
1582
- if (anchorSeq === void 0) return;
1583
- await store.recordEntry(agent.session.id, {
2359
+ if (anchorSeq === void 0) {
2360
+ await discardCapture(capture);
2361
+ return;
2362
+ }
2363
+ await store.recordBackup(agent.session.id, {
1584
2364
  callId: exec.callId,
1585
2365
  anchorSeq,
1586
- path: capture.path,
1587
- before: capture.before ?? null
1588
- });
2366
+ path: capture.path
2367
+ }, capture.backup);
1589
2368
  let tracked = trackedBySession.get(agent.session.id);
1590
2369
  if (tracked === void 0) {
1591
2370
  tracked = /* @__PURE__ */ new Set();
@@ -1724,14 +2503,23 @@ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflig
1724
2503
  }
1725
2504
  let restore = "";
1726
2505
  if (mode === "both") {
1727
- const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
1728
- await syncRestoreObservations(ctx, fs, agent, outcome);
1729
- const parts = [];
1730
- if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
1731
- if (outcome.deleted.length > 0) parts.push(t("delete.count", { count: outcome.deleted.length }));
1732
- if (outcome.skipped.length > 0) parts.push(t("skip.count", { count: outcome.skipped.length }));
1733
- restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
1734
- restore += renderFailures(outcome.failed);
2506
+ let outcome;
2507
+ try {
2508
+ outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
2509
+ } catch (error) {
2510
+ if (!(error instanceof UnknownStoreVersionError)) throw error;
2511
+ const version = await store.readStoreVersion(agent.session.id);
2512
+ restore = `\uFF1B${t("storeUnsupported", { version: version ?? error.version })}`;
2513
+ }
2514
+ if (outcome !== void 0) {
2515
+ await syncRestoreObservations(ctx, fs, agent, outcome);
2516
+ const parts = [];
2517
+ if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
2518
+ if (outcome.deleted.length > 0) parts.push(t("delete.count", { count: outcome.deleted.length }));
2519
+ if (outcome.skipped.length > 0) parts.push(t("skip.count", { count: outcome.skipped.length }));
2520
+ restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
2521
+ restore += renderFailures(outcome.failed);
2522
+ }
1735
2523
  }
1736
2524
  return {
1737
2525
  kind: "success",
@@ -1774,7 +2562,13 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
1774
2562
  } catch (error) {
1775
2563
  return rewindErrorResult(error);
1776
2564
  }
1777
- const impacts = await store.impactsAfter(session.id, plan.targetSeq);
2565
+ const impacts = await store.impactsAfter(session.id, plan.targetSeq).catch((error) => {
2566
+ if (error instanceof UnknownStoreVersionError) return void 0;
2567
+ throw error;
2568
+ });
2569
+ if (impacts === void 0) {
2570
+ return { kind: "error", text: t("storeUnsupported", { version: await store.readStoreVersion(session.id) ?? 0 }) };
2571
+ }
1778
2572
  return { kind: "success", text: formatPlan(plan, impacts) };
1779
2573
  }
1780
2574
  if (parts[0] === "__candidates") {
@@ -1961,6 +2755,12 @@ function apply(ctx, config) {
1961
2755
  void (async () => {
1962
2756
  try {
1963
2757
  store.setFormatVersion(session.header.version);
2758
+ try {
2759
+ await store.assertKnownStoreVersion(session.id);
2760
+ } catch (error) {
2761
+ ctx.logger.warn(`[dsh-rewind] file restore disabled for ${session.id}: ${error instanceof Error ? error.message : String(error)}`);
2762
+ return;
2763
+ }
1964
2764
  const result = await store.reconcileFormatVersion(session.id, session.header.version);
1965
2765
  if (result.cleared) {
1966
2766
  ctx.logger.warn(`[dsh-rewind] cleared snapshots for ${session.id}: session format changed (v${session.header.version})`);
@@ -1995,7 +2795,7 @@ function apply(ctx, config) {
1995
2795
  fsService = fs;
1996
2796
  scope.on("tools/execute", async (exec, next) => {
1997
2797
  try {
1998
- await captureBefore(fs, exec, pending);
2798
+ await captureBefore(fs, store, exec, pending);
1999
2799
  } catch (error) {
2000
2800
  ctx.logger.warn(`[dsh-rewind] before-capture failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
2001
2801
  }
@@ -2011,7 +2811,10 @@ function apply(ctx, config) {
2011
2811
  return next();
2012
2812
  });
2013
2813
  scope.on("tools/result", (exec) => {
2014
- pending.delete(`${exec.agent?.id ?? "anon"}:${exec.callId}`);
2814
+ const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
2815
+ const capture = pending.get(key);
2816
+ pending.delete(key);
2817
+ void discardCapture(capture);
2015
2818
  return void 0;
2016
2819
  });
2017
2820
  });