dsh-rewind-plugin 0.3.0 → 0.3.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/README.md CHANGED
@@ -53,6 +53,8 @@ For contributors: install from a local checkout or a pinned commit — `dsh plug
53
53
 
54
54
  **Command-line entry**: type a bare `/rewind` and press Enter to open the candidate picker; selecting a target continues the same flow as the button.
55
55
 
56
+ Both the candidate picker and the mode popover support the keyboard: ↑↓ to move, Enter to confirm, Esc to cancel/back.
57
+
56
58
  Rewinds can be repeated (each appends a marker to the log). A rewind cannot be undone through the plugin, but the withdrawn messages can be recovered by manually editing the session log. The file-restore action is not re-backed up.
57
59
 
58
60
  ## How it works
@@ -74,17 +76,16 @@ The plugin tracks the write-class tools — `write`, `edit`, `str_replace_editor
74
76
 
75
77
  1. **Before-capture** at `tools/execute` (the around-dispatch stage): the target file is read; the resolved path + content are held in a pending map. This stage runs only after any pre-execute approval gate let the call through — an `ask` short-circuit (dsh-edit-approval) **cannot skip** the backup, and a denied call never records. If the read fails (e.g. a permission error), the change is simply not backed up — the plugin warns in the log but **does not block the write**.
76
78
  2. **Disk commit** at `tools/post-execute`: the before-backup is written under the turn's anchor message seq (`~/.dsh/rewind-snapshots/<session>/<anchor seq>/<callId>.json`).
77
- 3. **Restore** (`/rewind @<seq> both`): every backup anchored at or after the target applies — modified files are written back to their **earliest** captured before-state, files created after the target are deleted, symbolic / hard links are skipped (they share an inode with another name; restoring through one would clobber both). Writes go through plain `node:fs`, independent of the fs service — under sandbox / remote backends, path resolution may be restricted.
79
+ 3. **Restore** (`/rewind @<seq> both`): every backup anchored at or after the target applies once reconciled with the current disk — modified files are written back to their **earliest** captured before-state, files created after the target are deleted, files already matching the target state are left untouched (idempotent). Symbolic / hard links are skipped (they share an inode with another name; restoring through one would clobber both). Writes go through plain `node:fs`, independent of the fs service — under sandbox / remote backends, path resolution may be restricted.
78
80
  4. A tool body that **throws** skips `tools/post-execute`; a `tools/result` safety net clears the pending capture so nothing leaks in memory.
79
81
 
80
82
  Backups persist across host restarts, bounded to the newest 100 anchor groups per session.
81
83
 
82
84
  ## What it deliberately does NOT do
83
85
 
84
- - **Whole-tree / git-first snapshots** — only write-class tool edits are backed up. `bash`, other tools, and external edits are not tracked and cannot be restored: the same limitation as Claude Code, which defers such rollbacks to the user's git.
86
+ - **Whole-tree / git-first snapshots** — only write-class tool edits plus external changes to already-tracked files are backed up; files never touched by a tool are not restored: the same limitation as Claude Code, which defers such rollbacks to the user's git.
85
87
  - **Subagent edits** — not tracked (same as Claude Code): a subagent runs its own session, so its backups could never be restored by a rewind of the parent session.
86
88
  - **Fork / branch rewind and `/compact`** — the harness already provides these ("branch in new chat", compact).
87
- - **Keyboard shortcuts** (esc+esc rewind menu) — planned as a follow-up.
88
89
 
89
90
  ## Comparison with similar projects
90
91
 
@@ -116,7 +117,7 @@ Full instructions: [docs/troubleshooting.md](docs/troubleshooting.md)
116
117
 
117
118
  ## Security
118
119
 
119
- This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history. File writes happen only when you choose "conversation and code" — before-backups and restores stay under `~/.dsh/rewind-snapshots/`. It never touches your git repository, makes no network requests, and accesses no credentials.
120
+ This plugin only appends rewind-marker events to the session log; it never deletes or rewrites logged history. Workspace files are written only when you choose "conversation and code"; backups and restores stay under `~/.dsh/rewind-snapshots/`. It never touches your git repository, makes no network requests, and accesses no credentials.
120
121
 
121
122
  > **Note:** a rewind only hides messages from view — the exported session log (`/export`) still contains them, and this plugin cannot alter exports. To remove a conversation completely, delete its session file.
122
123
 
@@ -127,7 +128,7 @@ npm install # devDeps from the npm registry
127
128
  npm run typecheck # tsc on both compilation surfaces (host + client)
128
129
  npm test # vitest: rewind / snapshot / hidden / session-cwd / integration
129
130
  npm run build # esbuild: lib/index.js (host ESM) + lib/client.js (loader closure) + .d.ts
130
- node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end (18 checks)
131
+ node scripts/verify-host.mjs # boot the BUILT host artifact end-to-end
131
132
  ```
132
133
 
133
134
  `prepare` runs the full build, so git installs and `npm pack` / `npm publish` always produce a complete `lib/` and the `LICENSE`.
package/README.zh.md CHANGED
@@ -53,6 +53,8 @@ dsh plugin --profile web add dsh-rewind-plugin
53
53
 
54
54
  **命令行入口**:输入裸 `/rewind` 回车打开候选面板,选择目标后流程与按钮一致。
55
55
 
56
+ 候选面板与模式弹层均支持键盘操作:↑↓ 移动、Enter 确认、Esc 取消/返回。
57
+
56
58
  回退可重复进行(每次追加一条标记到日志)。回退无法通过插件撤销,但可以手动编辑会话日志恢复。文件还原动作不再记录新备份。
57
59
 
58
60
  ## 原理
@@ -74,17 +76,16 @@ dsh plugin --profile web add dsh-rewind-plugin
74
76
 
75
77
  1. **写前备份**(`tools/execute`,around-dispatch 阶段):读取目标文件,把解析后的路径与内容放入 pending 表。此阶段只在任何 pre-execute 审批门放行之后运行——审批 `ask` 短路(dsh-edit-approval)**无法跳过**备份,被拒绝的调用也不会记录。若读取失败(如权限错误),该次变更直接不入备份——插件只在日志中警告,**不会阻塞写操作**。
76
78
  2. **落盘提交**(`tools/post-execute`):备份按当前轮**锚点消息 seq** 写入 `~/.dsh/rewind-snapshots/<会话>/<锚点 seq>/<callId>.json`。
77
- 3. **还原**(`/rewind @<seq> both`):锚点 ≥ 目标的每条备份生效——被修改的文件写回其**最早一次**捕获的 before 内容,目标之后新建的文件被删除,符号/硬链接跳过(它们与另一名字共享 inode,透过一个还原会误伤两个)。写入走纯 `node:fs`,不经 fs 服务——sandbox / 远程 backend 下路径解析可能受限。
79
+ 3. **还原**(`/rewind @<seq> both`):锚点 ≥ 目标的备份与磁盘**对账后生效**(改过的写回最早 before、新建的删除、一致的跳过——幂等)。符号/硬链接跳过(它们与另一名字共享 inode,透过一个还原会误伤两个)。写入走纯 `node:fs`,不经 fs 服务——sandbox / 远程 backend 下路径解析可能受限。
78
80
  4. 工具体**抛异常**会跳过 `tools/post-execute`;`tools/result` 兜底清掉 pending,避免内存泄漏。
79
81
 
80
82
  备份跨 host 重启持久化,每会话有界保留最近 100 组锚点。
81
83
 
82
84
  ## 明确不做的事
83
85
 
84
- - **整树 / git-first 快照**——只备份写类工具编辑。`bash`、其他工具与外部程序的修改不在备份内、无法还原:与 Claude Code 相同,官方同样不覆盖,此类回退交由用户 git 处理。
86
+ - **整树 / git-first 快照**——只备份写类工具编辑及已追踪文件的外部改动;从未被工具碰过的文件不还原:与 Claude Code 相同,此类回退交由用户 git 处理。
85
87
  - **子代理(subagent)的编辑**——不跟踪(同 Claude Code):子代理运行在自己的会话里,其备份无法被父会话的回退还原。
86
88
  - **fork / 分支回退与 `/compact`**——harness 已内置(「在新对话中分支」、compact)。
87
- - **快捷键**(esc+esc 打开回退菜单)——规划中的后续项。
88
89
 
89
90
  ## 与同类项目对比
90
91
 
@@ -116,7 +117,7 @@ dsh plugin --profile web add dsh-rewind-plugin
116
117
 
117
118
  ## 安全
118
119
 
119
- 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。文件写入仅在你选择「回退对话和代码」时发生,备份与还原都限定在 `~/.dsh/rewind-snapshots/` 内。不触碰你的 git 仓库,无网络请求,不访问任何凭据。
120
+ 本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份与还原都限定在 `~/.dsh/rewind-snapshots/` 内。不触碰你的 git 仓库,无网络请求,不访问任何凭据。
120
121
 
121
122
  > **注意:** 回退只是把消息从视图中隐藏——导出的会话日志(`/export`)仍包含撤回前的内容,本插件无法改动导出。要彻底删除对话,请删除对应的会话文件。
122
123
 
@@ -127,7 +128,7 @@ npm install # devDeps 来自 npm registry
127
128
  npm run typecheck # tsc 双面编译(host + client)
128
129
  npm test # vitest:rewind / snapshot / hidden / session-cwd / 集成
129
130
  npm run build # esbuild:lib/index.js(host ESM)+ lib/client.js(loader 闭包)+ .d.ts
130
- node scripts/verify-host.mjs # 端到端验证构建产物(18 项检查)
131
+ node scripts/verify-host.mjs # 端到端验证构建产物
131
132
  ```
132
133
 
133
134
  `prepare` 执行完整构建,所以 git 安装与 `npm pack` / `npm publish` 总会产出完整的 `lib/` 与 `LICENSE`。
package/lib/client.js CHANGED
@@ -33,9 +33,9 @@ __export(index_exports, {
33
33
  module.exports = __toCommonJS(index_exports);
34
34
 
35
35
  // src/client/hidden.ts
36
- function targetOfOutcome(text) {
37
- if (text === void 0) return void 0;
38
- const match = text.match(/seq (\d+)/);
36
+ function targetSeqOfArgs(args) {
37
+ if (args === void 0 || args === null) return void 0;
38
+ const match = args.match(/@(\d+)/);
39
39
  return match !== null ? Number(match[1]) : void 0;
40
40
  }
41
41
  function isExecutedRewindCommand(node, seq) {
@@ -48,7 +48,7 @@ function hasFileImpact(text) {
48
48
  if (text === void 0) return true;
49
49
  const match = text.match(/impact=(\d+)/);
50
50
  if (match !== null) return Number(match[1]) > 0;
51
- return text.includes("\u5C06\u5F71\u54CD");
51
+ return false;
52
52
  }
53
53
  function isPreviewCommand(command) {
54
54
  return (command.args ?? "").includes("preview");
@@ -69,7 +69,7 @@ function hiddenSeqsOf(snap) {
69
69
  const marker = command.outcome.sourceEventSeq;
70
70
  if (marker === void 0) continue;
71
71
  hidden.add(command.seq);
72
- const target = targetOfOutcome(command.outcome.text);
72
+ const target = targetSeqOfArgs(command.args);
73
73
  if (target !== void 0) {
74
74
  spans.push({ start: target, end: marker });
75
75
  }
@@ -304,8 +304,14 @@ function formatTarget(t, seq, time, preview) {
304
304
  const previewText = preview.length > 0 ? preview : t("popover.noText");
305
305
  return `seq ${seq} \xB7 ${hh}:${mm} \xB7 ${previewText}`;
306
306
  }
307
- function stripImpactToken(text) {
308
- return text.replace(/\n?impact=\d+\s*$/, "");
307
+ function parseImpactList(text) {
308
+ const restores = [];
309
+ const deletes = [];
310
+ for (const line of text.split("\n")) {
311
+ if (line.startsWith("restore:")) restores.push(line.slice("restore:".length));
312
+ else if (line.startsWith("delete:")) deletes.push(line.slice("delete:".length));
313
+ }
314
+ return { restores, deletes };
309
315
  }
310
316
  function findCommand(snapshot, match) {
311
317
  let found;
@@ -419,7 +425,20 @@ function renderImpactStep(root, opts, back, cached) {
419
425
  impact.textContent = t("popover.impact.failed", { message: outcome.text ?? "unknown error" });
420
426
  return;
421
427
  }
422
- impact.textContent = outcome.text === void 0 ? t("popover.impact.none") : stripImpactToken(outcome.text);
428
+ if (outcome.text === void 0) {
429
+ impact.textContent = t("popover.impact.none");
430
+ } else {
431
+ const { restores, deletes } = parseImpactList(outcome.text);
432
+ if (restores.length === 0 && deletes.length === 0) {
433
+ impact.textContent = t("popover.impact.none");
434
+ } else {
435
+ const lines = [
436
+ ...restores.map((path) => t("popover.impact.restore", { path })),
437
+ ...deletes.map((path) => t("popover.impact.delete", { path }))
438
+ ];
439
+ impact.textContent = lines.join("\n");
440
+ }
441
+ }
423
442
  confirm.disabled = false;
424
443
  confirm.focus();
425
444
  confirm.addEventListener("click", () => {
@@ -738,6 +757,8 @@ var zh = {
738
757
  "popover.impact.loading": "\u6B63\u5728\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\u2026",
739
758
  "popover.impact.failed": "\u65E0\u6CD5\u83B7\u53D6\u5F71\u54CD\u6E05\u5355\uFF1A{message}",
740
759
  "popover.impact.none": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u8DDF\u8E2A\u5230\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002",
760
+ "popover.impact.restore": "\u8FD8\u539F {path}",
761
+ "popover.impact.delete": "\u5220\u9664 {path}",
741
762
  "popover.confirm": "\u786E\u8BA4\u56DE\u9000",
742
763
  "popover.back": "\u8FD4\u56DE",
743
764
  "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"
@@ -757,6 +778,8 @@ var en = {
757
778
  "popover.impact.loading": "Fetching impact list\u2026",
758
779
  "popover.impact.failed": "Could not fetch the impact list: {message}",
759
780
  "popover.impact.none": "No tracked file changes after the target; nothing to restore.",
781
+ "popover.impact.restore": "Restore {path}",
782
+ "popover.impact.delete": "Delete {path}",
760
783
  "popover.confirm": "Confirm rewind",
761
784
  "popover.back": "Back",
762
785
  "guard.hint": "/rewind takes no typed arguments \u2014 enter /rewind to open the rewind picker."
package/lib/index.js CHANGED
@@ -1,6 +1,74 @@
1
1
  // src/index.ts
2
2
  import { createAssistantMessage } from "@deepseek-ai/dsh-llm";
3
3
  import { unlink } from "node:fs/promises";
4
+ import { settingsNamespace } from "@deepseek-ai/dsh-settings";
5
+
6
+ // src/locales.ts
7
+ var en = {
8
+ "usage.title": "Usage:",
9
+ "usage.noArgs": " /rewind (no args) withdraw the most recent user message",
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",
12
+ "describeTarget.seq": "seq {seq}",
13
+ "describeTarget.index": "message {index}",
14
+ "plan.rewinding": "Rewind to seq {targetSeq}, removing {count} node(s) from the model context (conversation log kept).",
15
+ "plan.affects": "Affects {count} file(s):",
16
+ "plan.restore": "restore {path}",
17
+ "plan.delete": "delete {path}",
18
+ "plan.noChanges": "No restorable changes after the target.",
19
+ "error.invalidTarget": 'Cannot parse target "{raw}" (expected <index> or @<seq>)',
20
+ "failures.suffix": "; {count} file(s) failed to restore: {list}",
21
+ "failures.item": "{path} ({message})",
22
+ "inflight": "A rewind is already running for this session; please wait.",
23
+ "stopFailed": "Could not stop the running agent; rewind cancelled. Please try again.",
24
+ "cancelled": "Rewind cancelled.",
25
+ "failed": "Rewind failed: {error}. The session is unchanged.",
26
+ "restore.count": "restored {count} file(s)",
27
+ "delete.count": "deleted {count} file(s)",
28
+ "skip.count": "skipped {count} link(s)",
29
+ "noRestorable": "; no restorable write-class changes after the target",
30
+ "success": "Withdrawn seq {targetSeq} and everything after it (conversation returned to earlier){restore}.",
31
+ "noUserMessages": "This session has no rewindable user messages yet.",
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)"
34
+ };
35
+ var zh = {
36
+ "usage.title": "\u7528\u6CD5\uFF1A",
37
+ "usage.noArgs": " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
38
+ "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",
40
+ "describeTarget.seq": "seq {seq}",
41
+ "describeTarget.index": "\u7B2C {index} \u6761\u6D88\u606F",
42
+ "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",
43
+ "plan.affects": "\u5C06\u5F71\u54CD {count} \u4E2A\u6587\u4EF6\uFF1A",
44
+ "plan.restore": "\u8FD8\u539F {path}",
45
+ "plan.delete": "\u5220\u9664 {path}",
46
+ "plan.noChanges": "\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u9700\u8981\u8FD8\u539F\u7684\u53D8\u66F4\u3002",
47
+ "error.invalidTarget": '\u65E0\u6CD5\u89E3\u6790\u76EE\u6807 "{raw}"\uFF08\u5E94\u4E3A <\u5E8F\u53F7> \u6216 @<seq>\uFF09',
48
+ "failures.suffix": "\uFF1B{count} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A{list}",
49
+ "failures.item": "{path}\uFF08{message}\uFF09",
50
+ "inflight": "\u8BE5\u4F1A\u8BDD\u5DF2\u6709\u4E00\u4E2A\u56DE\u9000\u6B63\u5728\u6267\u884C\uFF0C\u8BF7\u7A0D\u5019\u3002",
51
+ "stopFailed": "\u65E0\u6CD5\u505C\u6B62\u8FD0\u884C\u4E2D\u7684 agent\uFF0C\u56DE\u9000\u5DF2\u53D6\u6D88\u3002\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002",
52
+ "cancelled": "\u56DE\u9000\u5DF2\u53D6\u6D88\u3002",
53
+ "failed": "\u56DE\u9000\u5931\u8D25\uFF1A{error}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002",
54
+ "restore.count": "\u8FD8\u539F {count} \u4E2A\u6587\u4EF6",
55
+ "delete.count": "\u5220\u9664 {count} \u4E2A\u6587\u4EF6",
56
+ "skip.count": "\u8DF3\u8FC7 {count} \u4E2A\u94FE\u63A5",
57
+ "noRestorable": "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4",
58
+ "success": "\u5DF2\u64A4\u56DE seq {targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09{restore}\u3002",
59
+ "noUserMessages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
60
+ "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"
62
+ };
63
+ var HOST_DICTS = { en, zh };
64
+ function translate(lang, key, params = {}) {
65
+ const dict = HOST_DICTS[lang] ?? en;
66
+ let text = dict[key] ?? key;
67
+ for (const [name2, value] of Object.entries(params)) {
68
+ text = text.split(`{${name2}}`).join(String(value));
69
+ }
70
+ return text;
71
+ }
4
72
 
5
73
  // src/rewind.ts
6
74
  var RewindError = class extends Error {
@@ -105,12 +173,24 @@ function execSessionCwd(exec, requestedPath) {
105
173
  }
106
174
 
107
175
  // src/snapshot.ts
176
+ import { createHash } from "node:crypto";
108
177
  import { lstat, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
109
178
  import { dirname, join } from "node:path";
110
179
  import { homedir } from "node:os";
111
180
  var DEFAULT_SNAPSHOT_ROOT = join(homedir(), ".dsh", "rewind-snapshots");
112
181
  var SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
113
182
  var MAX_ANCHOR_GROUPS = 100;
183
+ var defaultProbe = {
184
+ async readText(path) {
185
+ try {
186
+ return await readFile(path, "utf8");
187
+ } catch (error) {
188
+ if (error.code === "ENOENT") return void 0;
189
+ throw error;
190
+ }
191
+ },
192
+ isLink: isLinkPath
193
+ };
114
194
  function safeFileId(callId) {
115
195
  return callId.replace(/[^a-zA-Z0-9._-]/g, "_");
116
196
  }
@@ -212,43 +292,99 @@ var SnapshotStore = class _SnapshotStore {
212
292
  }
213
293
  return earliest;
214
294
  }
215
- /** Per-file restore impact for the earliest entry at/after the target. */
216
- async impactsAfter(sessionId, targetSeq) {
217
- return [...(await this.earliestEntries(sessionId, targetSeq)).values()].sort((a, b) => a.path.localeCompare(b.path)).map((entry) => ({
218
- path: entry.path,
219
- action: entry.before === null ? "delete" : "restore"
220
- }));
221
- }
222
295
  /**
223
- * Restore the workspace to the target message's checkpoint: for every path
224
- * with entries anchored at or after it, apply the EARLIEST entry — write the
225
- * before content back, or delete the file when it was created after the
226
- * target. Symlinked and hard-linked paths are skipped (reported, never
227
- * written through); a restored file's parent directory is created when it
228
- * was deleted after the backup. Failures are per-file and never abort the
229
- * pass.
296
+ * The single source of truth for BOTH the impact preview and the restore
297
+ * pass: reconcile the earliest recorded entry per path (at/after the
298
+ * target) against the CURRENT on-disk state, and plan only the actions
299
+ * that would actually change the disk. This is the Claude Code model
300
+ * `fileHistoryGetDiffStats` / `applySnapshot` both compare against the
301
+ * live filesystem (`checkOriginFileChanged`) and count only real
302
+ * differences, so a rewind whose target state already matches the disk is
303
+ * a no-op with zero impact.
304
+ *
305
+ * - `before === null` (the file did not exist at the target) plans a
306
+ * `delete` ONLY when the file currently exists; an already-absent file
307
+ * is a no-op — this kills the "ghost impact" of replaying an entry a
308
+ * previous rewind already consumed.
309
+ * - `before === 'X'` plans a `restore` ONLY when the current content
310
+ * differs from X (or the file is missing); identical content is a no-op
311
+ * — this keeps repeated rewinds idempotent.
312
+ * - Symlinked / hard-linked paths are never planned (they are reported as
313
+ * skipped by the restore pass, never written through).
314
+ * - A probe failure (e.g. a permission error reading the file) plans the
315
+ * action conservatively as if the file differed, so an unreadable file
316
+ * is never silently dropped from the restore.
317
+ *
318
+ * @param sessionId - session whose snapshot store to plan against.
319
+ * @param targetSeq - rewind target; entries anchored at/after it apply.
320
+ * @param probe - current-disk state probe (defaults to the real FS).
321
+ * @returns the planned actions plus the link paths that were skipped.
230
322
  */
231
- async restoreAfter(sessionId, targetSeq, deleteFile) {
232
- const restored = [];
233
- const deleted = [];
323
+ async planRestore(sessionId, targetSeq, probe) {
324
+ const actions = [];
234
325
  const skipped = [];
235
- const failed = [];
236
326
  for (const entry of (await this.earliestEntries(sessionId, targetSeq)).values()) {
237
327
  try {
238
- if (await isLinkPath(entry.path)) {
328
+ if (await probe.isLink(entry.path)) {
239
329
  skipped.push(entry.path);
240
330
  continue;
241
331
  }
332
+ const current = await probe.readText(entry.path);
242
333
  if (entry.before === null) {
243
- await deleteFile(entry.path);
244
- deleted.push(entry.path);
334
+ if (current !== void 0) actions.push({ path: entry.path, action: "delete" });
335
+ } else if (current !== entry.before) {
336
+ actions.push({ path: entry.path, action: "restore", before: entry.before });
337
+ }
338
+ } catch (error) {
339
+ if (entry.before === null) {
340
+ actions.push({ path: entry.path, action: "delete" });
341
+ } else {
342
+ actions.push({ path: entry.path, action: "restore", before: entry.before });
343
+ }
344
+ }
345
+ }
346
+ return { actions, skipped };
347
+ }
348
+ /** Per-file restore impact: only actions that would actually change the disk. */
349
+ async impactsAfter(sessionId, targetSeq, probe = defaultProbe) {
350
+ const { actions } = await this.planRestore(sessionId, targetSeq, probe);
351
+ return actions.sort((a, b) => a.path.localeCompare(b.path)).map((action) => ({ path: action.path, action: action.action }));
352
+ }
353
+ /**
354
+ * Restore the workspace to the target message's checkpoint: execute exactly
355
+ * the actions {@link planRestore} derived from the record + current disk
356
+ * reconciliation — write the before content back, or delete the file when
357
+ * it was created after the target and still exists. Symlinked and
358
+ * hard-linked paths are skipped (reported, never written through); a
359
+ * restored file's parent directory is created when it was deleted after
360
+ * the backup; a delete whose file is ALREADY absent is a silent no-op (not
361
+ * a failure — the target state is already reached). Failures are per-file
362
+ * and never abort the pass.
363
+ */
364
+ async restoreAfter(sessionId, targetSeq, deleteFile, probe = defaultProbe) {
365
+ const restored = [];
366
+ const deleted = [];
367
+ const skipped = [];
368
+ const failed = [];
369
+ const { actions, skipped: skippedPaths } = await this.planRestore(sessionId, targetSeq, probe);
370
+ skipped.push(...skippedPaths);
371
+ for (const action of actions) {
372
+ try {
373
+ if (action.action === "delete") {
374
+ try {
375
+ await deleteFile(action.path);
376
+ } catch (error) {
377
+ if (error.code !== "ENOENT") throw error;
378
+ continue;
379
+ }
380
+ deleted.push(action.path);
245
381
  } else {
246
- await mkdir(dirname(entry.path), { recursive: true });
247
- await writeFile(entry.path, entry.before, "utf8");
248
- restored.push(entry.path);
382
+ await mkdir(dirname(action.path), { recursive: true });
383
+ await writeFile(action.path, action.before, "utf8");
384
+ restored.push(action.path);
249
385
  }
250
386
  } catch (error) {
251
- failed.push({ path: entry.path, message: error instanceof Error ? error.message : String(error) });
387
+ failed.push({ path: action.path, message: error instanceof Error ? error.message : String(error) });
252
388
  }
253
389
  }
254
390
  return { restored, deleted, skipped, failed };
@@ -283,19 +419,64 @@ var SnapshotStore = class _SnapshotStore {
283
419
  throw error;
284
420
  }
285
421
  }
422
+ /**
423
+ * All distinct paths ever recorded for a session — the "tracked files"
424
+ * set. Mirrors Claude Code's global `trackedFiles` collection (files stay
425
+ * tracked once a write-class tool touched them), derived from the disk
426
+ * entries so no extra persistence is needed.
427
+ */
428
+ async trackedPaths(sessionId) {
429
+ const paths = /* @__PURE__ */ new Set();
430
+ for (const entry of await this.entriesAfter(sessionId, 0)) {
431
+ paths.add(entry.path);
432
+ }
433
+ return paths;
434
+ }
286
435
  };
436
+ function hashPath(path) {
437
+ return createHash("sha256").update(path).digest("hex").slice(0, 8);
438
+ }
439
+ async function reconcileTracked(store, sessionId, anchorSeq, tracked, states, probe = defaultProbe) {
440
+ let recorded = 0;
441
+ for (const path of tracked) {
442
+ try {
443
+ if (await probe.isLink(path)) continue;
444
+ const current = await probe.readText(path);
445
+ const state = current ?? null;
446
+ const prev = states.get(path);
447
+ if (prev === void 0 || prev !== state) {
448
+ await store.recordEntry(sessionId, {
449
+ callId: `recheck-${anchorSeq}-${hashPath(path)}`,
450
+ anchorSeq,
451
+ path,
452
+ before: state
453
+ });
454
+ states.set(path, state);
455
+ recorded++;
456
+ }
457
+ } catch {
458
+ }
459
+ }
460
+ return recorded;
461
+ }
287
462
 
288
463
  // src/index.ts
289
464
  var name = "dsh-rewind";
290
465
  var inject = ["commands", "tools"];
291
466
  var TRACKED_TOOLS = /* @__PURE__ */ new Set(["write", "edit", "str_replace_editor"]);
292
467
  var MUTATING_EDITOR_COMMANDS = /* @__PURE__ */ new Set(["create", "str_replace", "insert"]);
293
- var USAGE = [
294
- "Usage:",
295
- " /rewind \uFF08\u65E0\u53C2\u6570\uFF09\u64A4\u56DE\u6700\u8FD1\u4E00\u6761\u7528\u6237\u6D88\u606F",
296
- " /rewind @<seq> chat|both \u56DE\u9000\u5230\u6307\u5B9A\u6D88\u606F\uFF08chat \u4EC5\u5BF9\u8BDD / both \u5BF9\u8BDD+\u6587\u4EF6\uFF09",
297
- " \u624B\u52A8\u8F93\u5165 /rewind \u4F1A\u88AB\u62E6\u622A\uFF0C\u8BF7\u4F7F\u7528\u6D88\u606F\u65C1\u7684\u300C\u56DE\u9000\u300D\u6309\u94AE"
298
- ].join("\n");
468
+ var activeLocale = "en";
469
+ function t(key, params) {
470
+ return translate(activeLocale, key, params);
471
+ }
472
+ function usage() {
473
+ return [
474
+ t("usage.title"),
475
+ t("usage.noArgs"),
476
+ t("usage.seq"),
477
+ t("usage.blocked")
478
+ ].join("\n");
479
+ }
299
480
  function mutationPathOf(exec) {
300
481
  const args = exec.arguments;
301
482
  if (exec.name === "write" || exec.name === "edit") {
@@ -352,7 +533,7 @@ async function captureBefore(fs, exec, pending) {
352
533
  const before = await readTextOrUndefined(fs, target, exec.signal);
353
534
  pending.set(`${exec.agent?.id ?? "anon"}:${exec.callId}`, { path: target.displayPath, before });
354
535
  }
355
- async function commitEntry(store, pending, anchorCache, exec, result) {
536
+ async function commitEntry(store, pending, anchorCache, trackedBySession, exec, result) {
356
537
  const key = `${exec.agent?.id ?? "anon"}:${exec.callId}`;
357
538
  const capture = pending.get(key);
358
539
  if (capture === void 0) return;
@@ -368,6 +549,12 @@ async function commitEntry(store, pending, anchorCache, exec, result) {
368
549
  path: capture.path,
369
550
  before: capture.before ?? null
370
551
  });
552
+ let tracked = trackedBySession.get(agent.session.id);
553
+ if (tracked === void 0) {
554
+ tracked = /* @__PURE__ */ new Set();
555
+ trackedBySession.set(agent.session.id, tracked);
556
+ }
557
+ tracked.add(capture.path);
371
558
  }
372
559
  function buildMarker() {
373
560
  return createAssistantMessage({
@@ -376,33 +563,62 @@ function buildMarker() {
376
563
  });
377
564
  }
378
565
  function describeTarget(target) {
379
- return target.kind === "seq" ? `seq ${target.seq}` : `\u7B2C ${target.index} \u6761\u6D88\u606F`;
566
+ return target.kind === "seq" ? t("describeTarget.seq", { seq: target.seq }) : t("describeTarget.index", { index: target.index });
380
567
  }
381
568
  function formatPlan(plan, files) {
382
569
  const lines = [
383
- `\u5C06\u56DE\u9000\u5230 seq ${plan.targetSeq}\uFF0C\u4ECE\u6A21\u578B\u4E0A\u4E0B\u6587\u79FB\u9664 ${plan.shadowedSeqs.length} \u4E2A\u8282\u70B9\uFF08\u5BF9\u8BDD\u65E5\u5FD7\u4FDD\u7559\uFF09\u3002`
570
+ t("plan.rewinding", { targetSeq: plan.targetSeq, count: plan.shadowedSeqs.length })
384
571
  ];
385
572
  if (files.length > 0) {
386
- lines.push(`\u5C06\u5F71\u54CD ${files.length} \u4E2A\u6587\u4EF6\uFF1A`);
573
+ lines.push(t("plan.affects", { count: files.length }));
387
574
  for (const file of files) {
388
- lines.push(` ${file.action === "restore" ? "\u8FD8\u539F" : "\u5220\u9664"} ${file.path}`);
575
+ lines.push(` ${file.action === "restore" ? t("plan.restore", { path: file.path }) : t("plan.delete", { path: file.path })}`);
389
576
  }
390
577
  } else {
391
- lines.push("\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u5FEB\u7167\u8BB0\u5F55\u7684\u5199\u7C7B\u53D8\u66F4\uFF0C\u65E0\u9700\u8FD8\u539F\u6587\u4EF6\u3002");
578
+ lines.push(t("plan.noChanges"));
392
579
  }
393
580
  lines.push(`impact=${files.length}`);
581
+ for (const file of files) {
582
+ lines.push(`${file.action}:${file.path}`);
583
+ }
394
584
  return lines.join("\n");
395
585
  }
396
586
  function resolveOrError(events, surface, raw) {
397
587
  const target = parseRewindTarget(raw);
398
588
  if (target === void 0) {
399
- throw new RewindError("invalid-index", `\u65E0\u6CD5\u89E3\u6790\u76EE\u6807 "${raw}"\uFF08\u5E94\u4E3A <\u5E8F\u53F7> \u6216 @<seq>\uFF09`);
589
+ throw new RewindError("invalid-index", t("error.invalidTarget", { raw }));
400
590
  }
401
591
  return planRewind(events, surface, target);
402
592
  }
403
593
  function renderFailures(failed) {
404
594
  if (failed.length === 0) return "";
405
- return `\uFF1B${failed.length} \u4E2A\u6587\u4EF6\u8FD8\u539F\u5931\u8D25\uFF1A${failed.map((f) => `${f.path}\uFF08${f.message}\uFF09`).join("\u3001")}`;
595
+ return t("failures.suffix", {
596
+ count: failed.length,
597
+ list: failed.map((f) => t("failures.item", { path: f.path, message: f.message })).join("\u3001")
598
+ });
599
+ }
600
+ async function resolveObservationTarget(fs, path) {
601
+ try {
602
+ return await fs.resolve(path);
603
+ } catch {
604
+ return void 0;
605
+ }
606
+ }
607
+ async function syncRestoreObservations(ctx, fs, agent, outcome) {
608
+ if (fs === void 0) return;
609
+ const actor = { agent };
610
+ for (const path of outcome.deleted) {
611
+ const target = await resolveObservationTarget(fs, path);
612
+ if (target === void 0) continue;
613
+ ctx.emit("fs/observed", target, { kind: "absent" }, actor);
614
+ }
615
+ for (const path of outcome.restored) {
616
+ const target = await resolveObservationTarget(fs, path);
617
+ if (target === void 0) continue;
618
+ const info = await fs.stat(target);
619
+ if (info === void 0) continue;
620
+ ctx.emit("fs/observed", target, { kind: "present", version: info.version }, actor);
621
+ }
406
622
  }
407
623
  async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
408
624
  if (signal.aborted) return false;
@@ -425,11 +641,11 @@ async function waitForAgentIdle(agent, signal, timeoutMs = 15e3) {
425
641
  if (onAbort !== void 0) signal.removeEventListener("abort", onAbort);
426
642
  }
427
643
  }
428
- async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight) {
644
+ async function executeRewind(ctx, store, fs, invocation, rawTarget, mode, inflight) {
429
645
  const { agent } = invocation;
430
646
  const sessionId = agent.session.id;
431
647
  if (inflight.has(sessionId)) {
432
- return { kind: "error", text: "\u8BE5\u4F1A\u8BDD\u5DF2\u6709\u4E00\u4E2A\u56DE\u9000\u6B63\u5728\u6267\u884C\uFF0C\u8BF7\u7A0D\u5019\u3002" };
648
+ return { kind: "error", text: t("inflight") };
433
649
  }
434
650
  inflight.add(sessionId);
435
651
  try {
@@ -437,11 +653,11 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
437
653
  agent.cancel({ kind: "user" });
438
654
  const stopped = await waitForAgentIdle(agent, invocation.signal);
439
655
  if (!stopped) {
440
- return { kind: "error", text: "\u65E0\u6CD5\u505C\u6B62\u8FD0\u884C\u4E2D\u7684 agent\uFF0C\u56DE\u9000\u5DF2\u53D6\u6D88\u3002\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002" };
656
+ return { kind: "error", text: t("stopFailed") };
441
657
  }
442
658
  }
443
659
  if (invocation.signal.aborted) {
444
- return { kind: "error", text: "\u56DE\u9000\u5DF2\u53D6\u6D88\u3002" };
660
+ return { kind: "error", text: t("cancelled") };
445
661
  }
446
662
  let plan;
447
663
  try {
@@ -459,22 +675,23 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
459
675
  } catch (error) {
460
676
  return {
461
677
  kind: "error",
462
- text: `\u56DE\u9000\u5931\u8D25\uFF1A${error instanceof Error ? error.message : String(error)}\u3002\u4F1A\u8BDD\u672A\u6539\u53D8\u3002`
678
+ text: t("failed", { error: error instanceof Error ? error.message : String(error) })
463
679
  };
464
680
  }
465
681
  let restore = "";
466
682
  if (mode === "both") {
467
683
  const outcome = await store.restoreAfter(agent.session.id, plan.targetSeq, (path) => unlink(path));
684
+ await syncRestoreObservations(ctx, fs, agent, outcome);
468
685
  const parts = [];
469
- if (outcome.restored.length > 0) parts.push(`\u8FD8\u539F ${outcome.restored.length} \u4E2A\u6587\u4EF6`);
470
- if (outcome.deleted.length > 0) parts.push(`\u5220\u9664 ${outcome.deleted.length} \u4E2A\u6587\u4EF6`);
471
- if (outcome.skipped.length > 0) parts.push(`\u8DF3\u8FC7 ${outcome.skipped.length} \u4E2A\u94FE\u63A5`);
472
- restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : "\uFF1B\u76EE\u6807\u4E4B\u540E\u6CA1\u6709\u53EF\u8FD8\u539F\u7684\u5199\u7C7B\u53D8\u66F4";
686
+ if (outcome.restored.length > 0) parts.push(t("restore.count", { count: outcome.restored.length }));
687
+ if (outcome.deleted.length > 0) parts.push(t("delete.count", { count: outcome.deleted.length }));
688
+ if (outcome.skipped.length > 0) parts.push(t("skip.count", { count: outcome.skipped.length }));
689
+ restore = parts.length > 0 ? `\uFF1B${parts.join("\u3001")}` : t("noRestorable");
473
690
  restore += renderFailures(outcome.failed);
474
691
  }
475
692
  return {
476
693
  kind: "success",
477
- text: `\u5DF2\u64A4\u56DE seq ${plan.targetSeq} \u53CA\u4E4B\u540E\u5185\u5BB9\uFF08\u5BF9\u8BDD\u5DF2\u56DE\u5230\u6B64\u524D\uFF09${restore}\u3002`,
694
+ text: t("success", { targetSeq: plan.targetSeq, restore }),
478
695
  sourceEventSeq: event.seq
479
696
  };
480
697
  } finally {
@@ -484,7 +701,7 @@ async function executeRewind(ctx, store, invocation, rawTarget, mode, inflight)
484
701
  function rewindErrorResult(error) {
485
702
  if (error instanceof RewindError) {
486
703
  const text = {
487
- "no-user-messages": "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002",
704
+ "no-user-messages": t("noUserMessages"),
488
705
  "invalid-index": error.message,
489
706
  "not-a-user-message": error.message,
490
707
  "not-on-surface": error.message
@@ -493,20 +710,20 @@ function rewindErrorResult(error) {
493
710
  }
494
711
  throw error;
495
712
  }
496
- async function handleRewind(ctx, store, invocation, inflight) {
713
+ async function handleRewind(ctx, store, fs, invocation, inflight) {
497
714
  const session = invocation.agent.session;
498
715
  const input = invocation.rawInput.trim();
499
716
  if (input === "") {
500
717
  const candidates = listRewindCandidates(session.events, session.surface.nodes, 1);
501
718
  if (candidates.length === 0) {
502
- return { kind: "error", text: "\u5F53\u524D\u4F1A\u8BDD\u8FD8\u6CA1\u6709\u53EF\u56DE\u9000\u7684\u7528\u6237\u6D88\u606F\u3002" };
719
+ return { kind: "error", text: t("noUserMessages") };
503
720
  }
504
- return executeRewind(ctx, store, invocation, `@${candidates[0].seq}`, "chat", inflight);
721
+ return executeRewind(ctx, store, fs, invocation, `@${candidates[0].seq}`, "chat", inflight);
505
722
  }
506
723
  const parts = input.split(/\s+/);
507
724
  if (parts[0] === "preview") {
508
725
  const target2 = parts[1];
509
- if (target2 === void 0) return { kind: "error", text: USAGE };
726
+ if (target2 === void 0) return { kind: "error", text: usage() };
510
727
  let plan;
511
728
  try {
512
729
  plan = resolveOrError(session.events, session.surface.nodes, target2);
@@ -519,34 +736,66 @@ async function handleRewind(ctx, store, invocation, inflight) {
519
736
  const target = parts[0];
520
737
  const mode = parts[1];
521
738
  if (mode !== void 0 && mode !== "chat" && mode !== "both") {
522
- return { kind: "error", text: USAGE };
739
+ return { kind: "error", text: usage() };
523
740
  }
524
741
  if (mode === void 0) {
525
742
  const parsed = parseRewindTarget(target);
526
- if (parsed === void 0) return { kind: "error", text: USAGE };
743
+ if (parsed === void 0) return { kind: "error", text: usage() };
527
744
  return {
528
745
  kind: "success",
529
- text: `\u5C06\u56DE\u9000\u5230 ${describeTarget(parsed)}\u3002\u9009\u62E9\u6A21\u5F0F\uFF1A
530
- /rewind ${target} chat \u4EC5\u56DE\u9000\u5BF9\u8BDD
531
- /rewind ${target} both \u56DE\u9000\u5BF9\u8BDD\u5E76\u8FD8\u539F\u6587\u4EF6`
746
+ text: t("chooseMode", { target: describeTarget(parsed) })
532
747
  };
533
748
  }
534
- return executeRewind(ctx, store, invocation, target, mode, inflight);
749
+ return executeRewind(ctx, store, fs, invocation, target, mode, inflight);
535
750
  }
536
751
  function apply(ctx, config) {
537
752
  const store = new SnapshotStore(config?.snapshotDir);
538
753
  const pending = /* @__PURE__ */ new Map();
539
754
  const anchorCache = /* @__PURE__ */ new WeakMap();
540
755
  const inflight = /* @__PURE__ */ new Set();
756
+ const trackedBySession = /* @__PURE__ */ new Map();
757
+ const statesBySession = /* @__PURE__ */ new Map();
758
+ let fsService;
759
+ ctx.inject(["settings"], (settingsCtx) => {
760
+ const section = settingsCtx.settings.get(settingsNamespace("locale"));
761
+ if (section?.preference === "zh" || section?.preference === "en") {
762
+ activeLocale = section.preference;
763
+ }
764
+ });
541
765
  ctx.effect(function* () {
542
766
  yield ctx.commands.register({
543
767
  name: "rewind",
544
- 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",
545
- handler: (invocation) => handleRewind(ctx, store, invocation, inflight)
768
+ description: t("command.description"),
769
+ handler: (invocation) => handleRewind(ctx, store, fsService, invocation, inflight)
546
770
  });
547
771
  }, "dsh-rewind command");
772
+ ctx.on("session/event", (session, event) => {
773
+ if (event.type !== "user/message") return;
774
+ const header = session.header;
775
+ if (header.origin === "subagent" || (header.delegationDepth ?? 0) > 0) return;
776
+ void (async () => {
777
+ try {
778
+ const sessionId = session.id;
779
+ let tracked = trackedBySession.get(sessionId);
780
+ if (tracked === void 0) {
781
+ tracked = await store.trackedPaths(sessionId);
782
+ trackedBySession.set(sessionId, tracked);
783
+ }
784
+ if (tracked.size === 0) return;
785
+ let states = statesBySession.get(sessionId);
786
+ if (states === void 0) {
787
+ states = /* @__PURE__ */ new Map();
788
+ statesBySession.set(sessionId, states);
789
+ }
790
+ await reconcileTracked(store, sessionId, event.seq, tracked, states);
791
+ } catch (error) {
792
+ ctx.logger.warn(`[dsh-rewind] boundary re-check failed: ${error instanceof Error ? error.message : String(error)}`);
793
+ }
794
+ })();
795
+ }, { global: true });
548
796
  ctx.inject(["fs"], (scope) => {
549
797
  const fs = scope.fs;
798
+ fsService = fs;
550
799
  scope.on("tools/execute", async (exec, next) => {
551
800
  try {
552
801
  await captureBefore(fs, exec, pending);
@@ -557,7 +806,7 @@ function apply(ctx, config) {
557
806
  });
558
807
  scope.on("tools/post-execute", async (exec, result, next) => {
559
808
  try {
560
- await commitEntry(store, pending, anchorCache, exec, result);
809
+ await commitEntry(store, pending, anchorCache, trackedBySession, exec, result);
561
810
  } catch (error) {
562
811
  ctx.logger.warn(`[dsh-rewind] checkpoint commit failed for ${exec.name}: ${error instanceof Error ? error.message : String(error)}`);
563
812
  }
@@ -13,8 +13,12 @@ export interface HiddenChat {
13
13
  get(key: string): ChatConversationViewNode | undefined;
14
14
  };
15
15
  }
16
- /** Extract the rewind target from a command outcome text ("已撤回 seq N..."). */
17
- export declare function targetOfOutcome(text: string | undefined): number | undefined;
16
+ /**
17
+ * Extract the rewind target seq from a `/rewind` command's structured `args`
18
+ * (e.g. `@5 chat`, `preview @5 both`). Locale-independent — never parses the
19
+ * host's human outcome copy.
20
+ */
21
+ export declare function targetSeqOfArgs(args: string | null | undefined): number | undefined;
18
22
  /**
19
23
  * True when a `/rewind` command node is an EXECUTED rewind for `seq` — the
20
24
  * admission form the popover drives (`@<seq> chat` / `both`) that settled
@@ -28,10 +32,10 @@ export declare function isExecutedRewindCommand(node: CommandNode, seq: number):
28
32
  * of the "rewind conversation and code" option (Claude Code hides the
29
33
  * code-restore options when the checkpoint has no tracked changes).
30
34
  *
31
- * Prefers the machine-readable `impact=<n>` trailer the current host appends
32
- * to preview text. Older host output (or a history-loaded preview row from
33
- * before the trailer existed) has none, so it falls back to the human copy
34
- * ("将影响 …") to keep mixed-version deployments correct.
35
+ * Reads ONLY the machine-readable `impact=<n>` trailer the host appends to
36
+ * preview text. Older host output without the trailer is treated as having no
37
+ * changes (never guesses from human copy). Unknown/absent text degrades to
38
+ * always-show so a working option is never hidden on a failed probe.
35
39
  */
36
40
  export declare function hasFileImpact(text: string | undefined): boolean;
37
41
  /**
@@ -15,6 +15,8 @@ export declare const zh: {
15
15
  'popover.impact.loading': string;
16
16
  'popover.impact.failed': string;
17
17
  'popover.impact.none': string;
18
+ 'popover.impact.restore': string;
19
+ 'popover.impact.delete': string;
18
20
  'popover.confirm': string;
19
21
  'popover.back': string;
20
22
  'guard.hint': string;
@@ -43,6 +45,8 @@ export declare const en: {
43
45
  'popover.impact.loading': string;
44
46
  'popover.impact.failed': string;
45
47
  'popover.impact.none': string;
48
+ 'popover.impact.restore': string;
49
+ 'popover.impact.delete': string;
46
50
  'popover.confirm': string;
47
51
  'popover.back': string;
48
52
  'guard.hint': string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Host-side localization for dsh-rewind's `/rewind` command output and command
3
+ * description.
4
+ *
5
+ * Architecture (matches the dsh ecosystem): the HOST half of a dual-face
6
+ * plugin has no locale service — only the browser client carries one. The host
7
+ * therefore renders its command-adjacent copy from a durable user preference
8
+ * (`ctx.settings` → `locale.preference`, registered by dsh-client-locale),
9
+ * defaulting to English — the ecosystem's neutral default language (the harness
10
+ * `FALLBACK_LOCALE` and the language dsh's own host commands use, e.g.
11
+ * dsh-plan-mode). See packages/client/locale in deepseek-harness.
12
+ *
13
+ * The client half (`src/client/locales.ts`) owns all interactive UI copy via
14
+ * `ctx.locale` + `t()`; the host's human text is a machine channel the client
15
+ * renders through machine tokens (`impact=<n>`, `args` @seq), never by parsing
16
+ * host prose.
17
+ *
18
+ * English is the key-set source of truth; zh is checked complete against it.
19
+ *
20
+ * @module dsh-rewind/locales
21
+ */
22
+ /** Host-side supported locale ids, mirroring the harness's shipped locales. */
23
+ export type HostLocaleId = 'zh' | 'en';
24
+ /** English dictionary — the key-set source of truth (neutral default). */
25
+ export declare const en: {
26
+ 'usage.title': string;
27
+ 'usage.noArgs': string;
28
+ 'usage.seq': string;
29
+ 'usage.blocked': string;
30
+ 'describeTarget.seq': string;
31
+ 'describeTarget.index': string;
32
+ 'plan.rewinding': string;
33
+ 'plan.affects': string;
34
+ 'plan.restore': string;
35
+ 'plan.delete': string;
36
+ 'plan.noChanges': string;
37
+ 'error.invalidTarget': string;
38
+ 'failures.suffix': string;
39
+ 'failures.item': string;
40
+ inflight: string;
41
+ stopFailed: string;
42
+ cancelled: string;
43
+ failed: string;
44
+ 'restore.count': string;
45
+ 'delete.count': string;
46
+ 'skip.count': string;
47
+ noRestorable: string;
48
+ success: string;
49
+ noUserMessages: string;
50
+ chooseMode: string;
51
+ 'command.description': string;
52
+ };
53
+ /** The host rewind dictionary key union. */
54
+ export type HostKey = keyof typeof en;
55
+ /** Chinese dictionary, checked complete against the en key set. */
56
+ export declare const zh: Record<HostKey, string>;
57
+ /** The host dictionaries keyed by locale id. */
58
+ export declare const HOST_DICTS: Record<HostLocaleId, Record<HostKey, string>>;
59
+ /**
60
+ * Render one dictionary key with `{name}` template interpolation. Unknown
61
+ * params are ignored; a missing key falls back to the raw key so a dictionary
62
+ * gap is visible instead of blank.
63
+ * @param lang - the active locale.
64
+ * @param key - the dictionary key.
65
+ * @param params - `{name}` substitution values.
66
+ */
67
+ export declare function translate(lang: HostLocaleId, key: HostKey, params?: Record<string, string | number>): string;
@@ -65,6 +65,34 @@ export interface RestoreOutcome {
65
65
  }
66
66
  /** Deletes one file by its real path (node:fs, bypassing the fs service). */
67
67
  export type DeleteFile = (path: string) => Promise<void>;
68
+ /**
69
+ * Current-on-disk state probe used by restore planning. Injected so the plan
70
+ * logic runs against a fake FS in tests; the production default reads the
71
+ * real file system with plain `node:fs` (see {@link defaultProbe}).
72
+ */
73
+ export interface DiskProbe {
74
+ /**
75
+ * Full text of the file, or undefined when the file does not exist.
76
+ * Any thrown error is treated as a probe failure: restore planning then
77
+ * conservatively treats the file as DIFFERING from its record (a restore
78
+ * still attempts the write / a delete still attempts the unlink), so an
79
+ * unreadable file is never silently skipped.
80
+ */
81
+ readText(path: string): Promise<string | undefined>;
82
+ /** True when the path is a symlink or a hard link (never planned/restored). */
83
+ isLink(path: string): Promise<boolean>;
84
+ }
85
+ /** One restore action the planner derived from record + disk reconciliation. */
86
+ export type PlannedAction = {
87
+ readonly path: string;
88
+ readonly action: 'restore';
89
+ readonly before: string;
90
+ } | {
91
+ readonly path: string;
92
+ readonly action: 'delete';
93
+ };
94
+ /** Production probe: real reads via node:fs, links detected by lstat + nlink. */
95
+ export declare const defaultProbe: DiskProbe;
68
96
  /**
69
97
  * On-disk checkpoint store. Every write goes straight through `node:fs`, so a
70
98
  * restore reliably lands on the real file system.
@@ -94,18 +122,49 @@ export declare class SnapshotStore {
94
122
  * single source of truth for both restore and impact preview.
95
123
  */
96
124
  private earliestEntries;
97
- /** Per-file restore impact for the earliest entry at/after the target. */
98
- impactsAfter(sessionId: string, targetSeq: number): Promise<FileImpact[]>;
99
125
  /**
100
- * Restore the workspace to the target message's checkpoint: for every path
101
- * with entries anchored at or after it, apply the EARLIEST entry — write the
102
- * before content back, or delete the file when it was created after the
103
- * target. Symlinked and hard-linked paths are skipped (reported, never
104
- * written through); a restored file's parent directory is created when it
105
- * was deleted after the backup. Failures are per-file and never abort the
106
- * pass.
126
+ * The single source of truth for BOTH the impact preview and the restore
127
+ * pass: reconcile the earliest recorded entry per path (at/after the
128
+ * target) against the CURRENT on-disk state, and plan only the actions
129
+ * that would actually change the disk. This is the Claude Code model
130
+ * `fileHistoryGetDiffStats` / `applySnapshot` both compare against the
131
+ * live filesystem (`checkOriginFileChanged`) and count only real
132
+ * differences, so a rewind whose target state already matches the disk is
133
+ * a no-op with zero impact.
134
+ *
135
+ * - `before === null` (the file did not exist at the target) plans a
136
+ * `delete` ONLY when the file currently exists; an already-absent file
137
+ * is a no-op — this kills the "ghost impact" of replaying an entry a
138
+ * previous rewind already consumed.
139
+ * - `before === 'X'` plans a `restore` ONLY when the current content
140
+ * differs from X (or the file is missing); identical content is a no-op
141
+ * — this keeps repeated rewinds idempotent.
142
+ * - Symlinked / hard-linked paths are never planned (they are reported as
143
+ * skipped by the restore pass, never written through).
144
+ * - A probe failure (e.g. a permission error reading the file) plans the
145
+ * action conservatively as if the file differed, so an unreadable file
146
+ * is never silently dropped from the restore.
147
+ *
148
+ * @param sessionId - session whose snapshot store to plan against.
149
+ * @param targetSeq - rewind target; entries anchored at/after it apply.
150
+ * @param probe - current-disk state probe (defaults to the real FS).
151
+ * @returns the planned actions plus the link paths that were skipped.
152
+ */
153
+ private planRestore;
154
+ /** Per-file restore impact: only actions that would actually change the disk. */
155
+ impactsAfter(sessionId: string, targetSeq: number, probe?: DiskProbe): Promise<FileImpact[]>;
156
+ /**
157
+ * Restore the workspace to the target message's checkpoint: execute exactly
158
+ * the actions {@link planRestore} derived from the record + current disk
159
+ * reconciliation — write the before content back, or delete the file when
160
+ * it was created after the target and still exists. Symlinked and
161
+ * hard-linked paths are skipped (reported, never written through); a
162
+ * restored file's parent directory is created when it was deleted after
163
+ * the backup; a delete whose file is ALREADY absent is a silent no-op (not
164
+ * a failure — the target state is already reached). Failures are per-file
165
+ * and never abort the pass.
107
166
  */
108
- restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile): Promise<RestoreOutcome>;
167
+ restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile, probe?: DiskProbe): Promise<RestoreOutcome>;
109
168
  /**
110
169
  * Drop the session's oldest anchor groups beyond `keep` (default
111
170
  * {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
@@ -113,4 +172,42 @@ export declare class SnapshotStore {
113
172
  prune(sessionId: string, keep?: number): Promise<void>;
114
173
  /** True when a path exists on disk (used by tests and diagnostics). */
115
174
  exists(path: string): Promise<boolean>;
175
+ /**
176
+ * All distinct paths ever recorded for a session — the "tracked files"
177
+ * set. Mirrors Claude Code's global `trackedFiles` collection (files stay
178
+ * tracked once a write-class tool touched them), derived from the disk
179
+ * entries so no extra persistence is needed.
180
+ */
181
+ trackedPaths(sessionId: string): Promise<Set<string>>;
116
182
  }
183
+ /**
184
+ * Re-check every tracked file at a user-message boundary and record the
185
+ * current on-disk state for any file whose state changed since it was last
186
+ * seen — Claude Code's `fileHistoryMakeSnapshot` re-stats every tracked file
187
+ * at each user message and snapshots the new state (changed files get a new
188
+ * backup version, deleted files a null marker). Here the "new version" is a
189
+ * plain before-backup entry anchored at the boundary message, so an EXTERNAL
190
+ * edit or deletion (never seen by the write-class tool capture) enters the
191
+ * record and can be restored by a later rewind.
192
+ *
193
+ * Semantics: the recorded `before` is the file's state at the boundary —
194
+ * the state the boundary message's turn starts from, exactly like the
195
+ * tool-captured entries. An entry is written only when the state differs
196
+ * from the last-seen state (`states`); the FIRST sighting of a path always
197
+ * records (a restart leaves `states` empty, so the first boundary after a
198
+ * restart unconditionally records the current state — redundant but correct,
199
+ * mirroring Claude's resume-then-re-stat behavior).
200
+ *
201
+ * Symlinked / hard-linked paths are never re-checked (restores skip them).
202
+ * A probe failure skips the file with a warning-level no-op; it never
203
+ * aborts the boundary pass.
204
+ *
205
+ * @param store - the session's snapshot store.
206
+ * @param sessionId - session whose tracked files to re-check.
207
+ * @param anchorSeq - the boundary user-message seq (entry anchor).
208
+ * @param tracked - the session's tracked path set (read-only here).
209
+ * @param states - per-path last-seen state (path → content, null = absent).
210
+ * @param probe - current-disk state probe (defaults to the real FS).
211
+ * @returns the number of entries recorded.
212
+ */
213
+ export declare function reconcileTracked(store: SnapshotStore, sessionId: string, anchorSeq: number, tracked: ReadonlySet<string>, states: Map<string, string | null>, probe?: DiskProbe): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rewind-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -82,6 +82,7 @@
82
82
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
83
83
  "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6",
84
84
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
85
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
85
86
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
86
87
  },
87
88
  "peerDependenciesMeta": {
@@ -115,6 +116,9 @@
115
116
  "@deepseek-ai/dsh-session": {
116
117
  "optional": true
117
118
  },
119
+ "@deepseek-ai/dsh-settings": {
120
+ "optional": true
121
+ },
118
122
  "@deepseek-ai/dsh-tools": {
119
123
  "optional": true
120
124
  }
@@ -132,6 +136,7 @@
132
136
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
133
137
  "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.7",
134
138
  "@deepseek-ai/dsh-session": "^0.1.0-rc.7",
139
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
135
140
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
136
141
  "@types/node": "^24.0.0",
137
142
  "@types/react": "^18.3.31",