dsh-rewind-plugin 0.5.0 → 0.6.1

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).",
@@ -32,7 +32,7 @@ var en = {
32
32
  "chooseMode": "Rewind to {target}. Choose a mode:\n /rewind {target} chat conversation only\n /rewind {target} both conversation + file restore",
33
33
  "command.description": "Rewind the conversation back to an earlier user message (optionally restoring files)",
34
34
  "cleanup.description": "Manage automatic cleanup of session snapshot backups",
35
- "cleanup.inputHint": "on | off | max-age <days> | run [--apply] | status",
35
+ "cleanup.inputHint": "on | off | max-age <days> | run [--apply] [--current]",
36
36
  "cleanup.status": "Auto-cleanup: {state}. Max age: {days} day(s). Config: {path} ({present}).",
37
37
  "cleanup.enabled": "enabled",
38
38
  "cleanup.disabled": "disabled",
@@ -41,19 +41,24 @@ var en = {
41
41
  "cleanup.onOk": "Auto-cleanup enabled.",
42
42
  "cleanup.offOk": "Auto-cleanup disabled \u2014 all snapshots kept.",
43
43
  "cleanup.maxAgeOk": "Auto-cleanup max age set to {days} day(s).",
44
- "cleanup.cfgInvalid": "Snapshot cleanup config invalid: {detail}. Using the safe default (disabled).",
44
+ "cleanup.cfgInvalid": 'Snapshot cleanup config invalid: {detail}. Nothing was executed; use "on|off|max-age" to reset the config.',
45
45
  "cleanup.saveFailed": "Could not save cleanup config: {detail}.",
46
- "cleanup.runDry": "Dry-run: would remove {deleted} session snapshot backup(s). Re-run with --apply to delete.",
46
+ "cleanup.runDry": "Dry-run: would remove {deleted} session snapshot backup(s), freeing {freed} bytes. Re-run with --apply to delete.",
47
47
  "cleanup.runApply": "Removed {deleted} session snapshot backup(s), freeing {freed} bytes; {kept} kept, {remaining} bytes remain.",
48
48
  "cleanup.runFailed": "Cleanup failed: {detail}.",
49
49
  "cleanup.skipped": "({skipped} active session(s) skipped.)",
50
- "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"
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"
51
56
  };
52
57
  var zh = {
53
58
  "usage.title": "\u7528\u6CD5\uFF1A",
54
59
  "usage.noArgs": " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
55
60
  "usage.seq": " /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
56
- "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",
57
62
  "describeTarget.seq": "seq {seq}",
58
63
  "describeTarget.index": "\u7B2C {index} \u6761\u6D88\u606F",
59
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",
@@ -77,7 +82,7 @@ var zh = {
77
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",
78
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",
79
84
  "cleanup.description": "\u7BA1\u7406\u4F1A\u8BDD\u5FEB\u7167\u5907\u4EFD\u7684\u81EA\u52A8\u6E05\u7406",
80
- "cleanup.inputHint": "on | off | max-age <\u5929\u6570> | run [--apply] | status",
85
+ "cleanup.inputHint": "on | off | max-age <\u5929\u6570> | run [--apply] [--current]",
81
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",
82
87
  "cleanup.enabled": "\u5DF2\u5F00\u542F",
83
88
  "cleanup.disabled": "\u5DF2\u5173\u95ED",
@@ -86,13 +91,18 @@ var zh = {
86
91
  "cleanup.onOk": "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406\u3002",
87
92
  "cleanup.offOk": "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406\u2014\u2014\u4FDD\u7559\u5168\u90E8\u5FEB\u7167\u3002",
88
93
  "cleanup.maxAgeOk": "\u5DF2\u5C06\u81EA\u52A8\u6E05\u7406\u7684\u6700\u5927\u4FDD\u7559\u5929\u6570\u8BBE\u4E3A {days} \u5929\u3002",
89
- "cleanup.cfgInvalid": "\u5FEB\u7167\u6E05\u7406\u914D\u7F6E\u65E0\u6548\uFF1A{detail}\u3002\u5DF2\u4F7F\u7528\u5B89\u5168\u9ED8\u8BA4\u503C\uFF08\u5173\u95ED\uFF09\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",
90
95
  "cleanup.saveFailed": "\u65E0\u6CD5\u4FDD\u5B58\u6E05\u7406\u914D\u7F6E\uFF1A{detail}\u3002",
91
- "cleanup.runDry": "\u9884\u6F14\uFF1A\u5C06\u5220\u9664 {deleted} \u4E2A\u4F1A\u8BDD\u7684\u5FEB\u7167\u5907\u4EFD\u3002\u52A0 --apply \u6B63\u5F0F\u5220\u9664\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",
92
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",
93
98
  "cleanup.runFailed": "\u6E05\u7406\u5931\u8D25\uFF1A{detail}\u3002",
94
99
  "cleanup.skipped": "\uFF08\u8DF3\u8FC7\u4E86 {skipped} \u4E2A\u6D3B\u52A8\u4F1A\u8BDD\u3002\uFF09",
95
- "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"
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"
96
106
  };
97
107
  var HOST_DICTS = { en, zh };
98
108
  function translate(lang, key, params = {}) {
@@ -231,8 +241,9 @@ function execSessionCwd(exec, requestedPath) {
231
241
  import { createHash } from "node:crypto";
232
242
  import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
233
243
  import { dirname, join } from "node:path";
234
- import { homedir } from "node:os";
235
- 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);
236
247
  var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
237
248
  var MAX_ANCHOR_GROUPS = 100;
238
249
  function isLinkEntry(entry) {
@@ -337,11 +348,6 @@ async function dirSizeAndLastActive(dir) {
337
348
  return { size, lastActiveMs };
338
349
  }
339
350
  var SnapshotStore = class _SnapshotStore {
340
- constructor(root = process.env[SNAPSHOT_ROOT_ENV] ?? DEFAULT_SNAPSHOT_ROOT, opts) {
341
- this.root = root;
342
- this.dedup = opts?.dedup ?? true;
343
- }
344
- root;
345
351
  /** Debounce window for the per-commit prune (keeps the readdir+sort off the hot path). */
346
352
  static PRUNE_INTERVAL_MS = 1e3;
347
353
  lastPruneAt = 0;
@@ -360,6 +366,8 @@ var SnapshotStore = class _SnapshotStore {
360
366
  lastEntryTime = 0;
361
367
  /** Store options; `dedup` toggles in-place content dedup (default on). */
362
368
  dedup;
369
+ /** Resolved checkpoint store root (absolute); see the constructor's fallback. */
370
+ root;
363
371
  /**
364
372
  * In-memory per-path "most recent entry" for content dedup, keyed by
365
373
  * `<sessionId>\0<path>`. Each value holds the entry's effective `before`
@@ -370,6 +378,10 @@ var SnapshotStore = class _SnapshotStore {
370
378
  lastEntry = /* @__PURE__ */ new Map();
371
379
  /** Sessions whose dedup state has been seeded from disk this process. */
372
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
+ }
373
385
  /** Absolute path of one session's snapshot directory (id sanitized). */
374
386
  sessionDir(sessionId) {
375
387
  return join(this.root, safeSessionId(sessionId));
@@ -760,6 +772,13 @@ var SnapshotStore = class _SnapshotStore {
760
772
  * already absent, i.e. the target state is reached), a restore is a plain
761
773
  * writeFile with a recursive mkdir of the parent. Returns how the outcome
762
774
  * should record it.
775
+ *
776
+ * This is the only place the store writes restored content to the real FS,
777
+ * and it is deliberately a raw `writeFile`/`unlink` rather than the fs
778
+ * service: the caller only ever hands it a path from `planRestore` — one the
779
+ * session's own write-class tool call recorded and resolved (never a
780
+ * symlink/hard link) and only when it differs from the live disk. So no
781
+ * arbitrary path, no model input, never automatic.
763
782
  */
764
783
  async applyActionToDisk(kind, path, content, deleteFile) {
765
784
  if (kind === "delete") {
@@ -1181,6 +1200,103 @@ var SnapshotStore = class _SnapshotStore {
1181
1200
  }
1182
1201
  return paths;
1183
1202
  }
1203
+ /**
1204
+ * Summarize a session's on-disk footprint for a clear dry-run: anchor-group
1205
+ * count, committed checkpoint-entry count, restore-journal count, and total
1206
+ * bytes. Walks with `lstat` (never follows a symlink, so a hostile symlink
1207
+ * cannot escape the store root or inflate the measurement) and skips
1208
+ * dot-prefixed temp leftovers and non-`.json` members — they are never
1209
+ * checkpoint entries.
1210
+ */
1211
+ async sessionStats(sessionId) {
1212
+ const sessionDir = this.sessionDir(sessionId);
1213
+ let names;
1214
+ try {
1215
+ names = await readdir(sessionDir);
1216
+ } catch (error) {
1217
+ if (error.code === "ENOENT") return { anchorGroups: 0, entries: 0, journals: 0, bytes: 0 };
1218
+ throw error;
1219
+ }
1220
+ let anchorGroups = 0;
1221
+ let entries = 0;
1222
+ let journals = 0;
1223
+ let bytes = 0;
1224
+ for (const name2 of names) {
1225
+ if (name2.startsWith(".")) continue;
1226
+ const full = join(sessionDir, name2);
1227
+ let st;
1228
+ try {
1229
+ st = await lstat(full);
1230
+ } catch {
1231
+ continue;
1232
+ }
1233
+ if (st.isDirectory()) {
1234
+ if (!Number.isSafeInteger(Number(name2))) continue;
1235
+ anchorGroups++;
1236
+ let files;
1237
+ try {
1238
+ files = await readdir(full);
1239
+ } catch {
1240
+ continue;
1241
+ }
1242
+ for (const file of files) {
1243
+ if (!file.endsWith(".json")) continue;
1244
+ entries++;
1245
+ const fileSt = await lstat(join(full, file)).catch(() => void 0);
1246
+ if (fileSt !== void 0) bytes += fileSt.size;
1247
+ }
1248
+ } else if (name2.startsWith(_SnapshotStore.JOURNAL_PREFIX) && name2.endsWith(".json")) {
1249
+ journals++;
1250
+ bytes += st.size;
1251
+ }
1252
+ }
1253
+ return { anchorGroups, entries, journals, bytes };
1254
+ }
1255
+ /**
1256
+ * Remove a session's ENTIRE snapshot directory — every anchor group, every
1257
+ * checkpoint entry, and every restore journal — and reset the store's
1258
+ * in-memory dedup state so the session starts recording fresh from the
1259
+ * current workspace state. This is the manual "get rid of this session's
1260
+ * records NOW" action on the ACTIVE session the user is driving (it is never
1261
+ * targetable by id; that is a directory-manipulation concern the user can do
1262
+ * directly).
1263
+ *
1264
+ * SEMANTICS — clearing is an explicit abandonment: issuing the command means
1265
+ * the user accepts that this session's snapshot archive goes away. It is
1266
+ * therefore NOT gated on the state of any restore journal. A clear and a
1267
+ * restore are both slash commands the host runs to completion for an agent,
1268
+ * so they never interleave — any non-terminal journal present on disk is a
1269
+ * stale orphan from a previous (crashed) process, and discarding it is the
1270
+ * correct, safe resolution of that abandoned restore.
1271
+ *
1272
+ * SAFETY (this module's real concern is the plugin's ongoing BEHAVIOR, not
1273
+ * losing snapshots):
1274
+ * - Only the session dir is removed; dedup refs are session-relative, so
1275
+ * there is no cross-session dangling to materialize (the same rationale as
1276
+ * {@link pruneStale}'s whole-dir removal).
1277
+ * - The in-memory dedup state (`lastEntry` / `seededSessions`) is ALWAYS
1278
+ * reset on an apply — even when the dir was already empty. A stale
1279
+ * in-memory entry (e.g. a session whose dir was removed out-of-band) would
1280
+ * otherwise link a later `recordEntry` to a deleted prior entry, leaving a
1281
+ * dangling ref that breaks restore resolution. This is the primary
1282
+ * correctness guarantee.
1283
+ *
1284
+ * `dryRun` computes the report without touching disk or memory.
1285
+ */
1286
+ async clearSession(sessionId, opts) {
1287
+ const dryRun = opts?.dryRun ?? false;
1288
+ const stats = await this.sessionStats(sessionId);
1289
+ if (!dryRun) {
1290
+ if (stats.anchorGroups > 0 || stats.journals > 0) {
1291
+ await rm(this.sessionDir(sessionId), { recursive: true, force: true });
1292
+ }
1293
+ this.seededSessions.delete(sessionId);
1294
+ for (const key of this.lastEntry.keys()) {
1295
+ if (key.startsWith(`${sessionId}\0`)) this.lastEntry.delete(key);
1296
+ }
1297
+ }
1298
+ return { sessionId, ...stats, dryRun };
1299
+ }
1184
1300
  };
1185
1301
  function hashPath(path) {
1186
1302
  return createHash("sha256").update(path).digest("hex").slice(0, 8);
@@ -1211,18 +1327,18 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = de
1211
1327
  // src/snapshot-cleanup.ts
1212
1328
  import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
1213
1329
  import { dirname as dirname2, join as join2 } from "node:path";
1214
- import { homedir as homedir2 } from "node:os";
1330
+ import { resolveDshHome as resolveDshHome2 } from "@deepseek-ai/dsh-home-paths";
1215
1331
  var CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
1216
1332
  var CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
1217
1333
  var DEFAULT_MAX_AGE_DAYS = 30;
1218
1334
  var DEFAULT_CLEANUP_CONFIG = { enabled: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS };
1219
1335
  var AUTO_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1e3;
1220
- function resolveCleanupConfigPath() {
1221
- return process.env[CLEANUP_CONFIG_ENV] ?? join2(homedir2(), ".dsh", CLEANUP_CONFIG_FILENAME);
1336
+ function resolveCleanupConfigPath(dshHome) {
1337
+ return process.env[CLEANUP_CONFIG_ENV] ?? join2(resolveDshHome2(dshHome), CLEANUP_CONFIG_FILENAME);
1222
1338
  }
1223
1339
  var STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
1224
- function resolveCleanupStatePath() {
1225
- return join2(dirname2(resolveCleanupConfigPath()), STATE_FILENAME);
1340
+ function resolveCleanupStatePath(dshHome) {
1341
+ return join2(dirname2(resolveCleanupConfigPath(dshHome)), STATE_FILENAME);
1226
1342
  }
1227
1343
  async function loadLastSweepAt(path) {
1228
1344
  try {
@@ -1319,10 +1435,21 @@ function parseCleanupCommand(rawInput) {
1319
1435
  if (!Number.isInteger(days) || days <= 0) return { error: '"max-age" must be a positive integer (days)' };
1320
1436
  return { action: "max-age", value: days };
1321
1437
  }
1438
+ case "run-apply":
1439
+ return { error: 'the "run-apply" abbreviation was removed; use "run --apply"' };
1322
1440
  case "run": {
1323
- if (parts.length === 1) return { action: "run" };
1324
- if (parts.length === 2 && parts[1] === "--apply") return { action: "run-apply" };
1325
- return { error: "usage: /snapshot-auto-cleanup run [--apply]" };
1441
+ let apply2 = false;
1442
+ let current = false;
1443
+ for (const rawFlag of parts.slice(1)) {
1444
+ if (rawFlag === "--apply") {
1445
+ apply2 = true;
1446
+ } else if (rawFlag === "--current") {
1447
+ current = true;
1448
+ } else {
1449
+ return { error: `unknown /snapshot-auto-cleanup run flag "${rawFlag}"` };
1450
+ }
1451
+ }
1452
+ return { action: "run", target: current ? "current" : "rules", apply: apply2 };
1326
1453
  }
1327
1454
  default:
1328
1455
  return { error: `unknown /snapshot-auto-cleanup subcommand "${parts[0]}"` };
@@ -1640,13 +1767,13 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
1640
1767
  return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
1641
1768
  }
1642
1769
  var autoSweepChecked = false;
1643
- async function maybeRunAutoCleanup(ctx, store, sessionId) {
1770
+ async function maybeRunAutoCleanup(ctx, store, sessionId, dshHome) {
1644
1771
  if (autoSweepChecked) return;
1645
1772
  autoSweepChecked = true;
1646
1773
  await runAutoCleanupCheck({
1647
1774
  pruner: store,
1648
- configPath: resolveCleanupConfigPath(),
1649
- statePath: resolveCleanupStatePath(),
1775
+ configPath: resolveCleanupConfigPath(dshHome),
1776
+ statePath: resolveCleanupStatePath(dshHome),
1650
1777
  log: (msg) => ctx.logger.warn(msg)
1651
1778
  }, sessionId);
1652
1779
  }
@@ -1661,10 +1788,10 @@ function formatCleanupReport(report) {
1661
1788
  return report.skippedActive > 0 ? `${text}
1662
1789
  ${t("cleanup.skipped", { skipped: report.skippedActive })}` : text;
1663
1790
  }
1664
- async function handleSnapshotCleanup(store, invocation) {
1791
+ async function handleSnapshotCleanup(store, invocation, dshHome, trackedBySession) {
1665
1792
  const parsed = parseCleanupCommand(invocation.rawInput);
1666
1793
  if ("error" in parsed) return { kind: "error", text: t("cleanup.usage") };
1667
- const configPath = resolveCleanupConfigPath();
1794
+ const configPath = resolveCleanupConfigPath(dshHome);
1668
1795
  switch (parsed.action) {
1669
1796
  case "status": {
1670
1797
  const loaded = await loadCleanupConfig(configPath);
@@ -1700,17 +1827,20 @@ async function handleSnapshotCleanup(store, invocation) {
1700
1827
  }
1701
1828
  return { kind: "success", text: t("cleanup.maxAgeOk", { days: parsed.value }) };
1702
1829
  }
1703
- case "run":
1704
- case "run-apply": {
1830
+ case "run": {
1831
+ const apply2 = parsed.apply;
1832
+ if (parsed.target === "current") {
1833
+ return handleClearCurrent(store, invocation, apply2, trackedBySession);
1834
+ }
1705
1835
  const loaded = await loadCleanupConfig(configPath);
1706
1836
  if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
1707
1837
  try {
1708
1838
  const report = await store.pruneStale({
1709
1839
  keepActiveId: invocation.agent.session.id,
1710
1840
  maxAgeDays: loaded.config.maxAgeDays,
1711
- dryRun: parsed.action === "run"
1841
+ dryRun: !apply2
1712
1842
  });
1713
- if (!report.dryRun) await saveLastSweepAt(resolveCleanupStatePath(), Date.now());
1843
+ if (!report.dryRun) await saveLastSweepAt(resolveCleanupStatePath(dshHome), Date.now());
1714
1844
  return { kind: "success", text: formatCleanupReport(report) };
1715
1845
  } catch (error) {
1716
1846
  return { kind: "error", text: t("cleanup.runFailed", { detail: error instanceof Error ? error.message : String(error) }) };
@@ -1718,8 +1848,39 @@ async function handleSnapshotCleanup(store, invocation) {
1718
1848
  }
1719
1849
  }
1720
1850
  }
1851
+ function formatClearReport(report) {
1852
+ const key = report.dryRun ? "cleanup.clearDry" : "cleanup.clearApply";
1853
+ return t(key, {
1854
+ entries: report.entries,
1855
+ bytes: report.bytes
1856
+ });
1857
+ }
1858
+ async function handleClearCurrent(store, invocation, apply2, trackedBySession) {
1859
+ const { agent } = invocation;
1860
+ const sessionId = agent.session.id;
1861
+ if (apply2) {
1862
+ if (agent.status !== "idle") {
1863
+ agent.cancel({ kind: "user" }, { keepInbox: true });
1864
+ const stopped = await waitForAgentIdle(agent, invocation.signal);
1865
+ if (!stopped) {
1866
+ return { kind: "error", text: t("cleanup.clearActive", { sessionId }) };
1867
+ }
1868
+ }
1869
+ if (invocation.signal.aborted) {
1870
+ return { kind: "error", text: t("cleanup.clearCancelled") };
1871
+ }
1872
+ }
1873
+ try {
1874
+ const report = await store.clearSession(sessionId, { dryRun: !apply2 });
1875
+ if (!report.dryRun) trackedBySession.delete(sessionId);
1876
+ return { kind: "success", text: formatClearReport(report) };
1877
+ } catch (error) {
1878
+ return { kind: "error", text: t("cleanup.clearFailed", { detail: error instanceof Error ? error.message : String(error), sessionId }) };
1879
+ }
1880
+ }
1721
1881
  function apply(ctx, config) {
1722
- const store = new SnapshotStore(config?.snapshotDir, { dedup: config?.dedup });
1882
+ const dshHome = config?.dshHome;
1883
+ const store = new SnapshotStore(config?.snapshotDir, { dedup: config?.dedup, dshHome });
1723
1884
  const pending = /* @__PURE__ */ new Map();
1724
1885
  const anchorCache = /* @__PURE__ */ new WeakMap();
1725
1886
  const inflight = /* @__PURE__ */ new Set();
@@ -1732,16 +1893,22 @@ function apply(ctx, config) {
1732
1893
  }
1733
1894
  });
1734
1895
  ctx.effect(function* () {
1896
+ const rewindHandler = (invocation) => handleRewind(ctx, store, fsService, invocation, inflight);
1735
1897
  yield ctx.commands.register({
1736
1898
  name: "rewind",
1737
1899
  description: t("command.description"),
1738
- handler: (invocation) => handleRewind(ctx, store, fsService, invocation, inflight)
1900
+ handler: rewindHandler
1901
+ });
1902
+ yield ctx.commands.register({
1903
+ name: "undo",
1904
+ description: t("command.description"),
1905
+ handler: rewindHandler
1739
1906
  });
1740
1907
  yield ctx.commands.register({
1741
1908
  name: "snapshot-auto-cleanup",
1742
1909
  description: t("cleanup.description"),
1743
1910
  input: { hint: t("cleanup.inputHint") },
1744
- handler: (invocation) => handleSnapshotCleanup(store, invocation)
1911
+ handler: (invocation) => handleSnapshotCleanup(store, invocation, dshHome, trackedBySession)
1745
1912
  });
1746
1913
  }, "dsh-rewind command");
1747
1914
  ctx.on("session/event", (session, event) => {
@@ -1751,7 +1918,7 @@ function apply(ctx, config) {
1751
1918
  void (async () => {
1752
1919
  try {
1753
1920
  const sessionId = session.id;
1754
- void maybeRunAutoCleanup(ctx, store, sessionId);
1921
+ void maybeRunAutoCleanup(ctx, store, sessionId, dshHome);
1755
1922
  let tracked = trackedBySession.get(sessionId);
1756
1923
  if (tracked === void 0) {
1757
1924
  tracked = await store.trackedPaths(sessionId);
@@ -1777,7 +1944,7 @@ function apply(ctx, config) {
1777
1944
  });
1778
1945
  scope.on("tools/post-execute", async (exec, result, next) => {
1779
1946
  try {
1780
- void maybeRunAutoCleanup(ctx, store, exec.agent?.session?.id);
1947
+ void maybeRunAutoCleanup(ctx, store, exec.agent?.session?.id, dshHome);
1781
1948
  await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
1782
1949
  } catch (error) {
1783
1950
  ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
@@ -13,6 +13,34 @@ export interface HiddenChat {
13
13
  get(key: string): ChatConversationViewNode | undefined;
14
14
  };
15
15
  }
16
+ /**
17
+ * Reader for one session's live chat snapshot. The dual channel hides the
18
+ * harness split behind SiriLee/dsh-rewind#7: rc.2 serves the chat from the
19
+ * session face snapshot, while 0.1.2-alpha.1+ serves it from the
20
+ * `uiConversation` service's named "chat" view (contributed by
21
+ * dsh-client-ui-chat through the uiSession slot hook).
22
+ */
23
+ export type ChatOf = (session: {
24
+ readonly sessionId: string;
25
+ getSnapshot(): {
26
+ chat?: unknown;
27
+ };
28
+ } | undefined) => HiddenChat | undefined;
29
+ /**
30
+ * Resolve the chat snapshot across the two harness channels: the session-face
31
+ * snapshot first (rc.2 — on alpha.1+ the face no longer carries `chat`, so the
32
+ * field reads `undefined`), then the `uiConversation` "chat" view. The view's
33
+ * `getSnapshot()` returns undefined until the named view is registered, so
34
+ * both channels missing degrades to `undefined` (no targets, no hiding —
35
+ * never a crash).
36
+ */
37
+ export declare function chatSnapshotOf(face: {
38
+ getSnapshot(): {
39
+ chat?: unknown;
40
+ };
41
+ } | undefined, chatView: {
42
+ getSnapshot(): unknown;
43
+ } | undefined): HiddenChat | undefined;
16
44
  /**
17
45
  * Extract the rewind target seq from a `/rewind` command's structured `args`
18
46
  * (e.g. `@5 chat`, `preview @5 both`). Locale-independent — never parses the
@@ -1,7 +1,7 @@
1
1
  /**
2
- * dsh-rewind client half: the `/rewind` command decoration, the
3
- * parameterized-input guard, the locale registration, and the session-scoped
4
- * portal bridge that renders the per-message ↶ rewind button (see
2
+ * dsh-rewind client half: the `/rewind` command decoration, the locale
3
+ * registration, and the session-scoped portal bridge that renders the
4
+ * per-message ↶ rewind button (see
5
5
  * `portals.tsx` for the button itself).
6
6
  *
7
7
  * The button is NOT injected by hand into the DOM anymore: the plugin
@@ -14,15 +14,15 @@
14
14
  * drift.
15
15
  *
16
16
  * The text-driven flow is the harness's STANDARD command decoration
17
- * (`ctx.commandUi.decorate`): a bare `/rewind` picked from the slash-menu
18
- * completion, or typed in full and Entered — opens the harness's own
19
- * popupSelect shell (search, ↑↓/Enter, Esc) listing the rewind candidates
20
- * instead of executing the command. Picking one continues the SAME flow as
21
- * the ↶ button: the mode popover, both-impact confirmation, execution, row
22
- * hiding and the composer refill (`runRewindAndFill`). The parameterized
23
- * forms (`/rewind @<seq> chat|both`, `/rewind preview …`) stay internal
24
- * channels the ↶ button and the popover drive through `session.command`; a
25
- * hand-typed parameterized line is stopped with a hint.
17
+ * (`ctx.commandUi.decorate`): a bare `/rewind` (or its alias `/undo`)
18
+ * picked from the slash-menu completion, or typed in full and Entered —
19
+ * opens the harness's own popupSelect shell (search, ↑↓/Enter, Esc) listing
20
+ * the rewind candidates instead of executing the command. Picking one
21
+ * continues the SAME flow as the ↶ button: the mode popover, both-impact
22
+ * confirmation, execution, row hiding and the composer refill
23
+ * (`runRewindAndFill`). The parameterized forms (`/rewind @<seq> chat|both`,
24
+ * `/rewind preview …`) stay internal channels the ↶ button and the popover
25
+ * drive through `session.command`.
26
26
  *
27
27
  * @module dsh-rewind/client
28
28
  */
@@ -25,7 +25,6 @@ export declare const zh: {
25
25
  'popover.impact.delete': string;
26
26
  'popover.confirm': string;
27
27
  'popover.back': string;
28
- 'guard.hint': string;
29
28
  };
30
29
  /** The rewind namespace key union. */
31
30
  export type RewindKey = keyof typeof zh;
@@ -61,5 +60,4 @@ export declare const en: {
61
60
  'popover.impact.delete': string;
62
61
  'popover.confirm': string;
63
62
  'popover.back': string;
64
- 'guard.hint': string;
65
63
  };
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
17
17
  import type { CommandNode } from '@deepseek-ai/dsh-client-runtime/client';
18
+ import { type ChatOf } from './hidden.ts';
18
19
  import type { RewindKey } from './locales.ts';
19
20
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
20
21
  export interface PopoverOptions {
@@ -31,6 +32,12 @@ export interface PopoverOptions {
31
32
  /** Pending variant: executed after the retract confirm closes the popover. */
32
33
  readonly onRetract?: () => void;
33
34
  readonly preview: string;
35
+ /**
36
+ * Dual-channel chat reader (rc.2 session face / alpha.1+ uiConversation
37
+ * view): the durable variant's command probes scan the chat through it.
38
+ * Unused by the pending-retract variant.
39
+ */
40
+ readonly chatOf: ChatOf;
34
41
  /** The button that opened the popover (outside-click ignore target). */
35
42
  readonly anchor: HTMLElement;
36
43
  readonly t: Translate;
@@ -50,13 +57,13 @@ export declare function closePopover(): void;
50
57
  * command's stale outcome (e.g. an older preview that found file changes,
51
58
  * after those changes were already restored).
52
59
  */
53
- export declare function knownCommandSeqs(session: SessionFace, match: (node: CommandNode) => boolean): Set<number>;
60
+ export declare function knownCommandSeqs(session: SessionFace, chatOf: ChatOf, match: (node: CommandNode) => boolean): Set<number>;
54
61
  /**
55
62
  * Resolve the outcome of the newest matching rewind command by watching the
56
63
  * session snapshot (command/run + command/done land as one CommandNode).
57
64
  * @returns the outcome text-bearing node, or null on timeout.
58
65
  */
59
- export declare function waitForCommand(session: SessionFace, match: (node: CommandNode) => boolean, timeoutMs?: number): Promise<{
66
+ export declare function waitForCommand(session: SessionFace, chatOf: ChatOf, match: (node: CommandNode) => boolean, timeoutMs?: number): Promise<{
60
67
  kind: 'success' | 'error';
61
68
  text?: string;
62
69
  } | null>;
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { type ReactNode } from 'react';
26
26
  import type { SessionFace } from '@deepseek-ai/dsh-client-runtime/client';
27
+ import { type ChatOf, type HiddenChat } from './hidden.ts';
27
28
  import type { RewindKey } from './locales.ts';
28
29
  type Translate = (key: RewindKey, params?: Record<string, unknown>) => string;
29
30
  /** One portal target: the actions row of a user/steering seat + its durable node. */
@@ -51,6 +52,12 @@ export type PortalTarget = {
51
52
  /** Capabilities the session-scoped bridge receives from the plugin apply(). */
52
53
  export interface RewindBridgeDeps {
53
54
  readonly sessionOf: (sessionId: string) => SessionFace | undefined;
55
+ /**
56
+ * Dual-channel chat reader (rc.2 session face / alpha.1+ uiConversation
57
+ * "chat" view): every chat snapshot read goes through it. See
58
+ * `chatSnapshotOf` in hidden.ts for the channel precedence.
59
+ */
60
+ readonly chatOf: ChatOf;
54
61
  readonly currentSessionId: () => string | undefined;
55
62
  readonly t: Translate;
56
63
  readonly subscribeLocale: (cb: () => void) => () => void;
@@ -87,7 +94,13 @@ export declare function fillComposer(text: string): boolean;
87
94
  * the old baseline heuristic refilled withdrawn text into the composer
88
95
  * after switching sessions or restarting dsh.
89
96
  */
90
- export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', currentSessionId: () => string | undefined): Promise<void>;
97
+ export declare function runRewindAndFill(session: SessionFace, seq: number, mode: 'chat' | 'both', currentSessionId: () => string | undefined, chatOf: ChatOf): Promise<void>;
98
+ /**
99
+ * Collect the portal targets of one session: user rows × snapshot nodes.
100
+ * Exported as a test seam — the DOM→targets pairing that drives the ↶ button
101
+ * is otherwise only reachable through a full React portal render.
102
+ */
103
+ export declare function collectTargets(chat: HiddenChat, hiddenSeqs: ReadonlySet<number>): readonly PortalTarget[];
91
104
  interface RewindPortalsProps extends RewindBridgeDeps {
92
105
  readonly sessionId: string;
93
106
  }
@@ -98,7 +111,7 @@ interface RewindPortalsProps extends RewindBridgeDeps {
98
111
  * skipped when the target set is unchanged), so the plugin never runs a
99
112
  * synchronous full-transcript scan inside a commit microtask.
100
113
  */
101
- export declare function RewindPortals({ sessionId, sessionOf, currentSessionId, t, subscribeLocale }: RewindPortalsProps): ReactNode;
114
+ export declare function RewindPortals({ sessionId, sessionOf, chatOf, currentSessionId, t, subscribeLocale }: RewindPortalsProps): ReactNode;
102
115
  /**
103
116
  * Build the slot-entry component for the plugin apply(): a tiny bridge that
104
117
  * injects the apply-time capabilities (session resolution, locale, rewind
@@ -29,10 +29,12 @@ export { SnapshotStore } from './snapshot.ts';
29
29
  export type { CheckpointEntry, FileImpact, PruneStaleReport, RestoreOutcome, RestoreJournal, RestoreJournalState, RestoreReconcileReport } from './snapshot.ts';
30
30
  export declare const name = "dsh-rewind";
31
31
  export declare const inject: string[];
32
- /** Plugin config: optional override of the checkpoint store root. */
32
+ /** Plugin config. */
33
33
  export interface RewindConfig {
34
- /** Checkpoint store root (defaults to `~/.dsh/rewind-snapshots`). */
34
+ /** Checkpoint store root (exact path; beats `DSH_REWIND_SNAPSHOT_DIR` and the harness-home default). */
35
35
  readonly snapshotDir?: string;
36
+ /** Harness home override (`config.dshHome` > `$DSH_HOME` > `~/.dsh`); feeds the default snapshot/cleanup paths. */
37
+ readonly dshHome?: string;
36
38
  /** In-place content dedup (identical before-content → link). Default `true`. */
37
39
  readonly dedup?: boolean;
38
40
  }
@@ -55,6 +57,7 @@ export interface RewindConfig {
55
57
  * disk at `tools/post-execute` under the turn's anchor message seq.
56
58
  *
57
59
  * @param ctx - context carrying `commands`, `tools`, and an optional `fs`.
58
- * @param config - optional override of the checkpoint store root.
60
+ * @param config - optional plugin config: `snapshotDir` (exact store-root override),
61
+ * `dshHome` (harness-home override feeding the default paths), `dedup`.
59
62
  */
60
63
  export declare function apply(ctx: Context, config?: RewindConfig): void;
@@ -65,6 +65,11 @@ export declare const en: {
65
65
  'cleanup.runApply': string;
66
66
  'cleanup.runFailed': string;
67
67
  'cleanup.skipped': string;
68
+ 'cleanup.clearDry': string;
69
+ 'cleanup.clearApply': string;
70
+ 'cleanup.clearActive': string;
71
+ 'cleanup.clearCancelled': string;
72
+ 'cleanup.clearFailed': string;
68
73
  'cleanup.usage': string;
69
74
  };
70
75
  /** The host rewind dictionary key union. */