dsh-rewind-plugin 0.5.0 → 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.
@@ -1,42 +1,57 @@
1
- # 快照自动清理
1
+ # 快照清理
2
2
 
3
- 回退插件为每次被跟踪的文件修改写入一份磁盘 `before` 备份,并按其锚点消息分组。快照在**同一会话**内去重、且保留最近 100 组锚点,但跨**已不再活跃的会话**时存储仍会无界增长。`snapshot-auto-cleanup` 是**可选**的全局策略(默认关闭):把**长期不活跃**(失活超过可配置阈值)的会话快照**整目录**移除。
3
+ 插件会在每次修改文件前保存一份备份,方便你把代码回退到之前的状态。这些备份叫**快照**,按消息分组、按会话存放。
4
4
 
5
- 它只会移除**长期不活跃**会话的整个快照目录,绝不触碰活动会话的快照、绝不触碰对话日志,也保留仍在阈值内的快照数据。
5
+ `/snapshot-auto-cleanup` 帮你管理这些快照——既可以回收旧会话占用的磁盘,也可以清空**当前**会话、让它从"现在"重新开始记录回退历史。
6
+
7
+ ## 它能做什么
8
+
9
+ **自动清理**(默认关闭):插件会记住哪些会话你已不再使用,并定期移除它们的快照以节省磁盘。它**绝不**触碰你当前活跃的会话,也**绝不**触碰你的对话。
10
+
11
+ **手动:**即使自动清理是关闭的,你也可以自己执行清理:
12
+
13
+ - `/snapshot-auto-cleanup run [--apply]` — 先预演,再真正移除你已有一阵子没用的会话的快照。
14
+ - `/snapshot-auto-cleanup run --current [--apply]` — 先预演,再真正清空**当前**会话的快照。这会让它从"现在"重新开始记录回退历史(你的对话不受影响)。若此刻有正在进行中的回合,插件会先暂停它,再清空。
6
15
 
7
16
  ## 命令
8
17
 
9
18
  ```
10
- /snapshot-auto-cleanup 查看状态(是否开启、max-age、配置路径)
11
- /snapshot-auto-cleanup on|off 开启/关闭自动清理
12
- /snapshot-auto-cleanup max-age <天数> 设置失活阈值(正整数)
13
- /snapshot-auto-cleanup run 预演:列出将移除哪些会话
14
- /snapshot-auto-cleanup run --apply 真正移除这些会话
19
+ /snapshot-auto-cleanup 查看当前设置
20
+ /snapshot-auto-cleanup on|off 开启/关闭自动清理
21
+ /snapshot-auto-cleanup max-age <天数> 会话失活多少天后移除其快照
22
+ /snapshot-auto-cleanup run 预演:看自动清理会移除什么
23
+ /snapshot-auto-cleanup run --apply 真正移除这些快照
24
+ /snapshot-auto-cleanup run --current 预演:看清空本会话快照会怎样
25
+ /snapshot-auto-cleanup run --current --apply 真正清空本会话快照
15
26
  ```
16
27
 
17
- `run` 是手动逃生舱,无论自动清理是否为 `on` 都能用;默认只预演,加 `--apply` 才执行。
28
+ `run` 一开始总是预演;加 `--apply` 才真正执行。`run` 无论自动清理是否开启都能用。
18
29
 
19
- ## 配置文件
30
+ ## 设置
20
31
 
21
- 策略持久化到 `~/.dsh/snapshot-cleanup.json`:
32
+ 设置保存在 `<dsh home>/snapshot-cleanup.json`:
22
33
 
23
34
  ```json
24
35
  { "enabled": false, "maxAgeDays": 30 }
25
36
  ```
26
37
 
27
38
  - `enabled` — 是否运行自动清理(默认 `false`)。
28
- - `maxAgeDays` — 失活多少天后移除某**长期不活跃**会话的快照目录(默认 `30`;`0`/负数会被拒绝,因此损坏的配置不可能导致"删除一切")。
39
+ - `maxAgeDays` — 会话失活多少天后移除其快照(默认 `30`)。只接受正数,因此一个损坏的设置永远不可能"删掉一切"
40
+
41
+ 你可以用环境变量 `DSH_SNAPSHOT_CLEANUP_CONFIG` 指向别的文件。该文件只在运行 `/snapshot-auto-cleanup` 时写入;文件缺失按安全默认值(关闭)处理。
42
+
43
+ ## 自动清理何时运行
44
+
45
+ 自动清理每次运行(宿主启动)**最多检查一次**,发生在首次活动时(你发出一条消息或某次工具调用完成),且仅在**已开启**且距上次检查**已满 24 小时**时才执行。这个 24 小时时钟会写入 `<dsh home>/snapshot-cleanup-last-sweep.json`,重启不会重置。它后台运行,从不阻塞你在做的事。
29
46
 
30
- 可用环境变量 `DSH_SNAPSHOT_CLEANUP_CONFIG` 覆盖路径。该文件只由 `/snapshot-auto-cleanup` 命令写入。文件缺失按安全默认(关闭)处理;缺失或损坏会令一次清扫**fail-closed**(不删任何东西)并写入警告,再次运行命令时会有所提示。
47
+ 想让改动立即生效,请用 `run`;自动清理要到下次运行才会采用新设置。
31
48
 
32
- ## 运行时机
49
+ ## 安全
33
50
 
34
- 24 小时窗口锚定在**持久化**的上次清扫时间(`~/.dsh/snapshot-cleanup-last-sweep.json`)上,因此宿主重启不会重置:自动清扫在**每次运行只检查一次**,于某个窗口的首次会话活动(一条用户消息或一次工具调用完成)时执行,且仅在**已启用**且距上次清扫 **>=24 小时**时才清理。它后台运行,从不阻塞触发它的活动。由于每次运行只检查一次,想让改动立即生效最好用 `/snapshot-auto-cleanup run`;手动编辑配置文件(或在本次运行的首次活动之后才开启)会到下次运行才生效。
51
+ - 只会移除回退用的**快照**(那份文件备份)。你的对话绝不会被动到,插件也绝不改写或删除你的会话历史。
52
+ - 自动清理**永不**移除你**当前活跃**会话的快照——只清理已失活超过阈值的会话。
53
+ - `run --current` 会清空当前会话的快照。这对该会话的文件回退历史是**不可逆**的:你无法回退到清空之前的代码状态,但你的对话保持完整,会话会从"现在"开始重新记录快照。
35
54
 
36
- ## 安全与边界
55
+ ## 已知限制
37
56
 
38
- - 只移除**长期不活跃**会话的整目录;活动会话与对话日志永不触碰。
39
- - "不活跃"以 mtime 判定:仍在写入的会话会不断把"最新成员"时间戳往后推,因此永远到不了可被清理的失活阈值。
40
- - 去重 `ref` 链接是会话内相对的,所以移除整目录不会让别处产生悬空链接。
41
- - 权衡:开启自动清理后,某个长时间闲置才被重新打开的会话,只能从剩余(最近 100 组)锚点回退,旧快照已不存在;对话日志永不受影响。
42
- - 手动删除整个存储目录依然安全(下次捕获时自动重建);自动清理只是把这种删除限定到长期不活跃的会话。
57
+ 插件每个会话只保留**最近 100 条消息**的快照。若你在一个很长的会话里多次回退或压缩,这 100 个名额常会被"已经无法再回退到"的消息占满,你可能无法回退到想要的位置。(Claude Code 也是同样行为。)想恢复到一个干净状态,运行 `/snapshot-auto-cleanup run --current --apply` 清空当前会话、重新开始。
package/lib/client.js CHANGED
@@ -986,8 +986,7 @@ var zh = {
986
986
  "popover.impact.restore": "\u8FD8\u539F {path}",
987
987
  "popover.impact.delete": "\u5220\u9664 {path}",
988
988
  "popover.confirm": "\u786E\u8BA4\u56DE\u9000",
989
- "popover.back": "\u8FD4\u56DE",
990
- "guard.hint": "/rewind \u624B\u52A8\u8F93\u5165\u4E0D\u63A5\u53D7\u53C2\u6570\uFF0C\u8BF7\u76F4\u63A5\u8F93\u5165 /rewind \u6253\u5F00\u56DE\u9000\u83DC\u5355"
989
+ "popover.back": "\u8FD4\u56DE"
991
990
  };
992
991
  var en = {
993
992
  "button.aria": "Rewind to this message",
@@ -1013,8 +1012,7 @@ var en = {
1013
1012
  "popover.impact.restore": "Restore {path}",
1014
1013
  "popover.impact.delete": "Delete {path}",
1015
1014
  "popover.confirm": "Confirm rewind",
1016
- "popover.back": "Back",
1017
- "guard.hint": "/rewind takes no typed arguments \u2014 enter /rewind to open the rewind picker."
1015
+ "popover.back": "Back"
1018
1016
  };
1019
1017
 
1020
1018
  // src/client/index.ts
@@ -1069,8 +1067,7 @@ function apply(ctx) {
1069
1067
  const card = textarea?.closest("[data-composer-card]");
1070
1068
  return card ?? textarea ?? document.body;
1071
1069
  };
1072
- yield commandUi.decorate({
1073
- name: "rewind",
1070
+ const rewindPopupSpec = {
1074
1071
  // The picker exists exactly while the surface has a reachable user
1075
1072
  // message: a fresh session (no candidates) falls through to the host
1076
1073
  // command, which fails with "no user messages" — matching the harness's
@@ -1105,66 +1102,12 @@ function apply(ctx) {
1105
1102
  });
1106
1103
  }
1107
1104
  }
1108
- });
1109
- const PARAM_REWIND = /^\s*\/rewind\s+\S+/i;
1110
- const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
1111
- const hasParamRewindDraft = () => {
1112
- const textarea = composerTextarea();
1113
- return textarea !== null && PARAM_REWIND.test(textarea.value);
1114
- };
1115
- let guardHintEl = null;
1116
- let guardHintTimer;
1117
- const showGuardHint = () => {
1118
- if (guardHintEl !== null) guardHintEl.remove();
1119
- if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
1120
- const textarea = composerTextarea();
1121
- if (textarea === null) return;
1122
- const card = textarea.closest("[data-composer-card]");
1123
- const hint = document.createElement("div");
1124
- hint.className = "dsh-rewind-guard-hint";
1125
- hint.setAttribute("role", "status");
1126
- hint.textContent = t("guard.hint");
1127
- document.body.appendChild(hint);
1128
- const rect = card instanceof HTMLElement ? card.getBoundingClientRect() : textarea.getBoundingClientRect();
1129
- hint.style.left = `${Math.round(rect.left)}px`;
1130
- hint.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`;
1131
- guardHintEl = hint;
1132
- guardHintTimer = window.setTimeout(() => {
1133
- hint.remove();
1134
- if (guardHintEl === hint) guardHintEl = null;
1135
- guardHintTimer = void 0;
1136
- }, 3200);
1137
- };
1138
- const onParamRewindSubmit = (event) => {
1139
- if (!hasParamRewindDraft()) return;
1140
- event.preventDefault();
1141
- event.stopPropagation();
1142
- showGuardHint();
1143
- };
1144
- const onKeyDownGuard = (event) => {
1145
- if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
1146
- onParamRewindSubmit(event);
1147
1105
  };
1148
- const onClickGuard = (event) => {
1149
- if (event.button !== 0 || !hasParamRewindDraft()) return;
1150
- const target = event.target;
1151
- if (!(target instanceof Element)) return;
1152
- const button = target.closest("button");
1153
- if (button === null) return;
1154
- const card = button.closest("[data-composer-card]");
1155
- if (card === null) return;
1156
- const all = card.querySelectorAll("button");
1157
- if (all[all.length - 1] !== button) return;
1158
- if (button.querySelector("rect") !== null) return;
1159
- onParamRewindSubmit(event);
1160
- };
1161
- document.addEventListener("keydown", onKeyDownGuard, true);
1162
- document.addEventListener("click", onClickGuard, true);
1106
+ for (const name2 of ["rewind", "undo"]) {
1107
+ yield commandUi.decorate({ name: name2, ...rewindPopupSpec });
1108
+ }
1109
+ const composerTextarea = () => document.querySelector(COMPOSER_SELECTOR2);
1163
1110
  yield () => {
1164
- document.removeEventListener("keydown", onKeyDownGuard, true);
1165
- document.removeEventListener("click", onClickGuard, true);
1166
- if (guardHintEl !== null) guardHintEl.remove();
1167
- if (guardHintTimer !== void 0) window.clearTimeout(guardHintTimer);
1168
1111
  style.remove();
1169
1112
  };
1170
1113
  }, "dsh-rewind client lifecycle");
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));
@@ -1181,6 +1193,103 @@ var SnapshotStore = class _SnapshotStore {
1181
1193
  }
1182
1194
  return paths;
1183
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
+ }
1184
1293
  };
1185
1294
  function hashPath(path) {
1186
1295
  return createHash("sha256").update(path).digest("hex").slice(0, 8);
@@ -1211,18 +1320,18 @@ async function reconcileTracked(store, sessionId, anchorSeq, tracked, probe = de
1211
1320
  // src/snapshot-cleanup.ts
1212
1321
  import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
1213
1322
  import { dirname as dirname2, join as join2 } from "node:path";
1214
- import { homedir as homedir2 } from "node:os";
1323
+ import { resolveDshHome as resolveDshHome2 } from "@deepseek-ai/dsh-home-paths";
1215
1324
  var CLEANUP_CONFIG_FILENAME = "snapshot-cleanup.json";
1216
1325
  var CLEANUP_CONFIG_ENV = "DSH_SNAPSHOT_CLEANUP_CONFIG";
1217
1326
  var DEFAULT_MAX_AGE_DAYS = 30;
1218
1327
  var DEFAULT_CLEANUP_CONFIG = { enabled: false, maxAgeDays: DEFAULT_MAX_AGE_DAYS };
1219
1328
  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);
1329
+ function resolveCleanupConfigPath(dshHome) {
1330
+ return process.env[CLEANUP_CONFIG_ENV] ?? join2(resolveDshHome2(dshHome), CLEANUP_CONFIG_FILENAME);
1222
1331
  }
1223
1332
  var STATE_FILENAME = "snapshot-cleanup-last-sweep.json";
1224
- function resolveCleanupStatePath() {
1225
- return join2(dirname2(resolveCleanupConfigPath()), STATE_FILENAME);
1333
+ function resolveCleanupStatePath(dshHome) {
1334
+ return join2(dirname2(resolveCleanupConfigPath(dshHome)), STATE_FILENAME);
1226
1335
  }
1227
1336
  async function loadLastSweepAt(path) {
1228
1337
  try {
@@ -1319,10 +1428,21 @@ function parseCleanupCommand(rawInput) {
1319
1428
  if (!Number.isInteger(days) || days <= 0) return { error: '"max-age" must be a positive integer (days)' };
1320
1429
  return { action: "max-age", value: days };
1321
1430
  }
1431
+ case "run-apply":
1432
+ return { error: 'the "run-apply" abbreviation was removed; use "run --apply"' };
1322
1433
  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]" };
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 };
1326
1446
  }
1327
1447
  default:
1328
1448
  return { error: `unknown /snapshot-auto-cleanup subcommand "${parts[0]}"` };
@@ -1640,13 +1760,13 @@ async function handleRewind(ctx, store, fs, invocation, inflight) {
1640
1760
  return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
1641
1761
  }
1642
1762
  var autoSweepChecked = false;
1643
- async function maybeRunAutoCleanup(ctx, store, sessionId) {
1763
+ async function maybeRunAutoCleanup(ctx, store, sessionId, dshHome) {
1644
1764
  if (autoSweepChecked) return;
1645
1765
  autoSweepChecked = true;
1646
1766
  await runAutoCleanupCheck({
1647
1767
  pruner: store,
1648
- configPath: resolveCleanupConfigPath(),
1649
- statePath: resolveCleanupStatePath(),
1768
+ configPath: resolveCleanupConfigPath(dshHome),
1769
+ statePath: resolveCleanupStatePath(dshHome),
1650
1770
  log: (msg) => ctx.logger.warn(msg)
1651
1771
  }, sessionId);
1652
1772
  }
@@ -1661,10 +1781,10 @@ function formatCleanupReport(report) {
1661
1781
  return report.skippedActive > 0 ? `${text}
1662
1782
  ${t("cleanup.skipped", { skipped: report.skippedActive })}` : text;
1663
1783
  }
1664
- async function handleSnapshotCleanup(store, invocation) {
1784
+ async function handleSnapshotCleanup(store, invocation, dshHome, trackedBySession) {
1665
1785
  const parsed = parseCleanupCommand(invocation.rawInput);
1666
1786
  if ("error" in parsed) return { kind: "error", text: t("cleanup.usage") };
1667
- const configPath = resolveCleanupConfigPath();
1787
+ const configPath = resolveCleanupConfigPath(dshHome);
1668
1788
  switch (parsed.action) {
1669
1789
  case "status": {
1670
1790
  const loaded = await loadCleanupConfig(configPath);
@@ -1700,17 +1820,20 @@ async function handleSnapshotCleanup(store, invocation) {
1700
1820
  }
1701
1821
  return { kind: "success", text: t("cleanup.maxAgeOk", { days: parsed.value }) };
1702
1822
  }
1703
- case "run":
1704
- case "run-apply": {
1823
+ case "run": {
1824
+ const apply2 = parsed.apply;
1825
+ if (parsed.target === "current") {
1826
+ return handleClearCurrent(store, invocation, apply2, trackedBySession);
1827
+ }
1705
1828
  const loaded = await loadCleanupConfig(configPath);
1706
1829
  if (!loaded.ok) return { kind: "error", text: t("cleanup.cfgInvalid", { detail: loaded.error }) };
1707
1830
  try {
1708
1831
  const report = await store.pruneStale({
1709
1832
  keepActiveId: invocation.agent.session.id,
1710
1833
  maxAgeDays: loaded.config.maxAgeDays,
1711
- dryRun: parsed.action === "run"
1834
+ dryRun: !apply2
1712
1835
  });
1713
- if (!report.dryRun) await saveLastSweepAt(resolveCleanupStatePath(), Date.now());
1836
+ if (!report.dryRun) await saveLastSweepAt(resolveCleanupStatePath(dshHome), Date.now());
1714
1837
  return { kind: "success", text: formatCleanupReport(report) };
1715
1838
  } catch (error) {
1716
1839
  return { kind: "error", text: t("cleanup.runFailed", { detail: error instanceof Error ? error.message : String(error) }) };
@@ -1718,8 +1841,39 @@ async function handleSnapshotCleanup(store, invocation) {
1718
1841
  }
1719
1842
  }
1720
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
+ }
1721
1874
  function apply(ctx, config) {
1722
- const store = new SnapshotStore(config?.snapshotDir, { dedup: config?.dedup });
1875
+ const dshHome = config?.dshHome;
1876
+ const store = new SnapshotStore(config?.snapshotDir, { dedup: config?.dedup, dshHome });
1723
1877
  const pending = /* @__PURE__ */ new Map();
1724
1878
  const anchorCache = /* @__PURE__ */ new WeakMap();
1725
1879
  const inflight = /* @__PURE__ */ new Set();
@@ -1732,16 +1886,22 @@ function apply(ctx, config) {
1732
1886
  }
1733
1887
  });
1734
1888
  ctx.effect(function* () {
1889
+ const rewindHandler = (invocation) => handleRewind(ctx, store, fsService, invocation, inflight);
1735
1890
  yield ctx.commands.register({
1736
1891
  name: "rewind",
1737
1892
  description: t("command.description"),
1738
- 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
1739
1899
  });
1740
1900
  yield ctx.commands.register({
1741
1901
  name: "snapshot-auto-cleanup",
1742
1902
  description: t("cleanup.description"),
1743
1903
  input: { hint: t("cleanup.inputHint") },
1744
- handler: (invocation) => handleSnapshotCleanup(store, invocation)
1904
+ handler: (invocation) => handleSnapshotCleanup(store, invocation, dshHome, trackedBySession)
1745
1905
  });
1746
1906
  }, "dsh-rewind command");
1747
1907
  ctx.on("session/event", (session, event) => {
@@ -1751,7 +1911,7 @@ function apply(ctx, config) {
1751
1911
  void (async () => {
1752
1912
  try {
1753
1913
  const sessionId = session.id;
1754
- void maybeRunAutoCleanup(ctx, store, sessionId);
1914
+ void maybeRunAutoCleanup(ctx, store, sessionId, dshHome);
1755
1915
  let tracked = trackedBySession.get(sessionId);
1756
1916
  if (tracked === void 0) {
1757
1917
  tracked = await store.trackedPaths(sessionId);
@@ -1777,7 +1937,7 @@ function apply(ctx, config) {
1777
1937
  });
1778
1938
  scope.on("tools/post-execute", async (exec, result, next) => {
1779
1939
  try {
1780
- void maybeRunAutoCleanup(ctx, store, exec.agent?.session?.id);
1940
+ void maybeRunAutoCleanup(ctx, store, exec.agent?.session?.id, dshHome);
1781
1941
  await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
1782
1942
  } catch (error) {
1783
1943
  ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
@@ -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
  };