dsh-rewind-plugin 0.4.2 → 0.6.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
@@ -8,7 +8,7 @@ var en = {
8
8
  "usage.title": "Usage:",
9
9
  "usage.noArgs": " /rewind (no args) withdraw the most recent user message",
10
10
  "usage.seq": " /rewind @<seq> chat|both rewind to the given message (chat = conversation only / both = conversation + files)",
11
- "usage.blocked": " Manual /rewind input is intercepted; use the \u21B6 button next to a message",
11
+ "usage.blocked": " /rewind or /undo open the rewind picker",
12
12
  "describeTarget.seq": "seq {seq}",
13
13
  "describeTarget.index": "message {index}",
14
14
  "plan.rewinding": "Rewind to seq {targetSeq}, removing {count} node(s) from the model context (conversation log kept).",
@@ -30,13 +30,35 @@ var en = {
30
30
  "success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
31
31
  "noUserMessages": "This session has no rewindable user messages yet.",
32
32
  "chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
33
- "command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)"
33
+ "command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)",
34
+ "cleanup.description": "Manage automatic cleanup of session snapshot backups",
35
+ "cleanup.inputHint": "on | off | max-age <days> | run [--apply] [--current]",
36
+ "cleanup.status": "Auto-cleanup: {state}. Max age: {days} day(s). Config: {path} ({present}).",
37
+ "cleanup.enabled": "enabled",
38
+ "cleanup.disabled": "disabled",
39
+ "cleanup.present": "file present",
40
+ "cleanup.absent": "no file \u2014 defaults",
41
+ "cleanup.onOk": "Auto-cleanup enabled.",
42
+ "cleanup.offOk": "Auto-cleanup disabled \u2014 all snapshots kept.",
43
+ "cleanup.maxAgeOk": "Auto-cleanup max age set to {days} day(s).",
44
+ "cleanup.cfgInvalid": 'Snapshot cleanup config invalid: {detail}. Nothing was executed; use "on|off|max-age" to reset the config.',
45
+ "cleanup.saveFailed": "Could not save cleanup config: {detail}.",
46
+ "cleanup.runDry": "Dry-run: would remove {deleted} session snapshot backup(s), freeing {freed} bytes. Re-run with --apply to delete.",
47
+ "cleanup.runApply": "Removed {deleted} session snapshot backup(s), freeing {freed} bytes; {kept} kept, {remaining} bytes remain.",
48
+ "cleanup.runFailed": "Cleanup failed: {detail}.",
49
+ "cleanup.skipped": "({skipped} active session(s) skipped.)",
50
+ "cleanup.clearDry": "Dry-run: would clear {entries} snapshot(s) of the current session, freeing {bytes} bytes. Re-run with --apply to delete.",
51
+ "cleanup.clearApply": "Cleared {entries} snapshot(s) of the current session, freeing {bytes} bytes. This session now records snapshots fresh from its current state.",
52
+ "cleanup.clearActive": "Could not clear session {sessionId}: the session is still running and could not be stopped. Try again once it is idle.",
53
+ "cleanup.clearCancelled": "Clear cancelled.",
54
+ "cleanup.clearFailed": "Could not clear session {sessionId}: {detail}.",
55
+ "cleanup.usage": "Usage:\n /snapshot-auto-cleanup show status\n /snapshot-auto-cleanup on|off enable/disable auto-cleanup\n /snapshot-auto-cleanup max-age <days> set the idle cutoff\n /snapshot-auto-cleanup run [--apply] dry-run, or execute with --apply\n /snapshot-auto-cleanup run --current [--apply] dry-run/clear this session's snapshots"
34
56
  };
35
57
  var zh = {
36
58
  "usage.title": "\u7528\u6CD5\uFF1A",
37
59
  "usage.noArgs": " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
38
60
  "usage.seq": " /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
39
- "usage.blocked": " \u624B\u52A8\u8F93\u5165 /rewind \u4F1A\u88AB\u62E6\u622A\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE",
61
+ "usage.blocked": " /rewind \u6216 /undo \u6253\u5F00\u56DE\u9000\u9009\u62E9\u9762\u677F",
40
62
  "describeTarget.seq": "seq {seq}",
41
63
  "describeTarget.index": "\u7B2C {index} \u6761\u6D88\u606F",
42
64
  "plan.rewinding": "\u5C06\u56DE\u9000\u5230 seq {targetSeq}\uFF0C\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u79FB\u9664 {count} \u4E2A\u8282\u70B9\uFF08\u5BF9\u8BDD\u65E5\u5FD7\u4FDD\u7559\uFF09\u3002",
@@ -58,7 +80,29 @@ var zh = {
58
80
  "success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
59
81
  "noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
60
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",
61
- "command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09"
83
+ "command.description": "\u5728\u540C\u7A97\u53E3\u5185\u5C06\u5BF9\u8BDD\u56DE\u9000\u5230\u66F4\u65E9\u7684\u7528\u6237\u6D88\u606F\uFF08\u53EF\u540C\u65F6\u8FD8\u539F\u6587\u4EF6\uFF09",
84
+ "cleanup.description": "\u7BA1\u7406\u4F1A\u8BDD\u5FEB\u7167\u5907\u4EFD\u7684\u81EA\u52A8\u6E05\u7406",
85
+ "cleanup.inputHint": "on | off | max-age <\u5929\u6570> | run [--apply] [--current]",
86
+ "cleanup.status": "\u81EA\u52A8\u6E05\u7406\uFF1A{state}\u3002\u6700\u5927\u4FDD\u7559\u5929\u6570\uFF1A{days} \u5929\u3002\u914D\u7F6E\uFF1A{path}\uFF08{present}\uFF09\u3002",
87
+ "cleanup.enabled": "\u5DF2\u5F00\u542F",
88
+ "cleanup.disabled": "\u5DF2\u5173\u95ED",
89
+ "cleanup.present": "\u5B58\u5728\u914D\u7F6E\u6587\u4EF6",
90
+ "cleanup.absent": "\u65E0\u6587\u4EF6\u2014\u2014\u4F7F\u7528\u9ED8\u8BA4\u503C",
91
+ "cleanup.onOk": "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406\u3002",
92
+ "cleanup.offOk": "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406\u2014\u2014\u4FDD\u7559\u5168\u90E8\u5FEB\u7167\u3002",
93
+ "cleanup.maxAgeOk": "\u5DF2\u5C06\u81EA\u52A8\u6E05\u7406\u7684\u6700\u5927\u4FDD\u7559\u5929\u6570\u8BBE\u4E3A {days} \u5929\u3002",
94
+ "cleanup.cfgInvalid": "\u5FEB\u7167\u6E05\u7406\u914D\u7F6E\u65E0\u6548\uFF1A{detail}\u3002\u672A\u6267\u884C\u4EFB\u4F55\u64CD\u4F5C\uFF1B\u8BF7\u7528\u300Con|off|max-age\u300D\u91CD\u8BBE\u914D\u7F6E\u4EE5\u4FEE\u590D\u3002",
95
+ "cleanup.saveFailed": "\u65E0\u6CD5\u4FDD\u5B58\u6E05\u7406\u914D\u7F6E\uFF1A{detail}\u3002",
96
+ "cleanup.runDry": "\u9884\u6F14\uFF1A\u5C06\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\uFF0C\u91CA\u653E {freed} \u5B57\u8282\u3002\u52A0 --apply \u6B63\u5F0F\u5220\u9664\u3002",
97
+ "cleanup.runApply": "\u5DF2\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\uFF0C\u91CA\u653E {freed} \u5B57\u8282\uFF1B\u4FDD\u7559 {kept} \u4E2A\uFF0C\u5269\u4F59 {remaining} \u5B57\u8282\u3002",
98
+ "cleanup.runFailed": "\u6E05\u7406\u5931\u8D25\uFF1A{detail}\u3002",
99
+ "cleanup.skipped": "\uFF08\u8DF3\u8FC7\u4E86 {skipped} \u4E2A\u6D3B\u52A8\u4F1A\u8BDD\u3002\uFF09",
100
+ "cleanup.clearDry": "\u9884\u6F14\uFF1A\u5C06\u6E05\u9664\u5F53\u524D\u4F1A\u8BDD\u7684 {entries} \u4E2A\u5FEB\u7167\uFF0C\u91CA\u653E {bytes} \u5B57\u8282\u3002\u52A0 --apply \u6B63\u5F0F\u5220\u9664\u3002",
101
+ "cleanup.clearApply": "\u5DF2\u6E05\u9664\u5F53\u524D\u4F1A\u8BDD\u7684 {entries} \u4E2A\u5FEB\u7167\uFF0C\u91CA\u653E {bytes} \u5B57\u8282\u3002\u8BE5\u4F1A\u8BDD\u5DF2\u91CD\u7F6E\u4E3A\u4ECE\u5F53\u524D\u72B6\u6001\u91CD\u65B0\u8BB0\u5F55\u5FEB\u7167\u3002",
102
+ "cleanup.clearActive": "\u65E0\u6CD5\u6E05\u9664\u4F1A\u8BDD {sessionId}\uFF1A\u4F1A\u8BDD\u4ECD\u5728\u8FD0\u884C\u4E14\u672A\u80FD\u505C\u6B62\uFF0C\u8BF7\u5F85\u5176\u7A7A\u95F2\u540E\u91CD\u8BD5\u3002",
103
+ "cleanup.clearCancelled": "\u6E05\u7A7A\u5DF2\u53D6\u6D88\u3002",
104
+ "cleanup.clearFailed": "\u65E0\u6CD5\u6E05\u9664\u4F1A\u8BDD {sessionId}\uFF1A{detail}\u3002",
105
+ "cleanup.usage": "\u7528\u6CD5\uFF1A\n /snapshot-auto-cleanup \u67E5\u770B\u72B6\u6001\n /snapshot-auto-cleanup on|off \u5F00\u542F/\u5173\u95ED\u81EA\u52A8\u6E05\u7406\n /snapshot-auto-cleanup max-age <\u5929\u6570> \u8BBE\u7F6E\u5931\u6D3B\u9608\u503C\uFF08\u5929\uFF09\n /snapshot-auto-cleanup run [--apply] \u9884\u6F14\uFF0C\u6216\u52A0 --apply \u6267\u884C\n /snapshot-auto-cleanup run --current [--apply] \u9884\u6F14/\u6E05\u9664\u672C\u4F1A\u8BDD\u5FEB\u7167"
62
106
  };
63
107
  var HOST_DICTS = { en, zh };
64
108
  function translate(lang, key, params = {}) {
@@ -197,10 +241,14 @@ function execSessionCwd(exec, requestedPath) {
197
241
  import { createHash } from "node:crypto";
198
242
  import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
199
243
  import { dirname, join } from "node:path";
200
- import { homedir } from "node:os";
201
- var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
244
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
245
+ var SNAPSHOT_DIR_NAME = "rewind-snapshots";
246
+ var DEFAULT_SNAPSHOT_ROOT = join(resolveDshHome(), SNAPSHOT_DIR_NAME);
202
247
  var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
203
248
  var MAX_ANCHOR_GROUPS = 100;
249
+ function isLinkEntry(entry) {
250
+ return "ref" in entry;
251
+ }
204
252
  var defaultProbe = {
205
253
  async readText(path) {
206
254
  try {
@@ -242,13 +290,19 @@ async function readEntry(file) {
242
290
  try {
243
291
  const parsed = JSON.parse(await readFile(file, "utf8"));
244
292
  if (typeof parsed.path !== "string" || typeof parsed.anchorSeq !== "number") return void 0;
245
- return {
293
+ const base = {
246
294
  callId: String(parsed.callId ?? ""),
247
295
  anchorSeq: parsed.anchorSeq,
248
296
  path: parsed.path,
249
- before: typeof parsed.before === "string" ? parsed.before : null,
250
297
  time: typeof parsed.time === "number" ? parsed.time : 0
251
298
  };
299
+ if (typeof parsed.ref === "string") {
300
+ return { ...base, ref: parsed.ref };
301
+ }
302
+ return {
303
+ ...base,
304
+ before: typeof parsed.before === "string" ? parsed.before : null
305
+ };
252
306
  } catch {
253
307
  return void 0;
254
308
  }
@@ -261,11 +315,39 @@ async function isLinkPath(path) {
261
315
  return false;
262
316
  }
263
317
  }
318
+ function isSafeLinkRef(ref) {
319
+ return /^[0-9]+\/[a-zA-Z0-9._-]+\.json$/.test(ref);
320
+ }
321
+ async function dirSizeAndLastActive(dir) {
322
+ let size = 0;
323
+ let lastActiveMs = 0;
324
+ const visit = async (current) => {
325
+ let st;
326
+ try {
327
+ st = await lstat(current);
328
+ } catch {
329
+ return;
330
+ }
331
+ if (st.mtimeMs > lastActiveMs) lastActiveMs = st.mtimeMs;
332
+ if (!st.isDirectory()) {
333
+ size += st.size;
334
+ return;
335
+ }
336
+ let names;
337
+ try {
338
+ names = await readdir(current);
339
+ } catch {
340
+ return;
341
+ }
342
+ for (const name2 of names) {
343
+ if (name2.startsWith(".")) continue;
344
+ await visit(join(current, name2));
345
+ }
346
+ };
347
+ await visit(dir);
348
+ return { size, lastActiveMs };
349
+ }
264
350
  var SnapshotStore = class _SnapshotStore {
265
- constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT) {
266
- this.root = root;
267
- }
268
- root;
269
351
  /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
270
352
  static PRUNE_INTERVAL_MS = 1e3;
271
353
  lastPruneAt = 0;
@@ -282,6 +364,24 @@ var SnapshotStore = class _SnapshotStore {
282
364
  * it, and even then the in-process order still holds.
283
365
  */
284
366
  lastEntryTime = 0;
367
+ /** Store options; `dedup` toggles in-place content dedup (default on). */
368
+ dedup;
369
+ /** Resolved checkpoint store root (absolute); see the constructor's fallback. */
370
+ root;
371
+ /**
372
+ * In-memory per-path "most recent entry" for content dedup, keyed by
373
+ * `<sessionId>\0<path>`. Each value holds the entry's effective `before`
374
+ * content and its own file ref, so a new record with the same content links
375
+ * to the immediately-prior entry (linear chain). Seeded lazily per session
376
+ * from the bounded on-disk window, so dedup survives a host restart.
377
+ */
378
+ lastEntry = /* @__PURE__ */ new Map();
379
+ /** Sessions whose dedup state has been seeded from disk this process. */
380
+ seededSessions = /* @__PURE__ */ new Set();
381
+ constructor(root, opts) {
382
+ this.dedup = opts?.dedup ?? true;
383
+ this.root = root ?? process.env[SNAPSHOT_ROOT_ENV] ?? join(resolveDshHome(opts?.dshHome), SNAPSHOT_DIR_NAME);
384
+ }
285
385
  /** Absolute path of one session's snapshot directory (id sanitized). */
286
386
  sessionDir(sessionId) {
287
387
  return join(this.root, safeSessionId(sessionId));
@@ -290,26 +390,91 @@ var SnapshotStore = class _SnapshotStore {
290
390
  anchorDir(sessionId, anchorSeq) {
291
391
  return join(this.sessionDir(sessionId), String(anchorSeq));
292
392
  }
293
- /** Commit one before-backup under its turn's anchor group (atomic write). */
393
+ /** Absolute file ref (relative to the session dir) of an entry. */
394
+ entryRefOf(sessionId, callId, anchorSeq) {
395
+ return `${anchorSeq}/${safeFileId(callId)}.json`;
396
+ }
397
+ /**
398
+ * Seed a session's dedup state from the existing (bounded) on-disk window:
399
+ * scan entries newest-first and record the most recent entry per path. This
400
+ * makes content dedup survive a host restart within the session window. A
401
+ * no-op after the first seed (or when `dedup` is disabled).
402
+ */
403
+ async ensureDedupSeeded(sessionId) {
404
+ if (!this.dedup || this.seededSessions.has(sessionId)) return;
405
+ this.seededSessions.add(sessionId);
406
+ try {
407
+ for (const entry of await this.entriesAfter(sessionId, 0)) {
408
+ const key = `${sessionId}\0${entry.path}`;
409
+ if (this.lastEntry.has(key)) continue;
410
+ const content = await this.resolveBefore(sessionId, entry);
411
+ this.lastEntry.set(key, { content, ref: this.entryRefOf(sessionId, entry.callId, entry.anchorSeq) });
412
+ }
413
+ } catch {
414
+ this.seededSessions.delete(sessionId);
415
+ }
416
+ }
417
+ /**
418
+ * Resolve an entry's effective `before` content, following a link chain to
419
+ * its terminal real snapshot. Refs are strictly backward in
420
+ * `(anchorSeq, time)`, so the chain is acyclic and finite. A dangling or
421
+ * cyclic link throws — callers fail per-file (never silently dropping the
422
+ * path from a restore).
423
+ */
424
+ async resolveBefore(sessionId, entry, seen = /* @__PURE__ */ new Set()) {
425
+ if (!isLinkEntry(entry)) return entry.before;
426
+ const key = `${entry.anchorSeq}:${entry.callId}`;
427
+ if (seen.has(key)) throw new Error(`link cycle at ${entry.path} (${key})`);
428
+ seen.add(key);
429
+ if (!isSafeLinkRef(entry.ref)) throw new Error(`unsafe link ref ${entry.ref} for ${entry.path}`);
430
+ const referenced = await readEntry(join(this.sessionDir(sessionId), entry.ref));
431
+ if (referenced === void 0) throw new Error(`dangling link ${entry.ref} for ${entry.path}`);
432
+ return this.resolveBefore(sessionId, referenced, seen);
433
+ }
434
+ /** Commit one before-backup (or an in-place dedup link) under its anchor. */
294
435
  async recordEntry(sessionId, entry, opts) {
295
436
  const time = Math.max(Date.now(), this.lastEntryTime + 1);
296
437
  this.lastEntryTime = time;
438
+ await this.ensureDedupSeeded(sessionId);
297
439
  const dir = this.anchorDir(sessionId, entry.anchorSeq);
298
440
  await mkdir(dir, { recursive: true });
299
- const committed = { ...entry, time };
300
- await writeJsonAtomic(
301
- join(dir, `${safeFileId(entry.callId)}.json`),
302
- committed,
303
- // Test-only crash seam: fire after the temp write so a "half-written"
304
- // crash leaves no committed entry behind.
305
- () => opts?.crash?.("after-temp-write")
306
- );
441
+ const file = join(dir, `${safeFileId(entry.callId)}.json`);
442
+ const selfRef = this.entryRefOf(sessionId, entry.callId, entry.anchorSeq);
443
+ const key = `${sessionId}\0${entry.path}`;
444
+ const prior = this.lastEntry.get(key);
445
+ if (this.dedup && opts?.dedup !== false && prior !== void 0 && prior.content === entry.before) {
446
+ const committed = {
447
+ callId: entry.callId,
448
+ anchorSeq: entry.anchorSeq,
449
+ path: entry.path,
450
+ ref: prior.ref,
451
+ time
452
+ };
453
+ await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
454
+ this.lastEntry.set(key, { content: prior.content, ref: selfRef });
455
+ } else {
456
+ const committed = { ...entry, time };
457
+ await writeJsonAtomic(file, committed, () => opts?.crash?.("after-temp-write"));
458
+ this.lastEntry.set(key, { content: entry.before, ref: selfRef });
459
+ }
307
460
  const now = Date.now();
308
461
  if (now - this.lastPruneAt >= _SnapshotStore.PRUNE_INTERVAL_MS) {
309
462
  this.lastPruneAt = now;
310
463
  await this.prune(sessionId);
311
464
  }
312
465
  }
466
+ /**
467
+ * The effective content recorded by the path's MOST RECENT entry, or
468
+ * undefined when the path has never been recorded (a fresh tracking sight).
469
+ * This is the single in-memory "last known state" the boundary uses to
470
+ * decide whether a tracked file changed — the same source `recordEntry`
471
+ * dedups against, so there is one content copy and one comparison per
472
+ * decision, not two. Seeding is idempotent (once per session from disk).
473
+ */
474
+ async lastKnownContent(sessionId, path) {
475
+ await this.ensureDedupSeeded(sessionId);
476
+ return this.lastEntry.get(`${sessionId}\0${path}`)?.content;
477
+ }
313
478
  /**
314
479
  * All committed entries anchored at or after `targetSeq`, newest first (for
315
480
  * preview ordering). The boundary is inclusive: rewinding to a message also
@@ -379,32 +544,46 @@ var SnapshotStore = class _SnapshotStore {
379
544
  * @param sessionId - session whose snapshot store to plan against.
380
545
  * @param targetSeq - rewind target; entries anchored at/after it apply.
381
546
  * @param probe - current-disk state probe (defaults to the real FS).
382
- * @returns the planned actions plus the link paths that were skipped.
547
+ * @returns the planned actions, the link paths skipped, and per-file failures.
383
548
  */
384
549
  async planRestore(sessionId, targetSeq, probe) {
385
550
  const actions = [];
386
551
  const skipped = [];
552
+ const failed = [];
387
553
  for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
388
554
  try {
389
555
  if (await probe.isLink(entry.path)) {
390
556
  skipped.push(entry.path);
391
557
  continue;
392
558
  }
559
+ let before;
560
+ try {
561
+ before = await this.resolveBefore(sessionId, entry);
562
+ } catch (error) {
563
+ failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
564
+ continue;
565
+ }
393
566
  const current = await probe.readText(entry.path);
394
- if (entry.before === null) {
567
+ if (before === null) {
395
568
  if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
396
- } else if (current !== entry.before) {
397
- actions.push({ path: entry.path, action: "restore", before: entry.before });
569
+ } else if (current !== before) {
570
+ actions.push({ path: entry.path, action: "restore", before });
398
571
  }
399
572
  } catch (error) {
400
- if (entry.before === null) {
573
+ let before;
574
+ try {
575
+ before = await this.resolveBefore(sessionId, entry);
576
+ } catch {
577
+ before = null;
578
+ }
579
+ if (before === null) {
401
580
  actions.push({ path: entry.path, action: "delete" });
402
581
  } else {
403
- actions.push({ path: entry.path, action: "restore", before: entry.before });
582
+ actions.push({ path: entry.path, action: "restore", before });
404
583
  }
405
584
  }
406
585
  }
407
- return { actions, skipped };
586
+ return { actions, skipped, failed };
408
587
  }
409
588
  /** Per-file restore impact: only actions that would actually change the disk. */
410
589
  async impactsAfter(sessionId, targetSeq, probe = defaultProbe) {
@@ -436,8 +615,9 @@ var SnapshotStore = class _SnapshotStore {
436
615
  const deleted = [];
437
616
  const skipped = [];
438
617
  const failed = [];
439
- const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
618
+ const { actions, skipped: skippedPaths, failed: planFailed } = await this.planRestore(sessionId, targetSeq, probe);
440
619
  skipped.push(...skippedPaths);
620
+ failed.push(...planFailed);
441
621
  if (actions.length === 0) return { restored, deleted, skipped, failed };
442
622
  const journal = await this.beginRestore(sessionId, targetSeq, actions, probe);
443
623
  for (let i = 0; i < actions.length; i++) {
@@ -840,8 +1020,21 @@ var SnapshotStore = class _SnapshotStore {
840
1020
  * recycles terminal restore journals (see {@link pruneTerminalJournals}),
841
1021
  * so the per-commit cap bounds BOTH the checkpoint entries and the journal
842
1022
  * accumulation.
1023
+ *
1024
+ * Because dedup links reference prior entries, eviction is LINK-AWARE: before
1025
+ * deleting the oldest groups, any SURVIVING (kept-group) link whose `ref`
1026
+ * lands on a real snapshot inside a doomed group is MATERIALIZED (rewritten
1027
+ * as a real snapshot carrying the resolved content), so no kept link is left
1028
+ * dangling. Links form a linear predecessor chain, so materializing the first
1029
+ * link after each doomed real is enough — later links already point at that
1030
+ * materialized entry (or at other kept links), requiring no rewrite.
1031
+ *
1032
+ * `opts.crash` is the test-only seam: a crash fired inside a materialization
1033
+ * write (between its temp write and rename) leaves ONLY a `.tmp` — the doomed
1034
+ * real is still on disk and the kept link still resolves, so nothing dangles
1035
+ * and a later prune simply re-materializes.
843
1036
  */
844
- async prune(sessionId, keep = MAX_ANCHOR_GROUPS) {
1037
+ async prune(sessionId, keep = MAX_ANCHOR_GROUPS, opts) {
845
1038
  const sessionDir = this.sessionDir(sessionId);
846
1039
  let names;
847
1040
  try {
@@ -854,7 +1047,34 @@ var SnapshotStore = class _SnapshotStore {
854
1047
  const seqs = names.map(Number).filter((seq) => Number.isSafeInteger(seq)).sort((a, b) => a - b);
855
1048
  const excess = seqs.length - keep;
856
1049
  if (excess <= 0) return;
857
- for (const seq of seqs.slice(0, excess)) {
1050
+ const doomed = new Set(seqs.slice(0, excess));
1051
+ for (const seq of seqs.slice(excess)) {
1052
+ const files = await readdir(this.anchorDir(sessionId, seq)).catch(() => []);
1053
+ for (const file of files) {
1054
+ if (!file.endsWith(".json")) continue;
1055
+ const entry = await readEntry(join(this.anchorDir(sessionId, seq), file));
1056
+ if (entry === void 0 || !isLinkEntry(entry)) continue;
1057
+ if (!isSafeLinkRef(entry.ref)) continue;
1058
+ const slash = entry.ref.indexOf("/");
1059
+ const refAnchor = slash === -1 ? Number.NaN : Number(entry.ref.slice(0, slash));
1060
+ if (!Number.isSafeInteger(refAnchor) || !doomed.has(refAnchor)) continue;
1061
+ let before;
1062
+ try {
1063
+ before = await this.resolveBefore(sessionId, entry);
1064
+ } catch {
1065
+ continue;
1066
+ }
1067
+ const real = {
1068
+ callId: entry.callId,
1069
+ anchorSeq: entry.anchorSeq,
1070
+ path: entry.path,
1071
+ before,
1072
+ time: entry.time
1073
+ };
1074
+ await writeJsonAtomic(join(this.anchorDir(sessionId, seq), file), real, () => opts?.crash?.("after-temp-write"));
1075
+ }
1076
+ }
1077
+ for (const seq of doomed) {
858
1078
  await rm(this.anchorDir(sessionId, seq), { recursive: true, force: true });
859
1079
  }
860
1080
  }
@@ -889,6 +1109,77 @@ var SnapshotStore = class _SnapshotStore {
889
1109
  throw error;
890
1110
  }
891
1111
  }
1112
+ /**
1113
+ * Cross-session retention sweep: remove WHOLE session directories whose
1114
+ * newest member stamp is older than `maxAgeDays` days of idle, keeping the
1115
+ * active session (`keepActiveId`) untouched. This is the anti-growth policy
1116
+ * for finished sessions (rewind only ever reads the active session, so a
1117
+ * finished session's backups are provably dead weight).
1118
+ *
1119
+ * SAFETY:
1120
+ * - Only whole session directories are removed (dedup refs are
1121
+ * session-relative, so there is no cross-session dangling to materialize);
1122
+ * - the active session is never targeted (`keepActiveId`), and everything
1123
+ * else is protected by its own mtime — a session that is still written to
1124
+ * keeps scrolling its newest member stamp forward, so it is never old
1125
+ * enough to be pruned;
1126
+ * - a non-positive `maxAgeDays` throws instead of degenerating into a
1127
+ * mass-destructive `cutoff` in the far future;
1128
+ * - the walk uses `lstat` (no symlink following) and skips dot-prefixed
1129
+ * temp left overs, so measurement stays inside the store root.
1130
+ *
1131
+ * `dryRun` computes and reports exactly what would be removed without
1132
+ * deleting anything — the `/snapshot-auto-cleanup run` preview.
1133
+ */
1134
+ async pruneStale(opts) {
1135
+ const { keepActiveId, dryRun = false } = opts;
1136
+ const maxAgeDays = opts.maxAgeDays;
1137
+ if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) {
1138
+ throw new RangeError("pruneStale: maxAgeDays must be a positive finite number");
1139
+ }
1140
+ const cutoffMs = Date.now() - maxAgeDays * 864e5;
1141
+ let scanned = 0;
1142
+ let deleted = 0;
1143
+ let freedBytes = 0;
1144
+ let kept = 0;
1145
+ let skippedActive = 0;
1146
+ let remainingBytes = 0;
1147
+ const report = () => ({ scanned, deleted, freedBytes, kept, remainingBytes, skippedActive, dryRun });
1148
+ let names;
1149
+ try {
1150
+ names = await readdir(this.root);
1151
+ } catch (error) {
1152
+ if (error.code === "ENOENT") return report();
1153
+ throw error;
1154
+ }
1155
+ for (const name2 of names) {
1156
+ if (name2.startsWith(".")) continue;
1157
+ const full = join(this.root, name2);
1158
+ let st;
1159
+ try {
1160
+ st = await lstat(full);
1161
+ } catch {
1162
+ continue;
1163
+ }
1164
+ if (!st.isDirectory()) continue;
1165
+ scanned++;
1166
+ if (keepActiveId !== void 0 && safeSessionId(keepActiveId) === name2) {
1167
+ skippedActive++;
1168
+ remainingBytes += (await dirSizeAndLastActive(full)).size;
1169
+ continue;
1170
+ }
1171
+ const { size, lastActiveMs } = await dirSizeAndLastActive(full);
1172
+ if (lastActiveMs < cutoffMs) {
1173
+ deleted++;
1174
+ freedBytes += size;
1175
+ if (!dryRun) await rm(full, { recursive: true, force: true });
1176
+ } else {
1177
+ kept++;
1178
+ remainingBytes += size;
1179
+ }
1180
+ }
1181
+ return report();
1182
+ }
892
1183
  /**
893
1184
  * All distinct paths ever recorded for a session — the "tracked files"
894
1185
  * set. Mirrors Claude Code's global `trackedFiles` collection (files stay
@@ -902,26 +1193,122 @@ var SnapshotStore = class _SnapshotStore {
902
1193
  }
903
1194
  return paths;
904
1195
  }
1196
+ /**
1197
+ * Summarize a session's on-disk footprint for a clear dry-run: anchor-group
1198
+ * count, committed checkpoint-entry count, restore-journal count, and total
1199
+ * bytes. Walks with `lstat` (never follows a symlink, so a hostile symlink
1200
+ * cannot escape the store root or inflate the measurement) and skips
1201
+ * dot-prefixed temp leftovers and non-`.json` members — they are never
1202
+ * checkpoint entries.
1203
+ */
1204
+ async sessionStats(sessionId) {
1205
+ const sessionDir = this.sessionDir(sessionId);
1206
+ let names;
1207
+ try {
1208
+ names = await readdir(sessionDir);
1209
+ } catch (error) {
1210
+ if (error.code === "ENOENT") return { anchorGroups: 0, entries: 0, journals: 0, bytes: 0 };
1211
+ throw error;
1212
+ }
1213
+ let anchorGroups = 0;
1214
+ let entries = 0;
1215
+ let journals = 0;
1216
+ let bytes = 0;
1217
+ for (const name2 of names) {
1218
+ if (name2.startsWith(".")) continue;
1219
+ const full = join(sessionDir, name2);
1220
+ let st;
1221
+ try {
1222
+ st = await lstat(full);
1223
+ } catch {
1224
+ continue;
1225
+ }
1226
+ if (st.isDirectory()) {
1227
+ if (!Number.isSafeInteger(Number(name2))) continue;
1228
+ anchorGroups++;
1229
+ let files;
1230
+ try {
1231
+ files = await readdir(full);
1232
+ } catch {
1233
+ continue;
1234
+ }
1235
+ for (const file of files) {
1236
+ if (!file.endsWith(".json")) continue;
1237
+ entries++;
1238
+ const fileSt = await lstat(join(full, file)).catch(() => void 0);
1239
+ if (fileSt !== void 0) bytes += fileSt.size;
1240
+ }
1241
+ } else if (name2.startsWith(_SnapshotStore.JOURNAL_PREFIX) && name2.endsWith(".json")) {
1242
+ journals++;
1243
+ bytes += st.size;
1244
+ }
1245
+ }
1246
+ return { anchorGroups, entries, journals, bytes };
1247
+ }
1248
+ /**
1249
+ * Remove a session's ENTIRE snapshot directory — every anchor group, every
1250
+ * checkpoint entry, and every restore journal — and reset the store's
1251
+ * in-memory dedup state so the session starts recording fresh from the
1252
+ * current workspace state. This is the manual "get rid of this session's
1253
+ * records NOW" action on the ACTIVE session the user is driving (it is never
1254
+ * targetable by id; that is a directory-manipulation concern the user can do
1255
+ * directly).
1256
+ *
1257
+ * SEMANTICS — clearing is an explicit abandonment: issuing the command means
1258
+ * the user accepts that this session's snapshot archive goes away. It is
1259
+ * therefore NOT gated on the state of any restore journal. A clear and a
1260
+ * restore are both slash commands the host runs to completion for an agent,
1261
+ * so they never interleave — any non-terminal journal present on disk is a
1262
+ * stale orphan from a previous (crashed) process, and discarding it is the
1263
+ * correct, safe resolution of that abandoned restore.
1264
+ *
1265
+ * SAFETY (this module's real concern is the plugin's ongoing BEHAVIOR, not
1266
+ * losing snapshots):
1267
+ * - Only the session dir is removed; dedup refs are session-relative, so
1268
+ * there is no cross-session dangling to materialize (the same rationale as
1269
+ * {@link pruneStale}'s whole-dir removal).
1270
+ * - The in-memory dedup state (`lastEntry` / `seededSessions`) is ALWAYS
1271
+ * reset on an apply — even when the dir was already empty. A stale
1272
+ * in-memory entry (e.g. a session whose dir was removed out-of-band) would
1273
+ * otherwise link a later `recordEntry` to a deleted prior entry, leaving a
1274
+ * dangling ref that breaks restore resolution. This is the primary
1275
+ * correctness guarantee.
1276
+ *
1277
+ * `dryRun` computes the report without touching disk or memory.
1278
+ */
1279
+ async clearSession(sessionId, opts) {
1280
+ const dryRun = opts?.dryRun ?? false;
1281
+ const stats = await this.sessionStats(sessionId);
1282
+ if (!dryRun) {
1283
+ if (stats.anchorGroups > 0 || stats.journals > 0) {
1284
+ await rm(this.sessionDir(sessionId), { recursive: true, force: true });
1285
+ }
1286
+ this.seededSessions.delete(sessionId);
1287
+ for (const key of this.lastEntry.keys()) {
1288
+ if (key.startsWith(`${sessionId}\0`)) this.lastEntry.delete(key);
1289
+ }
1290
+ }
1291
+ return { sessionId, ...stats, dryRun };
1292
+ }
905
1293
  };
906
1294
  function hashPath(path) {
907
1295
  return createHash("sha256").update(path).digest("hex").slice(0, 8);
908
1296
  }
909
- async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, probe = defaultProbe) {
1297
+ async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = defaultProbe) {
910
1298
  let recorded = 0;
911
1299
  for (const path of tracked) {
912
1300
  try {
913
1301
  if (await probe.isLink(path)) continue;
914
1302
  const current = await probe.readText(path);
915
1303
  const state = current ?? null;
916
- const prev = states.get(path);
917
- if (prev === void 0 || prev !== state) {
1304
+ const last = await store.lastKnownContent(sessionId, path);
1305
+ if (last === void 0 || last !== state) {
918
1306
  await store.recordEntry(sessionId, {
919
1307
  callId: `recheck-${anchorSeq}-${hashPath(path)}`,
920
1308
  anchorSeq,
921
1309
  path,
922
1310
  before: state
923
- });
924
- states.set(path, state);
1311
+ }, { dedup: false });
925
1312
  recorded++;
926
1313
  }
927
1314
  } catch {
@@ -930,6 +1317,141 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, pr
930
1317
  return recorded;
931
1318
  }
932
1319
 
1320
+ // src/snapshot-cleanup.ts
1321
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
1322
+ import { dirname as dirname2, join as join2 } from "node:path";
1323
+ import { resolveDshHome as resolveDshHome2 } from "@deepseek-ai/dsh-home-paths";
1324
+ var CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
1325
+ var CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
1326
+ var DEFAULT_MAX_AGE_DAYS = 30;
1327
+ var DEFAULT_CLEANUP_CONFIG = { enabled: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS };
1328
+ var AUTO_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1e3;
1329
+ function resolveCleanupConfigPath(dshHome) {
1330
+ return process.env[CLEANUP_CONFIG_ENV] ?? join2(resolveDshHome2(dshHome), CLEANUP_CONFIG_FILENAME);
1331
+ }
1332
+ var STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
1333
+ function resolveCleanupStatePath(dshHome) {
1334
+ return join2(dirname2(resolveCleanupConfigPath(dshHome)), STATE_FILENAME);
1335
+ }
1336
+ async function loadLastSweepAt(path) {
1337
+ try {
1338
+ const raw = JSON.parse(await readFile2(path, "utf8"));
1339
+ const value = raw["lastSweepAt"];
1340
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
1341
+ } catch {
1342
+ return 0;
1343
+ }
1344
+ }
1345
+ async function saveLastSweepAt(path, ms) {
1346
+ const tmp = `${path}.tmp`;
1347
+ await mkdir2(dirname2(path), { recursive: true });
1348
+ await writeFile2(tmp, JSON.stringify({ lastSweepAt: ms }), "utf8");
1349
+ await rename2(tmp, path);
1350
+ }
1351
+ async function runAutoCleanupCheck(deps, sessionId) {
1352
+ try {
1353
+ const loaded = await loadCleanupConfig(deps.configPath);
1354
+ if (!loaded.ok) {
1355
+ deps.log(`[dsh-rewind] snapshot cleanup config invalid; auto-cleanup skipped: ${loaded.error}`);
1356
+ return;
1357
+ }
1358
+ if (!loaded.config.enabled) return;
1359
+ if (!shouldRunAutoSweep(await loadLastSweepAt(deps.statePath), Date.now())) return;
1360
+ await deps.pruner.pruneStale({ keepActiveId: sessionId, maxAgeDays: loaded.config.maxAgeDays });
1361
+ await saveLastSweepAt(deps.statePath, Date.now());
1362
+ } catch (error) {
1363
+ deps.log(`[dsh-rewind] snapshot auto-cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
1364
+ }
1365
+ }
1366
+ function parseCleanupConfig(raw) {
1367
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1368
+ return { ok: false, error: "config must be a JSON object" };
1369
+ }
1370
+ const record = raw;
1371
+ let enabled = DEFAULT_CLEANUP_CONFIG.enabled;
1372
+ let maxAgeDays = DEFAULT_CLEANUP_CONFIG.maxAgeDays;
1373
+ if (record["enabled"] !== void 0) {
1374
+ if (typeof record["enabled"] !== "boolean") return { ok: false, error: '"enabled" must be a boolean' };
1375
+ enabled = record["enabled"];
1376
+ }
1377
+ if (record["maxAgeDays"] !== void 0) {
1378
+ const value = record["maxAgeDays"];
1379
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
1380
+ return { ok: false, error: '"maxAgeDays" must be a positive integer' };
1381
+ }
1382
+ maxAgeDays = value;
1383
+ }
1384
+ return { ok: true, config: { enabled, maxAgeDays } };
1385
+ }
1386
+ async function loadCleanupConfig(path) {
1387
+ let text;
1388
+ try {
1389
+ text = await readFile2(path, "utf8");
1390
+ } catch (error) {
1391
+ if (error.code === "ENOENT") {
1392
+ return { ok: true, config: { ...DEFAULT_CLEANUP_CONFIG }, fromFile: false };
1393
+ }
1394
+ return { ok: false, error: `config file unreadable: ${error instanceof Error ? error.message : String(error)}` };
1395
+ }
1396
+ let raw;
1397
+ try {
1398
+ raw = JSON.parse(text);
1399
+ } catch (error) {
1400
+ return { ok: false, error: `config file is not valid JSON: ${error instanceof Error ? error.message : String(error)}` };
1401
+ }
1402
+ const parsed = parseCleanupConfig(raw);
1403
+ if (!parsed.ok) return { ok: false, error: parsed.error };
1404
+ return { ok: true, config: parsed.config, fromFile: true };
1405
+ }
1406
+ async function saveCleanupConfig(path, config) {
1407
+ if (typeof config.enabled !== "boolean" || !Number.isInteger(config.maxAgeDays) || config.maxAgeDays <= 0) {
1408
+ throw new RangeError("invalid cleanup config: enabled must be a boolean and maxAgeDays a positive integer");
1409
+ }
1410
+ const tmp = `${path}.tmp`;
1411
+ await mkdir2(dirname2(path), { recursive: true });
1412
+ await writeFile2(tmp, JSON.stringify(config, null, 2), "utf8");
1413
+ await rename2(tmp, path);
1414
+ }
1415
+ function parseCleanupCommand(rawInput) {
1416
+ const parts = rawInput.trim().split(/\s+/).filter(Boolean);
1417
+ if (parts.length === 0) return { action: "status" };
1418
+ switch (parts[0]) {
1419
+ case "status":
1420
+ return parts.length === 1 ? { action: "status" } : { error: "usage: /snapshot-auto-cleanup status" };
1421
+ case "on":
1422
+ return parts.length === 1 ? { action: "on" } : { error: "usage: /snapshot-auto-cleanup on" };
1423
+ case "off":
1424
+ return parts.length === 1 ? { action: "off" } : { error: "usage: /snapshot-auto-cleanup off" };
1425
+ case "max-age": {
1426
+ if (parts.length !== 2) return { error: "usage: /snapshot-auto-cleanup max-age <days>" };
1427
+ const days = Number(parts[1]);
1428
+ if (!Number.isInteger(days) || days <= 0) return { error: '"max-age" must be a positive integer (days)' };
1429
+ return { action: "max-age", value: days };
1430
+ }
1431
+ case "run-apply":
1432
+ return { error: 'the "run-apply" abbreviation was removed; use "run --apply"' };
1433
+ case "run": {
1434
+ let apply2 = false;
1435
+ let current = false;
1436
+ for (const rawFlag of parts.slice(1)) {
1437
+ if (rawFlag === "--apply") {
1438
+ apply2 = true;
1439
+ } else if (rawFlag === "--current") {
1440
+ current = true;
1441
+ } else {
1442
+ return { error: `unknown /snapshot-auto-cleanup run flag "${rawFlag}"` };
1443
+ }
1444
+ }
1445
+ return { action: "run", target: current ? "current" : "rules", apply: apply2 };
1446
+ }
1447
+ default:
1448
+ return { error: `unknown /snapshot-auto-cleanup subcommand "${parts[0]}"` };
1449
+ }
1450
+ }
1451
+ function shouldRunAutoSweep(lastAtMs, nowMs) {
1452
+ return nowMs - lastAtMs >= AUTO_SWEEP_INTERVAL_MS;
1453
+ }
1454
+
933
1455
  // src/index.ts
934
1456
  var name = "dsh-rewind";
935
1457
  var inject = ["commands", "tools"];
@@ -1237,13 +1759,125 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
1237
1759
  }
1238
1760
  return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
1239
1761
  }
1762
+ var autoSweepChecked = false;
1763
+ async function maybeRunAutoCleanup(ctx, store, sessionId, dshHome) {
1764
+ if (autoSweepChecked) return;
1765
+ autoSweepChecked = true;
1766
+ await runAutoCleanupCheck({
1767
+ pruner: store,
1768
+ configPath: resolveCleanupConfigPath(dshHome),
1769
+ statePath: resolveCleanupStatePath(dshHome),
1770
+ log: (msg) => ctx.logger.warn(msg)
1771
+ }, sessionId);
1772
+ }
1773
+ function formatCleanupReport(report) {
1774
+ const key = report.dryRun ? "cleanup.runDry" : "cleanup.runApply";
1775
+ const text = t(key, {
1776
+ deleted: report.deleted,
1777
+ freed: report.freedBytes,
1778
+ kept: report.kept,
1779
+ remaining: report.remainingBytes
1780
+ });
1781
+ return report.skippedActive > 0 ? `${text}
1782
+ ${t("cleanup.skipped", { skipped: report.skippedActive })}` : text;
1783
+ }
1784
+ async function handleSnapshotCleanup(store, invocation, dshHome, trackedBySession) {
1785
+ const parsed = parseCleanupCommand(invocation.rawInput);
1786
+ if ("error" in parsed) return { kind: "error", text: t("cleanup.usage") };
1787
+ const configPath = resolveCleanupConfigPath(dshHome);
1788
+ switch (parsed.action) {
1789
+ case "status": {
1790
+ const loaded = await loadCleanupConfig(configPath);
1791
+ if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
1792
+ return {
1793
+ kind: "success",
1794
+ text: t("cleanup.status", {
1795
+ state: t(loaded.config.enabled ? "cleanup.enabled" : "cleanup.disabled"),
1796
+ days: loaded.config.maxAgeDays,
1797
+ path: configPath,
1798
+ present: t(loaded.fromFile ? "cleanup.present" : "cleanup.absent")
1799
+ })
1800
+ };
1801
+ }
1802
+ case "on":
1803
+ case "off": {
1804
+ const loaded = await loadCleanupConfig(configPath);
1805
+ const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, enabled: parsed.action === "on" };
1806
+ try {
1807
+ await saveCleanupConfig(configPath, next);
1808
+ } catch (error) {
1809
+ return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
1810
+ }
1811
+ return { kind: "success", text: t(parsed.action === "on" ? "cleanup.onOk" : "cleanup.offOk") };
1812
+ }
1813
+ case "max-age": {
1814
+ const loaded = await loadCleanupConfig(configPath);
1815
+ const next = { ...loaded.ok ? loaded.config : DEFAULT_CLEANUP_CONFIG, maxAgeDays: parsed.value };
1816
+ try {
1817
+ await saveCleanupConfig(configPath, next);
1818
+ } catch (error) {
1819
+ return { kind: "error", text: t("cleanup.saveFailed", { detail: error instanceof Error ? error.message : String(error) }) };
1820
+ }
1821
+ return { kind: "success", text: t("cleanup.maxAgeOk", { days: parsed.value }) };
1822
+ }
1823
+ case "run": {
1824
+ const apply2 = parsed.apply;
1825
+ if (parsed.target === "current") {
1826
+ return handleClearCurrent(store, invocation, apply2, trackedBySession);
1827
+ }
1828
+ const loaded = await loadCleanupConfig(configPath);
1829
+ if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
1830
+ try {
1831
+ const report = await store.pruneStale({
1832
+ keepActiveId: invocation.agent.session.id,
1833
+ maxAgeDays: loaded.config.maxAgeDays,
1834
+ dryRun: !apply2
1835
+ });
1836
+ if (!report.dryRun) await saveLastSweepAt(resolveCleanupStatePath(dshHome), Date.now());
1837
+ return { kind: "success", text: formatCleanupReport(report) };
1838
+ } catch (error) {
1839
+ return { kind: "error", text: t("cleanup.runFailed", { detail: error instanceof Error ? error.message : String(error) }) };
1840
+ }
1841
+ }
1842
+ }
1843
+ }
1844
+ function formatClearReport(report) {
1845
+ const key = report.dryRun ? "cleanup.clearDry" : "cleanup.clearApply";
1846
+ return t(key, {
1847
+ entries: report.entries,
1848
+ bytes: report.bytes
1849
+ });
1850
+ }
1851
+ async function handleClearCurrent(store, invocation, apply2, trackedBySession) {
1852
+ const { agent } = invocation;
1853
+ const sessionId = agent.session.id;
1854
+ if (apply2) {
1855
+ if (agent.status !== "idle") {
1856
+ agent.cancel({ kind: "user" }, { keepInbox: true });
1857
+ const stopped = await waitForAgentIdle(agent, invocation.signal);
1858
+ if (!stopped) {
1859
+ return { kind: "error", text: t("cleanup.clearActive", { sessionId }) };
1860
+ }
1861
+ }
1862
+ if (invocation.signal.aborted) {
1863
+ return { kind: "error", text: t("cleanup.clearCancelled") };
1864
+ }
1865
+ }
1866
+ try {
1867
+ const report = await store.clearSession(sessionId, { dryRun: !apply2 });
1868
+ if (!report.dryRun) trackedBySession.delete(sessionId);
1869
+ return { kind: "success", text: formatClearReport(report) };
1870
+ } catch (error) {
1871
+ return { kind: "error", text: t("cleanup.clearFailed", { detail: error instanceof Error ? error.message : String(error), sessionId }) };
1872
+ }
1873
+ }
1240
1874
  function apply(ctx, config) {
1241
- const store = new SnapshotStore(config?.snapshotDir);
1875
+ const dshHome = config?.dshHome;
1876
+ const store = new SnapshotStore(config?.snapshotDir, { dedup: config?.dedup, dshHome });
1242
1877
  const pending = /* @__PURE__ */ new Map();
1243
1878
  const anchorCache = /* @__PURE__ */ new WeakMap();
1244
1879
  const inflight = /* @__PURE__ */ new Set();
1245
1880
  const trackedBySession = /* @__PURE__ */ new Map();
1246
- const statesBySession = /* @__PURE__ */ new Map();
1247
1881
  let fsService;
1248
1882
  ctx.inject(["settings"], (settingsCtx) => {
1249
1883
  const section = settingsCtx.settings.get(settingsNamespace("locale"));
@@ -1252,10 +1886,22 @@ function apply(ctx, config) {
1252
1886
  }
1253
1887
  });
1254
1888
  ctx.effect(function* () {
1889
+ const rewindHandler = (invocation) => handleRewind(ctx, store, fsService, invocation, inflight);
1255
1890
  yield ctx.commands.register({
1256
1891
  name: "rewind",
1257
1892
  description: t("command.description"),
1258
- handler: (invocation) => handleRewind(ctx, store, fsService, invocation, inflight)
1893
+ handler: rewindHandler
1894
+ });
1895
+ yield ctx.commands.register({
1896
+ name: "undo",
1897
+ description: t("command.description"),
1898
+ handler: rewindHandler
1899
+ });
1900
+ yield ctx.commands.register({
1901
+ name: "snapshot-auto-cleanup",
1902
+ description: t("cleanup.description"),
1903
+ input: { hint: t("cleanup.inputHint") },
1904
+ handler: (invocation) => handleSnapshotCleanup(store, invocation, dshHome, trackedBySession)
1259
1905
  });
1260
1906
  }, "dsh-rewind command");
1261
1907
  ctx.on("session/event", (session, event) => {
@@ -1265,18 +1911,14 @@ function apply(ctx, config) {
1265
1911
  void (async () => {
1266
1912
  try {
1267
1913
  const sessionId = session.id;
1914
+ void maybeRunAutoCleanup(ctx, store, sessionId, dshHome);
1268
1915
  let tracked = trackedBySession.get(sessionId);
1269
1916
  if (tracked === void 0) {
1270
1917
  tracked = await store.trackedPaths(sessionId);
1271
1918
  trackedBySession.set(sessionId, tracked);
1272
1919
  }
1273
1920
  if (tracked.size === 0) return;
1274
- let states = statesBySession.get(sessionId);
1275
- if (states === void 0) {
1276
- states = /* @__PURE__ */ new Map();
1277
- statesBySession.set(sessionId, states);
1278
- }
1279
- await reconcileTracked(store, sessionId, event.seq, tracked, states);
1921
+ await reconcileTracked(store, sessionId, event.seq, tracked);
1280
1922
  } catch (error) {
1281
1923
  ctx.logger.warn(`[dsh-rewind] boundary re-check failed: ${error instanceof Error ? error.message : String(error)}`);
1282
1924
  }
@@ -1295,6 +1937,7 @@ function apply(ctx, config) {
1295
1937
  });
1296
1938
  scope.on("tools/post-execute", async (exec, result, next) => {
1297
1939
  try {
1940
+ void maybeRunAutoCleanup(ctx, store, exec.agent?.session?.id, dshHome);
1298
1941
  await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
1299
1942
  } catch (error) {
1300
1943
  ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);